Common CQRS Library¶
Separate commands and queries with reactive dispatch, execution context, validation, and authorization hooks.
Define command/query messages and their handlers, then invoke the appropriate bus. Configure timeouts and query caching through firefly.cqrs; use the EDA integration only when the event transport has also been configured.
| Maven artifact | Packaging | Source project |
|---|---|---|
org.fireflyframework:fireflyframework-cqrs |
jar |
modules/cqrs |
Part of Business operations.
Command and query paths¶
flowchart LR
Caller[Application boundary] --> Command[CommandBus]
Caller --> Query[QueryBus]
Command --> Checks[Validation and authorization]
Checks --> Handler[Command handler]
Query --> QueryHandler[Query handler]
QueryHandler --> Result[Reactive result]
Handler --> Result
Query -. optional .-> Cache[Query cache]
Commands and queries have different handlers and policies. The buses connect application messages to those handlers while keeping results in Reactor publishers.
Add the component¶
Installation and version availability explains source builds and published releases.
<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-cqrs</artifactId>
<version>26.10.20-SNAPSHOT</version>
</dependency>
Quick start¶
The runnable example is a customer onboarding service. POST /applications submits an applicant's name and email through CommandBus; GET /applications/{id} reads the saved application through QueryBus.
Use the first-service tutorial for the complete Maven project, main class, run commands, and HTTP tests. The classes here belong to that project. Its repository is an in-memory map that loses data on restart, and its query disables caching. Authentication, outbound events, and a database are later integration choices.
Submit a customer application¶
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;
}
}
SubmitApplicationHandler.java:
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);
}
}
Read an application by ID¶
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;
}
}
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());
}
}
Store the tutorial state¶
package com.example.jafly;
import java.util.UUID;
public record OnboardingApplication(UUID id, String applicantName, String email) {
}
InMemoryApplicationRepository.java:
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)));
}
}
Expose the two HTTP routes¶
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 returns 201 Created with a Location header for a submission, 200 OK for a known ID, 404 for an unknown ID, and 400 for invalid name/email input. The handlers use the application's concrete repository; the bus supplies dispatch and its configured processing pipeline.
Run and check the application¶
After installing the required framework libraries, run the standalone project from the repository root:
Submit a fictional applicant from another terminal:
curl -i -X POST http://localhost:8080/applications \
-H 'Content-Type: application/json' \
-d '{"applicantName":"Ada Lovelace","email":"ada@example.com"}'
Use the returned id in GET /applications/{id}. Run the project's tests with:
Authorization and optional bridges¶
The command/query interfaces allow execution by default. Protected operations need an authorization rule and trusted identity context. Jakarta annotation validation needs a Validator bean; the example declares the validation starter.
Publishing a command result requires EDA, an EventPublisherFactory bean, the enabled CQRS/EDA bridge, and @PublishDomainEvent on the handler. Query invalidation requires a QueryCacheAdapter and a matching @InvalidateCacheOn event delivered through Spring's application-event path. Connect broker events to that local path explicitly. The onboarding tutorial configures neither bridge.
These annotation declarations are API fragments for an application that has added that infrastructure:
@PublishDomainEvent(destination = "application-events", eventType = "application.submitted")
@InvalidateCacheOn(eventTypes = {"application.submitted", "application.reviewed"})
Apply each annotation to its corresponding command or query handler. If you enable query caching, update the tutorial's query and handler cache settings and verify invalidation; its current query intentionally reads the map directly.
Configuration and wiring¶
The firefly.cqrs.authorization namespace is bound by AuthorizationProperties.java. Nested configuration is declared in the linked class. Defaults are the field initializers.
| Setting | Java type | Source default |
|---|---|---|
firefly.cqrs.authorization.enabled |
boolean |
true |
firefly.cqrs.authorization.custom |
Custom |
new Custom() |
firefly.cqrs.authorization.logging |
Logging |
new Logging() |
firefly.cqrs.authorization.performance |
Performance |
new Performance() |
The firefly.cqrs namespace is bound by CqrsProperties.java. Nested configuration is declared in the linked class. Defaults are the field initializers.
| Setting | Java type | Source default |
|---|---|---|
firefly.cqrs.enabled |
boolean |
true |
firefly.cqrs.command |
Command |
new Command() |
firefly.cqrs.query |
Query |
new Query() |
Property-based wiring conditions found in this module:
prefix = "firefly.cqrs", name = "enabled", havingValue = "true", matchIfMissing = true. CqrsActuatorAutoConfiguration.java.prefix = "firefly.cqrs", name = "enabled", havingValue = "true", matchIfMissing = true. CqrsAutoConfiguration.java.name = "firefly.cqrs.authorization.enabled", havingValue = "true", matchIfMissing = true. CqrsAutoConfiguration.java.prefix = "firefly.cqrs.eda", name = "enabled", havingValue = "true", matchIfMissing = true. CqrsEdaAutoConfiguration.java.
Public API and extension points¶
| Type | Contract and responsibility |
|---|---|
CommandBus |
Interface for the Command Bus in CQRS architecture. |
QueryBus |
Interface for the Query Bus in CQRS architecture. |
CommandHandler |
Base class for all command handlers in the CQRS framework. |
QueryHandler |
Base class for all query handlers in the CQRS framework. |
ExecutionContext |
Execution context for CQRS commands and queries. |
Related components¶
| Related component | Why it belongs nearby |
|---|---|
| Kernel | Share a consistent exception vocabulary across application, domain, and infrastructure boundaries. |
| Validators | Apply Jakarta Bean Validation constraints to financial, identity, contact, and date fields. |
| Common Cache Library | Use a common cache API with a local Caffeine default and pluggable distributed providers. |
| Observability | Provide a common metrics, tracing, health, and context-propagation foundation for framework components. |
| Common EDA Library | Publish and consume events through a common envelope and transport-independent APIs. |
Build and test¶
Use Java 25. From the repository root, build this component with its required dependencies:
git clone https://github.com/fireflyframework/fireflyframework-jafly.git
cd fireflyframework-jafly
./mvnw -pl :fireflyframework-cqrs -am verify
./mvnw -pl :fireflyframework-cqrs -am install
verify runs the selected projects' tests. install makes the current build available in your local Maven repository.
Representative test sources:
- CqrsFrameworkIntegrationTest.java.
- SpringCqrsIntegrationTest.java.
- CqrsActuatorIntegrationTest.java.
- AuthorizationIntegrationTest.java.
- AuthorizationResultTest.java.
- AuthorizationServiceTest.java.
Test fixture¶
The shouldCreateFailedAuthorizationResultWithSingleError test demonstrates one concrete behavior. Excerpt from AuthorizationResultTest.java. The test class supplies its fixtures and imports.
@Test
@DisplayName("Should create failed authorization result with single error")
void shouldCreateFailedAuthorizationResultWithSingleError() {
// Given
AuthorizationError error = AuthorizationError.of("account", "Account access denied");
// When
AuthorizationResult result = AuthorizationResult.failure(error);
// Then
assertThat(result.isAuthorized()).isFalse();
assertThat(result.isUnauthorized()).isTrue();
assertThat(result.getErrors()).hasSize(1);
assertThat(result.getErrors().get(0)).isEqualTo(error);
assertThat(result.getSummary()).isEqualTo("Authorization failed");
assertThat(result.getFirstError()).isEqualTo(error);
assertThat(result.getErrorMessages()).isEqualTo("Account access denied");
}
Detailed reference¶
Configuration¶
All properties live under the firefly.cqrs.* namespace. The values below are the configured defaults; you only need to set what you want to change.
firefly:
cqrs:
enabled: true # master switch for the CQRS framework
command:
timeout: 30s # default command processing timeout
metrics-enabled: true
tracing-enabled: true
query:
timeout: 15s # default query processing timeout
caching-enabled: true # enable query-result caching by default
cache-ttl: 15m # default TTL for the dedicated CQRS query cache
metrics-enabled: true
tracing-enabled: true
eda:
enabled: true # EDA classpath + the relevant publisher/cache beans are required
authorization:
enabled: true # master switch for authorization checks
custom:
enabled: true
timeout-ms: 5000 # timeout for custom authorize() logic
logging:
enabled: true
log-successful: false # only log failures/errors when false
log-performance: true
level: INFO
performance:
cache-enabled: false # cache authorization results
cache-ttl-seconds: 300
cache-max-size: 1000
async-enabled: false
Key properties¶
| Property | Default | Description |
|---|---|---|
firefly.cqrs.enabled |
true |
Enables/disables the entire CQRS framework. |
firefly.cqrs.command.timeout |
30s |
Default per-command processing timeout. |
firefly.cqrs.query.timeout |
15s |
Default per-query processing timeout. |
firefly.cqrs.query.caching-enabled |
true |
Turns query-result caching on/off globally. |
firefly.cqrs.query.cache-ttl |
15m |
Default TTL of the dedicated CQRS query cache. |
firefly.cqrs.eda.enabled |
true |
Enables the @PublishDomainEvent / @InvalidateCacheOn bridge (requires fireflyframework-eda). |
firefly.cqrs.authorization.enabled |
true |
Enables authorization on commands and queries. |
firefly.cqrs.authorization.custom.timeout-ms |
5000 |
Timeout (ms) for custom authorize() logic. |
firefly.cqrs.authorization.performance.cache-enabled |
false |
Caches authorization decisions. |
Query caching note: the actual cache provider (Caffeine, Redis, Hazelcast, JCache, or PostgreSQL) is selected by
fireflyframework-cacheviaCacheType.AUTO. Configure the provider throughfirefly.cache.*; the legacyfirefly.cqrs.query.cache.*keys are deprecated in favor of it.
Actuator endpoints¶
When Spring Boot Actuator is on the classpath, expose the CQRS endpoints:
GET /actuator/cqrs. overview (with/commands,/queries,/handlers,/healthselectors)GET /actuator/health/cqrs. CQRS framework health
How It Works¶
Auto-configuration is split into three classes, registered via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
CqrsAutoConfiguration. wiresCommandBus,QueryBus, the handler registry, validation, authorization, the dedicatedcqrsQueryCacheManager/QueryCacheAdapter, the correlation context, and metrics. Gated onfirefly.cqrs.enabled=true.CqrsActuatorAutoConfiguration. registers theCqrsMetricsEndpointandCqrsHealthIndicatorwhen Actuator is present.CqrsEdaAutoConfiguration. registers the EDA bridge (CommandEventPublisher,EventDrivenCacheInvalidator) whenfireflyframework-edais on the classpath andfirefly.cqrs.eda.enabled=true.
Each command/query flows through its bus: validate → authorize → dispatch to handler → metrics & tracing. Every bean is annotated @ConditionalOnMissingBean, so you can override any piece by declaring your own.