Skip to content

Jafly. Common CQRS Library

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

SubmitApplication.java:

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

FindApplication.java:

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;
    }
}

FindApplicationHandler.java:

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

OnboardingApplication.java:

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

ApplicationsController.java:

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:

./mvnw -f docs/examples/getting-started/pom.xml spring-boot:run

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:

./mvnw -B -ntp -f docs/examples/getting-started/pom.xml verify

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:

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

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-cache via CacheType.AUTO. Configure the provider through firefly.cache.*; the legacy firefly.cqrs.query.cache.* keys are deprecated in favor of it.

Actuator endpoints

When Spring Boot Actuator is on the classpath, expose the CQRS endpoints:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, cqrs
  • GET /actuator/cqrs. overview (with /commands, /queries, /handlers, /health selectors)
  • 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. wires CommandBus, QueryBus, the handler registry, validation, authorization, the dedicated cqrsQueryCacheManager/QueryCacheAdapter, the correlation context, and metrics. Gated on firefly.cqrs.enabled=true.
  • CqrsActuatorAutoConfiguration. registers the CqrsMetricsEndpoint and CqrsHealthIndicator when Actuator is present.
  • CqrsEdaAutoConfiguration. registers the EDA bridge (CommandEventPublisher, EventDrivenCacheInvalidator) when fireflyframework-eda is on the classpath and firefly.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.

Source and further reading