Chronicle is a production-grade, embeddable Event Sourcing engine for Java 21 and PostgreSQL. It persists domain events as immutable JSONB records, reconstructs aggregate state via replay, enforces optimistic locking, takes SHA-256-signed snapshots, and drives CQRS read models through an async projection engine.
The chronicle-example module demonstrates these capabilities with a fully functional bank account REST API.
Traditional systems store the current state of an entity. Event Sourcing stores the sequence of events that led to that state. This means:
- Every state change is captured as an immutable fact.
- State is reconstructed by replaying events in order.
- The full history of any entity is always available — no data is ever lost.
- Past states can be reconstructed by replaying up to any point in time.
When to use it:
- Financial systems where every cent must be auditable
- Complex domains where "why did this happen" matters as much as "what happened"
- Systems requiring temporal queries or rollback to past states
- CQRS architectures where reads and writes scale independently
chronicle/
├── chronicle-core/ # Zero Spring. Pure Java 21. Interfaces and domain model.
├── chronicle-jdbc/ # PostgreSQL implementation via spring-jdbc.
└── chronicle-example/ # Spring Boot REST API demonstrating the engine.
chronicle-core has zero Spring dependency — it can be embedded in any Java application. chronicle-jdbc provides the PostgreSQL-backed implementation. chronicle-example wires everything together with a production-style Spring Boot API.
sequenceDiagram
participant Client
participant API
participant ChronicleEngine
participant SnapshotStore
participant EventStore
participant ProjectionEngine
Client->>API: POST /api/accounts/{id}/deposit {"amountCents": 50000}
API->>ChronicleEngine: load(aggregateId)
ChronicleEngine->>SnapshotStore: loadLatest(aggregateId)
SnapshotStore-->>ChronicleEngine: Snapshot (SHA-256 verified)
ChronicleEngine->>EventStore: loadAfterVersion(aggregateId, snapshotVersion)
EventStore-->>ChronicleEngine: StoredEvent[]
ChronicleEngine-->>API: AggregateRoot (state replayed)
API->>API: deposit(50000) → MoneyDeposited event
API->>ChronicleEngine: save(aggregateRoot)
ChronicleEngine->>EventStore: INSERT events (expectedVersion server-side)
Note over EventStore: UNIQUE(aggregate_id, version) enforces ordering
EventStore-->>ChronicleEngine: committed
ChronicleEngine->>SnapshotStore: saveSnapshot() [every 50 events]
ProjectionEngine->>EventStore: poll new events
ProjectionEngine->>ProjectionEngine: UPDATE balances SET balance = balance + 50000
ChronicleEngine-->>API: saved
API-->>Client: 200 AccountResponse
sequenceDiagram
participant Client
participant API
participant RateLimit
participant ReadModel
Client->>API: GET /api/accounts/{id}
API->>RateLimit: check bucket (100 req/min per IP)
RateLimit-->>API: allowed
API->>ReadModel: SELECT * FROM balances WHERE account_id = ?
ReadModel-->>API: {id, ownerName, balance, updatedAt}
API-->>Client: 200 AccountResponse
flowchart TD
A([Receive Command]) --> B[Load Aggregate\nSnapshot + remaining events]
B --> C[Apply business logic\nProduce domain events]
C --> D[INSERT INTO events\nexpectedVersion = server-side only]
D --> E{UNIQUE constraint\nviolation?}
E -- No --> F[Save Snapshot\nif version mod 50 = 0]
F --> G[Projection Engine\npolls + updates read model]
G --> H([Return 200 Response])
E -- Yes, attempt ≤ 3 --> B
E -- Yes, attempt > 3 --> I([Return 409 Conflict])
flowchart TD
A([load aggregateId]) --> B[SELECT FROM snapshots]
B --> C{Snapshot\nexists?}
C -- No --> D[Full event replay\nfrom version 1]
C -- Yes --> E[Recalculate SHA-256\nfrom state JSON]
E --> F{Checksum\nmatches?}
F -- Yes --> G[Restore state\nfrom snapshot]
G --> H[Load events\nafter snapshot version]
H --> I([AggregateRoot ready])
F -- No --> J[WARN: snapshot tampered\nor corrupted]
J --> D
D --> I
| Technology | Version | Role |
|---|---|---|
| Java | 21 | Language + records, sealed classes, switch expressions |
| Spring Boot | 3.x | Web, JDBC, Validation, Lifecycle management |
| PostgreSQL | 16 | Append-only event store with JSONB payload |
| Jackson | 2.x | Type-safe, whitelist-only event serialization |
| Flyway | 9.x | Schema migrations (5 migrations, V1–V5) |
| JUnit 5 | 5.x | Unit + integration + adversarial + stress tests |
| Testcontainers | 1.x | Real PostgreSQL in all integration tests — no H2 |
| Bucket4j | 8.x | In-memory rate limiting (100 req/min per IP) |
| springdoc-openapi | 2.x | Swagger UI at /swagger-ui.html |
- Java 21
- Docker (for PostgreSQL via docker-compose or Testcontainers)
- Maven (or use the included
mvnwwrapper)
docker-compose up -d./mvnw spring-boot:run -pl chronicle-exampleThe API starts on port 8080. Swagger UI is at:
http://localhost:8080/swagger-ui.html
./mvnw test./mvnw clean package -DskipTestscurl -s -X POST http://localhost:8080/api/accounts \
-H "Content-Type: application/json" \
-d '{"ownerName":"Alice Smith"}' | jq .{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"ownerName": "Alice Smith",
"balanceCents": 0,
"createdAt": "2026-05-16T22:00:00Z"
}curl -s -X POST http://localhost:8080/api/accounts/{id}/deposit \
-H "Content-Type: application/json" \
-d '{"amountCents":50000,"description":"Salary"}' | jq .curl -s -X POST http://localhost:8080/api/accounts/{id}/withdraw \
-H "Content-Type: application/json" \
-d '{"amountCents":10000,"description":"ATM"}' | jq .curl -s -X POST http://localhost:8080/api/accounts/{fromId}/transfer \
-H "Content-Type: application/json" \
-d '{"toAccountId":"{toId}","amountCents":5000,"description":"Payment"}' | jq .curl -s http://localhost:8080/api/accounts/{id} | jq .curl -s http://localhost:8080/api/accounts/{id}/events | jq .
# Events after version 3
curl -s "http://localhost:8080/api/accounts/{id}/events?after=3" | jq .An aggregate is a cluster of domain objects treated as a single unit. In Chronicle, aggregates extend Aggregate<S> and implement a pure apply(S state, DomainEvent event) function that returns a new state without side effects.
A DomainEvent is an immutable record of something that happened. Events are stored once and never modified or deleted. StoredEvent wraps the domain event with metadata: eventId, aggregateId, version, timestamp, and a JSONB payload.
Snapshots accelerate state reconstruction by capturing the aggregate state at a point in time. Chronicle stores a SHA-256 checksum alongside each snapshot and recalculates it on load — a mismatch triggers a full event replay and a security warning. This detects tampering and storage corruption.
Projections consume the event stream and build denormalized read models optimized for queries. The ProjectionEngine polls for new events, distributes them to registered projections, and tracks its cursor position so it resumes correctly after a restart. All projections are idempotent — re-delivering an event has no effect.
Chronicle was designed with an adversarial mindset. Every layer is independently secure.
ObjectMapper is configured with FAIL_ON_UNKNOWN_PROPERTIES=true and BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES=true. The EventTypeRegistry is a strict whitelist — only explicitly registered event types can be deserialized. Unknown types throw IllegalArgumentException. This prevents gadget-chain RCE (CVE-2017-7525). activateDefaultTyping() and enableDefaultTyping() are permanently prohibited.
Events are written once and never modified or deleted. Immutability is enforced at three independent layers: the EventStore interface exposes no delete() or update() method; a PostgreSQL REVOKE UPDATE, DELETE statement removes database-level permissions; and a BEFORE UPDATE OR DELETE trigger raises an exception if any mutation is attempted by a privileged user.
A UNIQUE(aggregate_id, version) constraint in PostgreSQL enforces sequential versioning. expectedVersion is always calculated server-side from the aggregate's current version — it is never accepted from the HTTP request body. Concurrent saves from stale reads produce a DataIntegrityViolationException which is translated to a ConcurrentModificationException. The service layer retries automatically up to 3 times.
Every snapshot is stored with a SHA-256 checksum of its state JSON. On load, the checksum is recalculated and compared. A mismatch means the snapshot was tampered with or corrupted — Chronicle discards it, logs a [SECURITY] warning, and falls back to full event replay. The system is never left in an inconsistent state.
All DTOs use Bean Validation annotations (@NotBlank, @NotNull, @Min, @Max, @Size). Every controller method uses @Valid on all @RequestBody parameters. Client request bodies may never contain version, aggregateType, or eventType fields — these are server-controlled. Invalid UUIDs in path parameters return 400, not 500.
Every database query uses prepared statements with ? placeholders. JSONB payloads are inserted via PGobject — never by string concatenation. There is no dynamic SQL in the codebase.
The API never returns raw JSONB, internal class names, or stack traces. EventResponse provides a filtered summary map. application.yml sets server.error.include-stacktrace: never and server.error.include-message: never. Logs contain no PII or complete financial payloads.
All requests are rate-limited to 100 per minute per IP using Bucket4j. The client IP is read from HttpServletRequest.getRemoteAddr() — never from X-Forwarded-For, which is spoofable. Every HTTP response includes: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Cache-Control: no-store, Content-Security-Policy: default-src 'self', and Server: chronicle.
Chronicle uses optimistic concurrency control — no database-level locks are held during business logic execution.
- Load aggregate state (snapshot + remaining events).
- Apply business logic and produce new events.
- Attempt to save:
INSERT INTO eventswith the next expected version. - If another writer saved first, PostgreSQL raises a
UNIQUE(aggregate_id, version)violation. - Chronicle translates this to
ConcurrentModificationException. - The service layer retries from step 1, up to 3 times.
The stress tests show this model handles 10 concurrent writers, each performing 100 operations, with zero data loss. Exponential backoff with random jitter prevents synchronized retry storms. The final event count is always exactly correct — every operation is eventually committed.
| Test | Threads | Operations | Result |
|---|---|---|---|
| Mass deposits | 10 | 100 deposits each | 1000 cents — zero loss |
| Concurrent withdrawals | 5 | 300 cents each from 1000 | Exactly 3 succeed, balance 100 cents |
| Projection convergence | 5 | 50 deposits each (10 cents) | 2500 cents in read model |
| Category | Count | Description |
|---|---|---|
| Unit tests (core) | 39 | Serialization, registry, StoredEvent validation |
| JDBC integration | 19 | JdbcEventStore with real PostgreSQL via Testcontainers |
| Domain unit tests | 40 | BankAccount rules, aggregate behavior |
| Engine integration | 16 | ChronicleEngine roundtrip, optimistic locking |
| Projection tests | 19 | Idempotency, resume, reset, concurrent writes |
| API tests | 18 | All endpoints, validation, edge cases |
| Adversarial security | 14 | Penetration testing vectors |
| Stress tests | 3 | 10×100 deposits, concurrent withdrawals, projection convergence |
| E2E integration | 1 | Full lifecycle via real HTTP (TestRestTemplate) |
All integration tests use Testcontainers with real PostgreSQL 16. No H2, no mocks for the database. JSONB and PostgreSQL triggers behave identically in tests and production.
| Vector | Test |
|---|---|
Jackson deserialization with @type field |
JacksonEventSerializerTest |
| Unknown event type in registry | EventTypeRegistryTest |
| Concurrent EventTypeRegistry access | EventTypeRegistryConcurrencyTest |
| StoredEvent oversized payload | StoredEventTest |
| SQL injection via payload | JdbcEventStoreSecurityTest |
| Events immutable (UPDATE/DELETE blocked) | JdbcEventStoreSecurityTest |
| Snapshot checksum tamper detection | SnapshotSecurityTest |
| Negative balance invariant | BankAccountSecurityTest |
| Self-transfer prevention | BankAccountSecurityTest |
| Version injection from client | ChronicleEngineSecurityTest |
| Optimistic lock stale read | ChronicleEngineSecurityTest |
| Projection idempotency under re-delivery | ProjectionSecurityTest |
| Invalid UUID in path → 400 | AccountApiTest |
| Extra fields in body → 400 | AccountApiTest |
| Malformed JSON → 400, no stack trace | AccountApiTest |
after=-1 parameter → 400 |
AccountApiTest |
| Security headers on all responses | AccountApiTest |
| Transfer to non-existent account → 404, no debit | AccountApiTest |
| Rate limiting (burst) | AdversarialSecurityTest |
| Concurrent withdrawals — balance never negative | ConcurrencyStressTest |
| 10×100 deposits — zero data loss | ConcurrencyStressTest |
| Projection convergence under concurrent writes | ConcurrencyStressTest |
- Read the architecture documentation — it is the single source of truth for architecture decisions.
- Follow TDD: write the test before the implementation.
- No mocks for the database — use Testcontainers.
- No
floatordoublefor monetary amounts — alwayslong(cents). - No
@Autowiredon fields — constructor injection only. - Security decisions require a
// [SECURITY]comment with justification. - Every PR must pass
./mvnw testbefore merge.
MIT — see LICENSE.