Skip to content

Coordinate an onboarding application

Onboarding coordination

The first service accepts a customer's application. Before a reviewer can act on it, the service may need to check eligibility, reserve capacity, and collect documents. Those operations fail in different ways.

This runnable project uses three separate models for that onboarding story. A Saga removes a recorded application when an eligibility check fails. TCC reserves a reviewer slot and document-check capacity before confirming either. A Workflow orders validation, document collection, and the review decision.

They run independently so each failure rule is visible. The example does not automatically chain the three engines together or attach them to the first service's HTTP endpoints.

Three independent examples: Saga compensates a failed eligibility check, TCC confirms or cancels reservations, and Workflow orders validation, document checks and the review result.

The providers belong to the example. They keep records, reservations, decisions, and an operation trace in memory. They make no network calls. The engines, registries, argument binding, compensation, and execution-state storage come from Jafly's actual Spring auto-configuration.

Run the project

Use JDK 25. The full framework installation runs provider-backed tests and needs Docker, as described in installation. Once those libraries are installed, this standalone example itself needs no external services. From the Jafly checkout:

./mvnw -B -ntp install
./mvnw -B -ntp -f docs/examples/onboarding-coordination/pom.xml clean verify

The six tests need no database, Docker engine, message broker, or provider credentials. The project inherits the framework parent at 26.10.20-SNAPSHOT and keeps its own application version at 0.1.0-SNAPSHOT:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.fireflyframework</groupId>
        <artifactId>fireflyframework-parent</artifactId>
        <version>26.10.20-SNAPSHOT</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>onboarding-coordination</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <name>Jafly onboarding coordination tutorial</name>
    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.fireflyframework</groupId>
            <artifactId>fireflyframework-orchestration</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Spring discovers the application's models and provides the three engine beans:

package com.example.onboarding;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CoordinationApplication {
    public static void main(String[] args) {
        SpringApplication.run(CoordinationApplication.class, args);
    }
}

The tests run with a non-web application context. This project has no HTTP endpoint contract; its executable entry point for the scenarios is the test suite.

Define the application boundary

Each scenario uses a UUID to identify Alex Rivera's application. Tests allocate a fresh UUID for isolation:

package com.example.onboarding;

import java.util.UUID;

public record OnboardingApplication(UUID id, String applicantName, String email) {}

OnboardingProviders is an application-owned port. A deployed service would implement these operations against its chosen records and providers:

package com.example.onboarding;

import reactor.core.publisher.Mono;

import java.util.UUID;

public interface OnboardingProviders {
    Mono<OnboardingApplication> record(OnboardingApplication application);
    Mono<Void> removeRecord(OnboardingApplication application);
    Mono<OnboardingApplication> checkEligibility(OnboardingApplication application);
    Mono<ResourceReservation> reserve(UUID applicationId, String resource);
    Mono<Void> confirm(ResourceReservation reservation);
    Mono<Void> cancel(ResourceReservation reservation);
    Mono<OnboardingApplication> validate(OnboardingApplication application);
    Mono<OnboardingApplication> checkDocuments(OnboardingApplication application);
    Mono<String> recordDecision(OnboardingApplication application);
}

Its in-memory implementation supports controlled eligibility and document-provider failures. The tests inspect resulting business state as well as operation order:

package com.example.onboarding;

import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

@Component
public class InMemoryOnboardingProviders implements OnboardingProviders {
    private final Map<UUID, OnboardingApplication> applications = new ConcurrentHashMap<>();
    private final Map<ResourceReservation, String> reservations = new ConcurrentHashMap<>();
    private final Map<UUID, String> decisions = new ConcurrentHashMap<>();
    private final Map<UUID, List<String>> operations = new ConcurrentHashMap<>();
    private final Set<UUID> rejectedEligibility = ConcurrentHashMap.newKeySet();
    private final Set<UUID> rejectedDocuments = ConcurrentHashMap.newKeySet();

    public Mono<OnboardingApplication> record(OnboardingApplication application) {
        return Mono.fromSupplier(() -> {
            observe(application.id(), "record");
            applications.put(application.id(), application);
            return application;
        });
    }

    public Mono<Void> removeRecord(OnboardingApplication application) {
        return Mono.fromRunnable(() -> {
            observe(application.id(), "remove-record");
            applications.remove(application.id());
        });
    }

    public Mono<OnboardingApplication> checkEligibility(OnboardingApplication application) {
        return Mono.fromSupplier(() -> {
            observe(application.id(), "eligibility");
            if (rejectedEligibility.contains(application.id())) {
                throw new IllegalStateException("Applicant is ineligible");
            }
            return application;
        });
    }

