A framework-agnostic CQRS core, proven by three independent consumers: Spring Boot, plain Java, and Micronaut (in a separate repository).
A CQRS framework where the core (command/query/event buses, handler registration, interceptors) has zero dependencies on any DI container. The thesis — that core is framework-agnostic — is not an assertion; it is demonstrated by three independent consumers of the same core jar:
demo— a Book bounded context running on Spring Boot, PostgreSQL (write), MongoDB (read), and the outbox pattern. Hexagonal, production-shaped. In-repo.demo-vanilla— an Order bounded context bootstrapped with plain Java. No Spring, no DI container, manual handler registration, in-memory adapters, Javalin as the only web dependency. In-repo.myCQRS-micronaut— a Subscription bounded context running on Micronaut 4.x, in a separate repository. Consumesmycqrs-coreandmycqrs-micronautonly as published Maven artifacts from GitHub Packages. The strongest form of the portability claim: no shared source code, no monorepo conveniences — if the library leaks a Spring contract, that repo'smvn compilefails.
Swapping Spring for Micronaut, Quarkus, or plain new requires no changes to core.contracts or core.ddd — verified by ArchUnit and made executable by three separately packaged integration artifacts (mycqrs-core, mycqrs-spring, mycqrs-micronaut). mvn dependency:tree -pl src/demo-vanilla | grep springframework returns empty.
The codebase is designed to be read: every architectural choice is small enough to explain via code, and the ones that aren't obvious are documented as ADRs.
demo is deployed on Render free tier. Swagger UI entry point:
https://mycqrs.onrender.com/swagger-ui.html
Free tier; first request after idle may take ~30s. Conscious trade-off — production would use a paid tier or
min-instances=1.
| Endpoint | Purpose |
|---|---|
PUT /books/{id} |
Creates a book (client-generated UUID). |
GET /actuator/outbox |
Outbox stats — watch pending drain to processed. |
GET /books/{id} |
Reads from the Mongo projection. |
Deployment rationale: ADR 0007.
See the dashboard at / for the full observability surface (commands, outbox stats, recent events, read-side projections, write↔read snapshot).
demo-vanilla is not deployed — it's designed to run locally (mvn exec:java) and in tests. Its value is the proof of portability, not a production surface.
Served at / by the running demo deployment. Single-page HTML (no framework, no build
step) that exposes the full CQRS pipeline end-to-end in one view:
- Write side: create/update/delete books and authors, link an author to a book, and see the last ten UI-issued commands with per-call latency.
- Outbox: pending / processed counters, last processed timestamp, poll interval; polled every 2 s with a stale indicator on failure.
- Recent events: last 10 outbox rows with derived status
(
pending/processed/failed) and latency in milliseconds. Fed byGET /actuator/outbox-recent. - Read side: author and book projections, auto-refreshed after each command with a visible consistency delay; partial, case-insensitive search by book title.
- Write ↔ Read snapshot: for the currently-tracked author or book,
the Postgres row (normalised, with the
book_authorsjoin) next to the Mongo document (denormalised, with embedded author / book summaries) plus the last 10 event-log entries. Fed byGET /actuator/entity-snapshot/{kind}/{id}.
The primary observability surface of the demo — the only place where the command → outbox → projection flow is visible in real time. Rationale in ADR 0009; the LOC budget and the entity-snapshot contract are amended in ADR 0010.
HTTP request
│
▼
Controller ──► CommandBus ──► [ Validation ─► Transaction ─► Handler ]
│
▼
Aggregate UPDATE + Outbox INSERT
(single PostgreSQL transaction)
│
▼
OutboxPoller (scheduled)
│
▼
EventBus (in-memory)
│
▼
Projectors ──► MongoDB
HTTP request ──► Controller ──► QueryBus ──► QueryHandler ──► MongoDB
The outbox solves the dual-write problem. The aggregate row and the event envelope are written in the same PostgreSQL transaction — either both commit or neither does. A scheduled poller drains the outbox, publishes each event through an in-memory bus, and Mongo projectors update the read model. No distributed transaction, no lost events, no ordering gap between "event published" and "state committed".
Main / Javalin ──► CommandBus ──► [ LoggingInterceptor ─► Handler ]
│
▼
InMemoryRepository.save()
│
▼
handler publishes events inline
│
▼
EventBus ──► Projection handlers
│
▼
InMemoryReadModel
QueryBus decorated with LoggingQueryBus (decorator, pending ADR 0014
for a symmetric QueryInterceptor pipeline in core).
No transactions (in-memory), so event publication happens inline in the command handler rather than via a TransactionalCommandInterceptor. Same core, different contract — exactly the point.
src/core— reusable CQRS framework. Published ascom.oscaruiz:mycqrs-core. Genuinely framework-agnostic: the flattened POM has zero Spring and zero Micronaut dependencies. Integration adapters are separate sibling modules (see below). ArchUnit enforces the boundary.core.contracts— ports:Command,CommandBus,CommandHandler,CommandInterceptor,Event,EventBus,EventHandler,Query,QueryBus,QueryHandler.core.ddd—AggregateRoot<ID>,DomainEvent.core.idempotency—ProcessedCommandsStoreport +IdempotencyCommandInterceptor(no container deps).core.infrastructure.bus— in-memorySimpleCommandBus,SimpleQueryBus,SimpleEventBus.core.infrastructure.observability—CorrelationIdCommandInterceptor(no container deps).
src/core-spring— Spring Boot integration. Published ascom.oscaruiz:mycqrs-spring. Houses@EnableCqrs,CqrsConfiguration, the handler-auto-registeringBeanPostProcessors, Validation and Transactional interceptors, and the Spring-JDBC adapter for idempotency. Consumers using Spring Boot add this module alongsidemycqrs-core.src/core-micronaut— Micronaut 4.x integration. Published ascom.oscaruiz:mycqrs-micronaut. Houses@EnableCqrs(marker for symmetry),CqrsFactory,MicronautHandlerRegistrar(aStartupEventlistener), and Validation / Transactional interceptors. Idempotency wiring is out of scope in this release — see ADR 0016.src/demo— Book bounded context, Spring Boot adapter. Consumesmycqrs-core+mycqrs-spring. PostgreSQL + Flyway on the write side, MongoDB on the read side, outbox poller in between.src/demo-vanilla— Order bounded context, plain-Java adapter. Consumes onlymycqrs-core— zero Spring, zero Micronaut. Manual bootstrap viaVanillaBootstrapper, Javalin for HTTP, in-memory repository and read model. Logging viaLoggingCommandInterceptor(using the core's own interceptor contract) andLoggingQueryBus(decorator, documented as temporary pending ADR 0014). Designed to be read as executable proof thatcoreis portable. See ADR 0013.
The framework is published to GitHub Packages as three versioned Maven artifacts that share a single version axis. Consumers pick whichever integration they use, or none.
- Already on 1.3.1? See
MIGRATION.mdfor the upgrade path. - Rationale for the three-artifact split: ADR 0016. Publication infrastructure: ADR 0015.
<dependency>
<groupId>com.oscaruiz</groupId>
<artifactId>mycqrs-core</artifactId>
<version>1.4.0</version>
</dependency>This is what demo-vanilla uses. No Spring, no Micronaut. mvn dependency:tree returns zero org.springframework.* and zero io.micronaut.* entries.
<dependency>
<groupId>com.oscaruiz</groupId>
<artifactId>mycqrs-core</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>com.oscaruiz</groupId>
<artifactId>mycqrs-spring</artifactId>
<version>1.4.0</version>
</dependency>Note:
mycqrs-coreand your chosen integration module must share the same version. See MIGRATION.md — Version coupling requirement.
Activate with @EnableCqrs on a @Configuration class (usually the @SpringBootApplication entry point). This is what demo uses.
<dependency>
<groupId>com.oscaruiz</groupId>
<artifactId>mycqrs-core</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>com.oscaruiz</groupId>
<artifactId>mycqrs-micronaut</artifactId>
<version>1.4.0</version>
</dependency>Note:
mycqrs-coreand your chosen integration module must share the same version. See MIGRATION.md — Version coupling requirement.
Activation is implicit — Micronaut's annotation processor discovers CqrsFactory and MicronautHandlerRegistrar at compile time. The @EnableCqrs marker is provided for source-level symmetry with the Spring variant and has no runtime effect. Idempotency interceptor wiring is out of scope in this release; see ADR 0016 for details.
A complete external consumer using this variant is published as a separate repository: myCQRS-micronaut.
Declare the repository in the consumer's pom.xml:
<repositories>
<repository>
<id>github-mycqrs</id>
<url>https://maven.pkg.github.com/oscaruiz/myCQRS</url>
</repository>
</repositories>Authenticate in ~/.m2/settings.xml with a GitHub PAT that has the read:packages scope:
<servers>
<server>
<id>github-mycqrs</id>
<username>YOUR_GITHUB_USERNAME</username>
<password>YOUR_PAT_WITH_read:packages</password>
</server>
</servers>GitHub Packages requires a PAT with
read:packageseven for public packages. This is a GitHub limitation, not a project choice — expect to spend one minute creating a fine-scoped PAT the first time you consume the artifact.
Each released version is tagged v<version> in this repo; see releases for a list.
- Java 21, Maven (multi-module).
demo: Spring Boot 3.2.5, PostgreSQL + Flyway (ddl-auto=validate), MongoDB, Testcontainers.demo-vanilla: Javalin, Jackson, SLF4J simple. No Spring, no Micronaut, no Quarkus.- JUnit 5, Mockito, AssertJ, ArchUnit.
- Docker (multi-stage build for
demo), GitHub Actions CI.
Prerequisites: Java 21, Docker Desktop, and the bundled Maven wrapper (mvnw.cmd).
# Start PostgreSQL and MongoDB in containers
docker compose -f src\demo\docker-compose.yml up -d postgres mongo
# Run the demo application (dev profile; Flyway applies V1/V2/V3 on boot)
.\mvnw.cmd spring-boot:run -pl src/demoThe API uses client-generated UUIDs: the client picks the identifier, PUT creates the resource at that URI. Author is a separate aggregate; a book with an author is three writes (create author, create book, link). See ADR 0008.
$BOOK = "550e8400-e29b-41d4-a716-446655440000"
$AUTHOR = "b1e2c3d4-5678-90ab-cdef-1234567890ab"
# Create the author, then the book, then link them
curl.exe -X PUT "http://localhost:8080/authors/$AUTHOR" `
-H "Content-Type: application/json" `
-d '{\"firstName\":\"Sun\",\"lastName\":\"Tzu\",\"birthYear\":-544}'
curl.exe -X PUT "http://localhost:8080/books/$BOOK" `
-H "Content-Type: application/json" `
-d '{\"title\":\"The Art of War\"}'
curl.exe -X POST "http://localhost:8080/books/$BOOK/authors/$AUTHOR"
# Read (served from the Mongo projection, populated by the outbox poller)
curl.exe "http://localhost:8080/books/$BOOK"
# Update the title
curl.exe -X PATCH "http://localhost:8080/books/$BOOK" `
-H "Content-Type: application/json" `
-d '{\"title\":\"The Art of War (revised)\"}'
# Delete
curl.exe -X DELETE "http://localhost:8080/books/$BOOK"GET /books?title=… looks a book up by title in the read model.
No containers, no external services. Just the core, bootstrapped by hand:
# Run the narrated flow — creates and confirms an Order, prints each step
.\mvnw.cmd -pl src/demo-vanilla exec:java
# Or, if you prefer HTTP (Javalin on :8080):
# The Main class starts a Javalin server in the same run.The CQRS flow is visible in the console output — command in, event published, projection updated, query returning the new state. Intended to be read, not deployed. The same flow is exercised as an end-to-end test in CqrsFlowDemonstrationTest.
Framework independence is executable:
.\mvnw.cmd -pl src/demo-vanilla dependency:tree | Select-String "springframework"
# (empty output).\mvnw.cmd verifyCore tests run on JUnit + Mockito + AssertJ with no Spring context. demo integration tests boot @SpringBootTest against Testcontainers (PostgreSQL + MongoDB) wired via @ServiceConnection; H2 is not used anywhere. demo-vanilla tests run without Spring, without containers, against in-memory adapters — the assertion is that the core jar works in that environment too.
ArchUnit enforces package boundaries in CI: contracts and ddd must not depend on Spring, the Book context must follow an onion shape, command handlers must not call each other directly, and no module may contain a slice cycle.
Significant decisions — including deliberate non-adoptions such as Event Sourcing — are documented as ADRs in docs/adr/. The README intentionally does not summarize them; open the directory when a specific choice matters.
Implemented
- Framework-agnostic core published as three sibling artifacts (
mycqrs-core,mycqrs-spring,mycqrs-micronaut) sharing a single version axis. Themycqrs-corePOM is free of any Spring or Micronaut dependencies; consumers add the adapter they use explicitly. Rationale: ADR 0016. - Framework independence demonstrated by four consuming surfaces:
demo-vanillaon the baremycqrs-corejar (no DI container),demoonmycqrs-core + mycqrs-spring(Spring Boot),core-micronaut's own integration test onmycqrs-core + mycqrs-micronaut, and themyCQRS-micronautrepo as an external Maven consumer. Proves by example — including by external consumption from a separate repository — what ArchUnit proves by assertion. - Handler auto-registration via
BeanPostProcessors for commands, queries, and events (demo); via aStartupEventlistener in the Micronaut adapter; manual registration inVanillaBootstrapper(demo-vanilla). - Chainable command interceptors in a fixed order: validation → transaction → handler.
TransactionalCommandInterceptorusesPlatformTransactionManagerwithPROPAGATION_REQUIREDand commits outside thetryblock so a failing commit cannot trigger an invalid rollback. - Outbox pattern (in
demo):OutboxEventBus(marked@Primary) writes events to the outbox table inside the aggregate's transaction;OutboxPollerdrains the table asynchronously and dispatches to the internal in-memory bus, where Mongo projectors subscribe. - Optimistic locking on the aggregate via
@Versionon the JPA entity;GlobalExceptionHandlermaps domain and infrastructure exceptions to meaningful HTTP status codes (e.g.ObjectOptimisticLockingFailureException→ 409). - Client-generated UUIDs;
PUT/PATCH/DELETEfor writes andGETfor reads. - Flyway migrations with
ddl-auto=validatein every environment. - ArchUnit enforcement of architectural boundaries in both modules.
- Testcontainers (PostgreSQL + MongoDB) for every
demointegration test. - Spring profiles for
devandtest(demoonly). - Docker multi-stage image and GitHub Actions CI running
./mvnw verify. - Post-deploy smoke test against
/actuator/healthin the deploy workflow; each successful deploy registers a first-class GitHub Deployment against theproductionenvironment.
Planned
- Symmetric
QueryInterceptorpipeline in core (ADR 0014) — currentlydemo-vanillauses a decorator as a tactical workaround for query-side cross-cutting concerns. - End-to-end correlation ID propagated from HTTP request through command, event, and projection.
- Idempotency interceptor wiring in the Micronaut adapter (parity with Spring, currently out of scope in the first cut of
mycqrs-micronaut; see ADR 0016).
GPL-3.0. See LICENSE.
One-time manual steps after the first merge to main triggers the deploy workflow:
- Provision a Neon Postgres free-tier database. Capture the JDBC URL (with
sslmode=require), username, and password. - Provision a MongoDB Atlas M0 cluster. Allow network access from
0.0.0.0/0(Render free tier has no static IPs — documented trade-off). Capture themongodb+srvURI including the database name. - Create a Render Web Service in "Deploy an existing image from a registry" mode, pointing at
ghcr.io/<owner>/mycqrs:latest. Set the four env vars:SPRING_DATASOURCE_URL,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,SPRING_DATA_MONGODB_URI. - Copy the Render deploy hook URL into the GitHub repository secret
RENDER_DEPLOY_HOOK. - Create GitHub repo secret
RENDER_APP_URLcontaining the public URL of the Render service (no trailing slash). Used by the deploy workflow's post-deploy smoke test. - In the GitHub repository settings, ensure workflow permissions are set to "Read and write" so the deploy workflow can push to GHCR.
- After the first successful deploy workflow run, flip the GHCR package visibility to public (GitHub profile → Packages →
mycqrs→ Package settings → Change visibility). Only needed once.