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

ToolBest ForLimitation
Spring BatchETL, batch jobsNo long-running workflows
QuartzScheduled jobsNo state between runs
Camunda/FlowableBPMN diagramsXML/diagram-driven, heavyweight
Spring + DBCustom workflowsYou build everything yourself
FlovynDurable workflowsCode-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 model

Flovyn

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 compensation
class 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 logic

Flovyn - 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 codebase

Flovyn - 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

AspectBPMN (Camunda)Flovyn
Define workflowXML + diagram editorKotlin/Java code
Version controlXML diffs unreadableNormal code diffs
RefactoringManual diagram updatesIDE refactoring works
TestingDeploy to engineUnit test directly
DebuggingEngine logsStep through code
Learning curveBPMN spec + engineJust 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 logic

Feature Matrix

FeatureSpring BatchQuartzCamundaDIY SpringFlovyn
Batch processingManual
Scheduled jobsManual
Long-running workflowsManual
Durable state
Human tasksManual
Event sourcing
Time-travel debug
Code-first
Saga/compensationManualManual
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.

Built with v0