    public Mono<ResourceReservation> reserve(UUID applicationId, String resource) {
        return Mono.fromSupplier(() -> {
            observe(applicationId, "try:" + resource);
            if (resource.equals("documents") && rejectedDocuments.contains(applicationId)) {
                throw new IllegalStateException("Document check capacity unavailable");
            }
            var reservation = new ResourceReservation(applicationId, resource);
            reservations.put(reservation, "RESERVED");
            return reservation;
        });
    }

    public Mono<Void> confirm(ResourceReservation reservation) {
        return Mono.fromRunnable(() -> {
            observe(reservation.applicationId(), "confirm:" + reservation.resource());
            if (!reservations.replace(reservation, "RESERVED", "CONFIRMED")) {
                throw new IllegalStateException("Reservation is not held");
            }
        });
    }

    public Mono<Void> cancel(ResourceReservation reservation) {
        return Mono.fromRunnable(() -> {
            observe(reservation.applicationId(), "cancel:" + reservation.resource());
            reservations.remove(reservation);
        });
    }

    public Mono<OnboardingApplication> validate(OnboardingApplication application) {
        return Mono.fromSupplier(() -> {
            observe(application.id(), "validate");
            if (application.applicantName() == null || application.applicantName().isBlank()) {
                throw new IllegalArgumentException("Applicant name is required");
            }
            return application;
        });
    }

    public Mono<OnboardingApplication> checkDocuments(OnboardingApplication application) {
        return Mono.fromSupplier(() -> {
            observe(application.id(), "documents");
            if (rejectedDocuments.contains(application.id())) {
                throw new IllegalStateException("Document provider unavailable");
            }
            return application;
        });
    }

    public Mono<String> recordDecision(OnboardingApplication application) {
        return Mono.fromSupplier(() -> {
            observe(application.id(), "decision");
            decisions.put(application.id(), "READY_FOR_REVIEW");
            return "READY_FOR_REVIEW";
        });
    }

    public void rejectEligibility(UUID id) { rejectedEligibility.add(id); }
    public void rejectDocuments(UUID id) { rejectedDocuments.add(id); }
    public void acceptDocuments(UUID id) { rejectedDocuments.remove(id); }
    public Optional<OnboardingApplication> application(UUID id) { return Optional.ofNullable(applications.get(id)); }
    public Optional<String> reservation(UUID id, String resource) {
        return Optional.ofNullable(reservations.get(new ResourceReservation(id, resource)));
    }
    public Optional<String> decision(UUID id) { return Optional.ofNullable(decisions.get(id)); }
    public List<String> operations(UUID id) { return List.copyOf(operations.getOrDefault(id, List.of())); }

    private void observe(UUID id, String operation) {
        operations.computeIfAbsent(id, ignored -> new CopyOnWriteArrayList<>()).add(operation);
    }
}

These methods use deferred Reactor publishers, so state changes happen when the engine subscribes. A real adapter must also define its concurrency, duplicate-request, and business reversal rules. The example's maps do not establish a distributed transaction or durable customer record.

Undo a Saga step when a later step fails

SubmitOnboardingSaga first records the application, then checks eligibility. @FromStep("record") passes the completed record to the eligibility step. The compensate attribute connects that record step to its removal method:

package com.example.onboarding;

import lombok.RequiredArgsConstructor;
import org.fireflyframework.orchestration.core.argument.FromStep;
import org.fireflyframework.orchestration.core.argument.Input;
import org.fireflyframework.orchestration.saga.annotation.Saga;
import org.fireflyframework.orchestration.saga.annotation.SagaStep;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
@Saga(name = "submit-onboarding")
@RequiredArgsConstructor
public class SubmitOnboardingSaga {
    private final OnboardingProviders providers;

    @SagaStep(id = "record", compensate = "removeRecord")
    public Mono<OnboardingApplication> record(@Input OnboardingApplication application) {
        return providers.record(application);
    }

    @SagaStep(id = "eligibility", dependsOn = "record")
    public Mono<OnboardingApplication> eligibility(@FromStep("record") OnboardingApplication application) {
        return providers.checkEligibility(application);
    }

    public Mono<Void> removeRecord(@FromStep("record") OnboardingApplication application) {
        return providers.removeRecord(application);
    }
}

With an eligible applicant, the Saga succeeds and the record remains. With an ineligible applicant, the second step fails and the engine invokes removeRecord. The test checks that the record is absent and the trace is record, eligibility, remove-record.

This compensation is business logic written by the application. Removing an in-memory record is reversible here. An external action such as sending a message or opening a regulated account would need a different, explicitly supported recovery operation.

Reserve resources with TCC

ResourceReservation identifies one reserved resource for one application:

package com.example.onboarding;

import java.util.UUID;

public record ResourceReservation(UUID applicationId, String resource) {}

