Flovyn vs Java Ecosystem
Common Java approaches for workflow orchestration: Spring Batch, Quartz Scheduler, Camunda/Flowable BPMN, or manual state management. Flovyn provides durable execution without the complexity.
Quick Summary
| Tool | Best For | Limitation |
|---|---|---|
| Spring Batch | ETL, batch jobs | No long-running workflows |
| Quartz | Scheduled jobs | No state between runs |
| Camunda/Flowable | BPMN diagrams | XML/diagram-driven, heavyweight |
| Spring + DB | Custom workflows | You build everything yourself |
| Flovyn | Durable workflows | Code-first, event sourcing |
Spring Batch Comparison
Spring Batch is designed for batch processing (read → process → write). Not for multi-step business workflows.
Batch Processing
Spring Batch
@Configuration
public class BatchConfig {
@Bean
public Job importJob(JobRepository repo, Step step1, Step step2) {
return new JobBuilder("importJob", repo)
.start(step1)
.next(step2)
.build();
}
@Bean
public Step step1(JobRepository repo, PlatformTransactionManager tx) {
return new StepBuilder("step1", repo)
.<Input, Output>chunk(100, tx)
.reader(reader())
.processor(processor())
.writer(writer())
.faultTolerant()
.retryLimit(3)
.retry(TransientException.class)
.build();
}
}
// Lots of configuration, rigid chunk-based modelFlovyn
class ImportWorkflow : WorkflowDefinition<ImportInput, ImportResult>() {
override val kind = "import-job"
override val name = "Import Job"
override suspend fun execute(ctx: WorkflowContext, input: ImportInput): ImportResult {
val records = ctx.run("read-records") { readFromSource(input.source) }
val processed = records.mapIndexed { i, record ->
ctx.run("process-$i") { transform(record) }
}
ctx.run("write-results") { writeToDestination(processed) }
return ImportResult(processed.size)
}
}Where Spring Batch Falls Short
// Spring Batch cannot:
// 1. Wait for external approval mid-job
// 2. Sleep for days then resume
// 3. Handle webhooks triggering next steps
// 4. Coordinate with external services with compensationclass DataReviewWorkflow : WorkflowDefinition<ReviewInput, ReviewResult>() {
override val kind = "data-review"
override suspend fun execute(ctx: WorkflowContext, input: ReviewInput): ReviewResult {
val imported = ctx.run("import") { importData(input) }
// Wait for manual review (days/weeks) - worker NOT blocked
val approval = ctx.promise<Approval>("review-approval", timeout = Duration.ofDays(7))
val decision = try {
approval.await()
} catch (e: TimeoutException) {
null
}
if (decision?.approved == true) {
ctx.run("publish") { publishData(imported) }
} else {
ctx.run("archive") { archiveData(imported) }
}
return ReviewResult(status = "completed")
}
}Quartz Scheduler Comparison
Quartz runs jobs on schedule. No state persistence between runs, no workflow coordination.
Scheduled Job
Quartz
public class SyncJob implements Job {
@Override
public void execute(JobExecutionContext context) {
// No state from previous run
// If this fails, you lose all progress
List<Record> records = fetchRecords();
for (Record r : records) {
syncToExternal(r); // What if this fails halfway?
}
}
}
// Schedule it
Trigger trigger = TriggerBuilder.newTrigger()
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 * * * ?"))
.build();
scheduler.scheduleJob(job, trigger);Flovyn
class SyncWorkflow : WorkflowDefinition<SyncInput, SyncResult>() {
override val kind = "sync-job"
override val name = "Sync Job"
override suspend fun execute(ctx: WorkflowContext, input: SyncInput): SyncResult {
val records = fetchRecords(input.source)
var synced = 0
for ((i, record) in records.withIndex()) {
ctx.set("progress", "${i}/${records.size}")
ctx.run("sync-${i}") { syncToExternal(record) }
synced++
// Server can restart here - progress preserved
}
return SyncResult(synced = synced)
}
}
// Trigger via schedule, webhook, or API - same workflow
Retry with State
Quartz - Manual retry tracking
public class RetryJob implements Job {
@Autowired
private RetryStateRepository retryRepo; // You manage this
@Override
public void execute(JobExecutionContext context) {
String taskId = context.getJobDetail().getKey().getName();
RetryState state = retryRepo.findById(taskId)
.orElse(new RetryState(taskId, 0));
try {
callExternalApi();
retryRepo.delete(state);
} catch (Exception e) {
state.incrementAttempt();
state.setLastError(e.getMessage());
retryRepo.save(state);
if (state.getAttempts() < 5) {
// Reschedule with backoff
long delay = (long) Math.pow(2, state.getAttempts()) * 1000;
rescheduleJob(taskId, delay);
} else {
alertOps("Job failed after 5 attempts");
}
}
}
}
// 50+ lines just for retry logicFlovyn - Built-in
class ApiCallWorkflow : WorkflowDefinition<ApiInput, ApiResult>() {
override val kind = "api-call"
override suspend fun execute(ctx: WorkflowContext, input: ApiInput): ApiResult {
repeat(5) { attempt ->
try {
val response = ctx.schedule<ApiResponse>("call-api", input)
return ApiResult(response)
} catch (e: Exception) {
ctx.set("lastError", e.message)
val delay = (1L shl attempt) * 1000 // Exponential backoff
ctx.sleep(Duration.ofMillis(delay))
}
}
throw NonRetryableException("Failed after 5 attempts")
}
}
// State persisted, survives restarts, full history
Camunda/Flowable Comparison
BPMN engines are powerful but heavyweight. XML/diagram-driven, not code-first.
Simple Approval Flow
Camunda BPMN - XML + Java delegates
<!-- approval-process.bpmn -->
<bpmn:process id="approval" isExecutable="true">
<bpmn:startEvent id="start"/>
<bpmn:serviceTask id="sendRequest"
camunda:delegateExpression="${sendRequestDelegate}"/>
<bpmn:userTask id="managerApproval"
camunda:assignee="${manager}"/>
<bpmn:exclusiveGateway id="gateway"/>
<bpmn:serviceTask id="approved"
camunda:delegateExpression="${processApprovalDelegate}"/>
<bpmn:serviceTask id="rejected"
camunda:delegateExpression="${processRejectionDelegate}"/>
<bpmn:endEvent id="end"/>
<!-- Plus all the sequence flows... -->
</bpmn:process>@Component("sendRequestDelegate")
public class SendRequestDelegate implements JavaDelegate {
@Override
public void execute(DelegateExecution execution) {
String requestId = (String) execution.getVariable("requestId");
emailService.sendApprovalRequest(requestId);
}
}
// One class per task, scattered across codebaseFlovyn - Single file, readable flow
class ApprovalWorkflow : WorkflowDefinition<ApprovalRequest, ApprovalResult>() {
override val kind = "approval-process"
override val name = "Approval Process"
override suspend fun execute(ctx: WorkflowContext, input: ApprovalRequest): ApprovalResult {
// Send request
ctx.run("send-request") {
emailService.sendApprovalRequest(input.requestId, input.manager)
}
// Wait for approval (human task) - worker NOT blocked
val approvalPromise = ctx.promise<ApprovalDecision>(
"manager-approval",
timeout = Duration.ofDays(7)
)
val decision = try {
approvalPromise.await()
} catch (e: TimeoutException) {
null
}
return when {
decision == null -> {
ctx.run("timeout") { notifyTimeout(input) }
ApprovalResult(status = "timeout")
}
decision.approved -> {
ctx.run("process-approval") { processApproval(input) }
ApprovalResult(status = "approved")
}
else -> {
ctx.run("process-rejection") { processRejection(input, decision.reason) }
ApprovalResult(status = "rejected", reason = decision.reason)
}
}
}
}Why Code-First Beats BPMN
| Aspect | BPMN (Camunda) | Flovyn |
|---|---|---|
| Define workflow | XML + diagram editor | Kotlin/Java code |
| Version control | XML diffs unreadable | Normal code diffs |
| Refactoring | Manual diagram updates | IDE refactoring works |
| Testing | Deploy to engine | Unit test directly |
| Debugging | Engine logs | Step through code |
| Learning curve | BPMN spec + engine | Just write code |
Spring + Database (DIY)
The most common approach: manual state management with Spring services and database.
Order Processing
DIY - 200+ lines, bug-prone state machine
@Service
public class OrderService {
@Autowired private OrderRepository orderRepo;
@Autowired private PaymentService paymentService;
@Autowired private InventoryService inventoryService;
@Autowired private ShippingService shippingService;
@Transactional
public void processOrder(String orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
// Resume from last state
switch (order.getStatus()) {
case CREATED:
processPayment(order);
// fall through
case PAYMENT_COMPLETED:
reserveInventory(order);
// fall through
case INVENTORY_RESERVED:
createShipment(order);
break;
case FAILED:
handleFailure(order);
break;
}
}
private void processPayment(Order order) {
try {
PaymentResult result = paymentService.charge(order);
order.setPaymentId(result.getId());
order.setStatus(PAYMENT_COMPLETED);
orderRepo.save(order);
} catch (Exception e) {
order.setStatus(FAILED);
order.setError(e.getMessage());
orderRepo.save(order);
throw e;
}
}
private void reserveInventory(Order order) {
try {
InventoryResult result = inventoryService.reserve(order);
order.setReservationId(result.getId());
order.setStatus(INVENTORY_RESERVED);
orderRepo.save(order);
} catch (Exception e) {
// Compensate: refund payment
paymentService.refund(order.getPaymentId());
order.setStatus(FAILED);
orderRepo.save(order);
throw e;
}
}
// More methods, more state transitions, more edge cases...
}Flovyn - 40 lines, clear flow
@Workflow(name = "order-processing")
class OrderWorkflow : TypedWorkflowDefinition<OrderInput, OrderOutput> {
override suspend fun execute(ctx: WorkflowContext, input: OrderInput): OrderOutput {
ctx.set("status", "processing")
// Step 1: Payment
val payment = ctx.schedule<PaymentResult>("payment-task", input)
ctx.set("paymentId", payment.id)
// Step 2: Inventory (with compensation)
val inventory = try {
ctx.schedule<InventoryResult>("inventory-task", input)
} catch (e: Exception) {
ctx.run("refund") { paymentService.refund(payment.id) }
throw e
}
ctx.set("reservationId", inventory.id)
// Step 3: Shipping (with compensation)
val shipment = try {
ctx.schedule<ShipmentResult>("shipment-task", input)
} catch (e: Exception) {
ctx.run("release-inventory") {
inventoryService.release(inventory.id)
}
ctx.run("refund") { paymentService.refund(payment.id) }
throw e
}
ctx.set("status", "completed")
return OrderOutput(
orderId = input.orderId,
trackingNumber = shipment.trackingNumber
)
}
}
// Automatic state persistence, clear compensation logicFeature Matrix
| Feature | Spring Batch | Quartz | Camunda | DIY Spring | Flovyn |
|---|---|---|---|---|---|
| Batch processing | Manual | ||||
| Scheduled jobs | Manual | ||||
| Long-running workflows | Manual | ||||
| Durable state | |||||
| Human tasks | Manual | ||||
| Event sourcing | |||||
| Time-travel debug | |||||
| Code-first | |||||
| Saga/compensation | Manual | Manual | |||
| Lightweight |
When to Use What
Spring Batch
High-volume ETL, chunk-based processing with restart capability
Quartz
Simple scheduled jobs without state requirements
Camunda/Flowable
Business users need visual BPMN diagrams, compliance requires audit trails in specific format
DIY Spring
Simple flows, team prefers full control, willing to maintain state machine
Flovyn
Multi-step business workflows, external service coordination, long-running processes, need event sourcing without BPMN complexity
Ready to simplify your Java workflows?
See how Flovyn can replace your complex Java workflow infrastructure with clean, maintainable code.