Your first service¶
Build a customer onboarding service that accepts an application and lets the caller retrieve it. Begin with a WebFlux route, add a command to submit the application, then use a query to read it back.
The service stores applications in memory, so restarting it clears the data. It needs no database, message broker, or cloud credentials. Authentication, duplicate-submission handling, and durable storage remain application responsibilities and are outside this example.
Prepare the libraries¶
Use JDK 25 and the checked-in Maven 3.9.16 wrapper. Install CQRS and its reactor prerequisites for this tutorial:
git clone https://github.com/fireflyframework/fireflyframework-jafly.git
cd fireflyframework-jafly
./mvnw -B -ntp -pl :fireflyframework-cqrs -am install
This builds seven framework projects and runs their configured tests without Docker. The full framework build also tests database and broker adapters and needs Docker; see installation.
The complete application is in docs/examples/getting-started. See installation for prerequisites and choosing a locally installed or verified registry version.
Define the application¶
The framework parent manages dependency and build versions. The application keeps its own version, 0.1.0-SNAPSHOT. It declares WebFlux, validation, CQRS, and Lombok, with Spring Boot test support and Reactor Test for the tests. Lombok has provided scope; the parent configures its annotation processor.
pom.xml:
<?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-service</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Jafly customer onboarding tutorial</name>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-cqrs</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>
Start Spring Boot from src/main/java/com/example/jafly/OnboardingServiceApplication.java:
package com.example.jafly;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OnboardingServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OnboardingServiceApplication.class, args);
}
}
Spring scans components in com.example.jafly and its child packages. The controllers, handlers, and repository live there.
Begin with HTTP¶
The existing GET /hello route checks that Spring Boot and WebFlux are serving requests. The onboarding controller adds POST /applications for submission and GET /applications/{id} for retrieval:
package com.example.jafly;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.net.URI;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.fireflyframework.cqrs.command.CommandBus;
import org.fireflyframework.cqrs.query.QueryBus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/applications")
@RequiredArgsConstructor
public class ApplicationsController {
private final CommandBus commandBus;
private final QueryBus queryBus;
@PostMapping
public Mono<ResponseEntity<OnboardingApplication>> submit(@Valid @RequestBody ApplicationRequest request) {
return commandBus.send(new SubmitApplication(request.applicantName(), request.email()))
.map(application -> ResponseEntity.created(URI.create("/applications/" + application.id()))
.body(application));
}
@GetMapping("/{id}")
public Mono<ResponseEntity<OnboardingApplication>> find(@PathVariable UUID id) {
return queryBus.query(new FindApplication(id))
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
public record ApplicationRequest(@NotBlank String applicantName, @NotBlank @Email String email) {
}
}
The controller validates input and translates results into HTTP responses. It sends typed work through CommandBus and QueryBus; it does not instantiate either handler. @RequiredArgsConstructor generates a constructor for the two final dependencies, which Spring injects automatically. The handlers use the same pattern for their repository dependency.
Define the application record¶
An onboarding application has an ID, applicant name, and email address:
package com.example.jafly;
import java.util.UUID;
public record OnboardingApplication(UUID id, String applicantName, String email) {
}
The application owns its storage. This repository uses an in-memory map for the tutorial:
package com.example.jafly;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public class InMemoryApplicationRepository {
// Tutorial data belongs to this process and is lost when the application restarts.
private final Map<UUID, OnboardingApplication> applications = new ConcurrentHashMap<>();
public Mono<OnboardingApplication> save(OnboardingApplication application) {
return Mono.fromSupplier(() -> {
applications.put(application.id(), application);
return application;
});
}
public Mono<OnboardingApplication> findById(UUID id) {
return Mono.defer(() -> Mono.justOrEmpty(applications.get(id)));
}
}
This repository does not persist across restarts or coordinate several service instances. Replace it with storage that fits your service before relying on durable records.
Submit an application with a command¶
SubmitApplication describes the input and result type. Its command ID stays the same throughout execution:
package com.example.jafly;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.UUID;
import org.fireflyframework.cqrs.command.Command;
public record SubmitApplication(String commandId, @NotBlank String applicantName,
@NotBlank @Email String email) implements Command<OnboardingApplication> {
public SubmitApplication(String applicantName, String email) {
this(UUID.randomUUID().toString(), applicantName, email);
}
@Override
public String getCommandId() {
return commandId;
}
@Override
public Class<OnboardingApplication> getResultType() {
return OnboardingApplication.class;
}
}
@CommandHandlerComponent registers the handler as a Spring component. The handler creates the application record and saves it through the application repository:
package com.example.jafly;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.fireflyframework.cqrs.annotations.CommandHandlerComponent;
import org.fireflyframework.cqrs.command.CommandHandler;
import reactor.core.publisher.Mono;
@CommandHandlerComponent
@RequiredArgsConstructor
public class SubmitApplicationHandler extends CommandHandler<SubmitApplication, OnboardingApplication> {
private final InMemoryApplicationRepository repository;
@Override
protected Mono<OnboardingApplication> doHandle(SubmitApplication command) {
var application = new OnboardingApplication(UUID.randomUUID(), command.applicantName(), command.email());
return repository.save(application);
}
}
The bus finds the handler and runs the command pipeline. Annotation-based validation needs a Jakarta Validator bean, supplied here by the validation starter. A command ID alone does not make duplicate submissions idempotent.
This example defines no authorization policy. Command.authorize() allows by default. A protected operation needs an authorization rule and a trusted identity context supplied by the service.
Read an application with a query¶
The caller uses the ID returned by submission to ask for the stored application:
package com.example.jafly;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import org.fireflyframework.cqrs.query.Query;
public record FindApplication(String queryId, @NotNull UUID applicationId) implements Query<OnboardingApplication> {
public FindApplication(UUID applicationId) {
this(UUID.randomUUID().toString(), applicationId);
}
@Override
public String getQueryId() {
return queryId;
}
@Override
public Class<OnboardingApplication> getResultType() {
return OnboardingApplication.class;
}
@Override
public boolean isCacheable() {
return false;
}
}
The query handler reads from the same repository. It does not change the application:
package com.example.jafly;
import lombok.RequiredArgsConstructor;
import org.fireflyframework.cqrs.annotations.QueryHandlerComponent;
import org.fireflyframework.cqrs.query.QueryHandler;
import reactor.core.publisher.Mono;
@QueryHandlerComponent(cacheable = false)
@RequiredArgsConstructor
public class FindApplicationHandler extends QueryHandler<FindApplication, OnboardingApplication> {
private final InMemoryApplicationRepository repository;
@Override
protected Mono<OnboardingApplication> doHandle(FindApplication query) {
return repository.findById(query.applicationId());
}
}
Command and query handlers separate the write operation from the read operation. Both use one in-memory store here. CQRS does not require separate databases, and this example does not enable a query cache or publish events.
Run the service¶
From the framework checkout:
In another terminal, check the native HTTP route:
Expected body:
Submit an application:
curl -i -X POST http://localhost:8080/applications \
-H 'Content-Type: application/json' \
-d '{"applicantName":"Ada Lovelace","email":"ada@example.com"}'
The response has status 201 Created, a Location header such as /applications/<id>, and a JSON body containing id, applicantName, and email. Copy the returned ID into the next request:
The read returns the same application with status 200 OK. An unknown UUID returns 404 Not Found. A blank applicant name or invalid email returns 400 Bad Request on submission. Stop the service with Ctrl+C when finished.
Test the complete path¶
The example tests the command and query handlers and runs requests against the Spring Boot application on a random port. It checks submission and retrieval, validation errors, and a missing application.
The test source lives beside the application in src/test/java/com/example/jafly.
Choose the next capability¶
The runnable service ends here. For a larger onboarding process, choose the next dependency for a specific requirement:
- Add security and identity before accepting protected customer data.
- Read CQRS for execution context and the conditional event/cache bridges.
- Choose EDA and a transport if another service needs an application-submitted event.
- Use orchestration if onboarding requires coordinated checks with recovery or compensating steps.
These integrations need their own configuration and tests. Continue with the architecture guide to see their boundaries around the command path.