ReserveOnboardingResources registers a TCC definition with two participants. The builder's handlers close over the injected provider port. Try returns a reservation; Confirm and Cancel receive that Try result:

package com.example.onboarding;

import lombok.RequiredArgsConstructor;
import org.fireflyframework.orchestration.tcc.builder.TccBuilder;
import org.fireflyframework.orchestration.tcc.registry.TccDefinition;
import org.fireflyframework.orchestration.tcc.registry.TccRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.UUID;

@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class ReserveOnboardingResources {
    private final OnboardingProviders providers;

    @Bean
    TccDefinition onboardingReservations(TccRegistry registry) {
        var definition = TccBuilder.tcc("reserve-onboarding", 30_000, false, 0, 0)
                .participant("reviewer").order(1).timeoutMs(10_000)
                    .tryHandler((input, context) -> providers.reserve((UUID) input, "reviewer"))
                    .confirmHandler((result, context) -> providers.confirm((ResourceReservation) result))
                    .cancelHandler((result, context) -> providers.cancel((ResourceReservation) result))
                    .add()
                .participant("documents").order(2).timeoutMs(10_000)
                    .tryHandler((input, context) -> providers.reserve((UUID) input, "documents"))
                    .confirmHandler((result, context) -> providers.confirm((ResourceReservation) result))
                    .cancelHandler((result, context) -> providers.cancel((ResourceReservation) result))
                    .add()
                .build();
        registry.register(definition);
        return definition;
    }
}

The successful trace is try:reviewer, try:documents, confirm:reviewer, confirm:documents. Both reservations end as CONFIRMED.

If the document reservation fails, the engine cancels the successful reviewer reservation. Neither resource remains reserved and no Confirm operation runs. The example disables TCC retries so each phase is easy to inspect. A real capacity provider must implement the reservation lifecycle, expiration, repeat calls, and any reversal after a partial Confirm failure.

Order review work with a Workflow

ReviewOnboardingWorkflow declares dependencies between three steps. Document checking starts after validation; the decision is recorded only after document checking succeeds:

package com.example.onboarding;

import lombok.RequiredArgsConstructor;
import org.fireflyframework.orchestration.core.argument.FromStep;
import org.fireflyframework.orchestration.core.argument.Input;
import org.fireflyframework.orchestration.workflow.annotation.Workflow;
import org.fireflyframework.orchestration.workflow.annotation.WorkflowStep;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
@Workflow(id = "review-onboarding", maxRetries = 0)
@RequiredArgsConstructor
public class ReviewOnboardingWorkflow {
    private final OnboardingProviders providers;

    @WorkflowStep(id = "validate")
    public Mono<OnboardingApplication> validate(@Input("application") OnboardingApplication application) {
        return providers.validate(application);
    }

    @WorkflowStep(id = "documents", dependsOn = "validate")
    public Mono<OnboardingApplication> documents(@FromStep("validate") OnboardingApplication application) {
        return providers.checkDocuments(application);
    }

    @WorkflowStep(id = "decision", dependsOn = "documents")
    public Mono<String> decision(@FromStep("documents") OnboardingApplication application) {
        return providers.recordDecision(application);
    }
}

The success test checks both the engine's COMPLETED state and the provider's READY_FOR_REVIEW decision. The operation trace verifies dependency order.

When the document provider fails, the engine records FAILED and the decision step does not run. The recovery test makes the provider available again and starts a new execution with the same application input. The new execution completes with a different correlation ID; the original execution stays failed in storage.

This is an application-directed retry of the review process. The current resumeWorkflow API accepts suspended executions, not failed ones. Starting again reruns the earlier validation step, so deployed adapters must decide whether repeated operations are safe. This example does not claim automatic recovery across a process restart. Its execution state and provider data disappear when the context stops.

Check the outcomes

The integration test uses the engines supplied by Spring, with no mocked bus, engine, or registry:

package com.example.onboarding;

import org.fireflyframework.orchestration.core.model.ExecutionStatus;
import org.fireflyframework.orchestration.saga.engine.SagaEngine;
import org.fireflyframework.orchestration.saga.engine.StepInputs;
import org.fireflyframework.orchestration.tcc.engine.TccEngine;
import org.fireflyframework.orchestration.tcc.engine.TccInputs;
import org.fireflyframework.orchestration.workflow.engine.WorkflowEngine;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import reactor.test.StepVerifier;

import java.util.Map;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class OnboardingCoordinationTest {
    @Autowired SagaEngine sagas;
    @Autowired TccEngine transactions;
    @Autowired WorkflowEngine workflows;
    @Autowired InMemoryOnboardingProviders providers;

    @Test
    void sagaKeepsAnEligibleApplication() {
        var application = applicant();
        StepVerifier.create(sagas.execute("submit-onboarding", StepInputs.of("record", application)))
                .assertNext(result -> assertThat(result.isSuccess()).isTrue()).verifyComplete();
        assertThat(providers.application(application.id())).contains(application);
        assertThat(providers.operations(application.id())).containsExactly("record", "eligibility");
    }

    @Test
    void sagaRemovesTheRecordWhenEligibilityFails() {
        var application = applicant();
        providers.rejectEligibility(application.id());
        StepVerifier.create(sagas.execute("submit-onboarding", StepInputs.of("record", application)))
                .assertNext(result -> assertThat(result.isSuccess()).isFalse()).verifyComplete();
        assertThat(providers.application(application.id())).isEmpty();
        assertThat(providers.operations(application.id()))
                .containsExactly("record", "eligibility", "remove-record");
    }

    @Test
    void tccReservesEverythingBeforeConfirming() {
        var id = UUID.randomUUID();
        StepVerifier.create(transactions.execute("reserve-onboarding", reservationInputs(id)))
                .assertNext(result -> assertThat(result.isConfirmed()).isTrue()).verifyComplete();
        assertThat(providers.reservation(id, "reviewer")).contains("CONFIRMED");
        assertThat(providers.reservation(id, "documents")).contains("CONFIRMED");
        assertThat(providers.operations(id)).containsExactly(
                "try:reviewer", "try:documents", "confirm:reviewer", "confirm:documents");
    }

    @Test
    void tccCancelsTheReviewerReservationWhenDocumentReservationFails() {
        var id = UUID.randomUUID();
        providers.rejectDocuments(id);
        StepVerifier.create(transactions.execute("reserve-onboarding", reservationInputs(id)))
                .assertNext(result -> {
                    assertThat(result.isCanceled()).isTrue();
                    assertThat(result.error()).isPresent();
                }).verifyComplete();
        assertThat(providers.reservation(id, "reviewer")).isEmpty();
        assertThat(providers.reservation(id, "documents")).isEmpty();
        assertThat(providers.operations(id)).containsExactly(
                "try:reviewer", "try:documents", "cancel:reviewer");
    }

    @Test
    void workflowRespectsDependenciesAndRecordsADecision() {
        var application = applicant();
        StepVerifier.create(workflows.startWorkflow("review-onboarding", Map.of("application", application)))
                .assertNext(state -> {
                    assertThat(state.status()).isEqualTo(ExecutionStatus.COMPLETED);
                    assertThat(state.stepResults()).containsKeys("validate", "documents", "decision");
                }).verifyComplete();
        assertThat(providers.decision(application.id())).contains("READY_FOR_REVIEW");
        assertThat(providers.operations(application.id())).containsExactly("validate", "documents", "decision");
    }

    @Test
    void failedWorkflowStaysFailedAndANewExecutionCanSucceedAfterProviderRecovery() {
        var application = applicant();
        providers.rejectDocuments(application.id());
        var failed = workflows.startWorkflow("review-onboarding", Map.of("application", application)).block();
        assertThat(failed.status()).isEqualTo(ExecutionStatus.FAILED);
        assertThat(failed.stepResults()).doesNotContainKey("decision");
        assertThat(providers.decision(application.id())).isEmpty();

        providers.acceptDocuments(application.id());
        StepVerifier.create(workflows.startWorkflow("review-onboarding", Map.of("application", application)))
                .assertNext(recovered -> {
                    assertThat(recovered.status()).isEqualTo(ExecutionStatus.COMPLETED);
                    assertThat(recovered.correlationId()).isNotEqualTo(failed.correlationId());
                }).verifyComplete();
        StepVerifier.create(workflows.findByCorrelationId(failed.correlationId()))
                .assertNext(saved -> assertThat(saved.orElseThrow().status()).isEqualTo(ExecutionStatus.FAILED))
                .verifyComplete();
        assertThat(providers.decision(application.id())).contains("READY_FOR_REVIEW");
        assertThat(providers.operations(application.id()))
                .containsExactly("validate", "documents", "validate", "documents", "decision");
    }

    private TccInputs reservationInputs(UUID id) {
        return TccInputs.builder().forParticipant("reviewer", id).forParticipant("documents", id).build();
    }

    private OnboardingApplication applicant() {
        return new OnboardingApplication(UUID.randomUUID(), "Alex Rivera", "alex@example.test");
    }
}
Scenario Business result
Saga success The eligible application remains recorded
Saga eligibility failure Compensation removes the previously recorded application
TCC success Both resources are tried before either is confirmed
TCC document Try failure The reviewer reservation is canceled; no resource remains held
Workflow success Validation precedes documents; documents precede the decision
Workflow provider failure and recovery The first execution stays failed; a separate execution completes after recovery

For the full API reference, continue with the module's Saga, TCC, and Workflow documentation. For persisted application history, run the separate event-store project.