This document describes the Distributed Audit Ledger system architecture using CQRS (Command Query Responsibility Segregation) + Event Sourcing + Blockchain Anchoring patterns.
Source: diagrams/system-overview.puml
The platform implements a CQRS + Event Sourcing architecture with blockchain-backed integrity:
- Command API (
command-service) receives business commands - Event Emission - commands are validated and transformed into immutable domain events
- Kafka Backbone - events are published to
user.login.eventstopic- Independent event-store consumer (persists to PostgreSQL)
- Independent audit-writer consumer (anchors to blockchain)
- Query Service - provides read API for audit logs with integrity status
- Blockchain Anchor - event hashes are persisted on-chain for tamper-proof verification
| Service | Port | Purpose | Key Technology |
|---|---|---|---|
| command-service | 8081 | Write-side API for commands | WebFlux, Kafka Producer |
| event-store-service | 8082 | Immutable event persistence | Kafka Consumer, R2DBC, Flyway |
| audit-writer-service | 8083 | Blockchain anchoring worker | Kafka Consumer, Web3j, Ganache |
| query-service | 8084 | Read-side API for audit logs | WebFlux, R2DBC, Dynamic Queries |
| Module | Purpose |
|---|---|
| common/event-model | Domain event classes (AuditEvent, UserLoggedInEvent, EventType) |
| common/shared-contracts | Shared DTOs (UserLoginCommand, AuditEventDto, CommandResponse) and CanonicalObjectMapperFactory |
- AuditLedger.sol - Solidity smart contract for append-only hash ledger
- Owner-gated write access (
onlyOwner) - Duplicate hash rejection (
DuplicateHash) - Random read access for integrity checks
- Owner-gated write access (
- Ganache - Local Ethereum-compatible development chain (chainId: 1337)
- Hardhat - Compilation, testing, and deployment framework
| Component | Purpose | Port |
|---|---|---|
| PostgreSQL 16 | Event store database (audit schema) |
5432 |
| Kafka + Zookeeper | Async event broker and coordination | 9092 / 2181 |
| Ganache | Local blockchain (RPC endpoint) | 8545 |
| pgAdmin | PostgreSQL administration UI | 5050 |
| Kafka UI | Kafka monitoring/management | 8080 |
Source: diagrams/event-topic-flow.puml
CREATE TABLE audit.events (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(36) NOT NULL UNIQUE, -- Stable UUID
aggregate_id VARCHAR(128) NOT NULL, -- derived key (for login: user:<userId>)
event_type VARCHAR(128) NOT NULL, -- e.g., USER_LOGGED_IN
user_id VARCHAR(255), -- Denormalized for filtering
payload JSONB NOT NULL, -- Full event data
event_hash VARCHAR(64), -- SHA-256 (computed by event-store)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for common queries
CREATE INDEX idx_events_aggregate_id ON audit.events (aggregate_id);
CREATE INDEX idx_events_event_type ON audit.events (event_type);
CREATE INDEX idx_events_user_id ON audit.events (user_id);
CREATE INDEX idx_events_created_at ON audit.events (created_at);Critical: Both event-store-service and audit-writer-service must compute hashes identically:
// ✅ CORRECT (ensures byte-identical JSON serialization)
ObjectMapper mapper = CanonicalObjectMapperFactory.create();
byte[] eventBytes = mapper.writeValueAsBytes(event);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String eventHash = HexFormat.of().formatHex(digest.digest(eventBytes));
// ❌ WRONG (silent hash mismatch)
ObjectMapper mapper = new ObjectMapper(); // Default ordering differs
byte[] wrongBytes = mapper.writeValueAsBytes(event);
String eventHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(wrongBytes));Features of CanonicalObjectMapperFactory:
- Sorted JSON field keys (deterministic serialization)
- ISO-8601
Instantformatting - No type headers
- Compact output
AuditLedger.sol - Immutable append-only ledger on Ganache:
struct AuditRecord {
bytes32 eventHash; // SHA-256 of canonical event JSON
uint256 timestamp; // Seconds since epoch
string eventType; // e.g., "USER_LOGGED_IN"
address source; // Writer address (TODO: for future multi-writer support)
}
function appendAuditRecord(
bytes32 _eventHash,
uint256 _timestamp,
string memory _eventType,
address _source
) public onlyOwner {
// ✅ Rejects duplicate hashes
// ✅ Stores immutable record
}
function isHashExists(bytes32 _hash) public view returns (bool) {
// Read-side query for integrity checks
}All backend services are reactive-first with Spring WebFlux + Project Reactor, with controlled blocking in Kafka listeners where offset semantics require completion guarantees:
Source: diagrams/reactive-architecture.puml
- R2DBC (Reactive Relational Database Connectivity) replaces JPA
event-store-serviceKafka consumer blocks onpersist(...).block()so Kafka offset commit is aligned with DB write outcomeaudit-writer-serviceuses Web3j (blocking RPC) behind Kafka error-handler retries/backoff- Backpressure handling via Flux operators on read APIs
- Database queries use dynamic SQL (not ORM) for performance
| Document | Purpose |
|---|---|
| docs/ARCHITECTURE.md | This file: component boundaries and integration |
| docs/CQRS_FLOW.md | Step-by-step runtime flow with examples |
| docs/DEPLOYMENT.md | Quickstart guide and deployment workflows |
| docs/TESTING_SCENARIOS.md | Curl commands and smoke test scripts |
| deploy/README.md | Infrastructure (Docker Compose) setup and troubleshooting |
| backend/README.md | Backend module build/run instructions |
| blockchain/README.md | Blockchain module compile/test/deploy |
- CQRS Split: Separate write (command-service) and read (query-service) paths decouple scaling constraints
- Event Sourcing: Kafka + PostgreSQL provides immutable event log and temporal query capability
- Blockchain Anchoring: Event hashes on-chain provide a tamper-evident trail independent of DB
- Async Consumers: Two independent Kafka consumers (event-store, audit-writer) allow independent failure handling
- Reactive Stack: WebFlux + R2DBC minimize thread context switches and connection pool pressure
- Canonical JSON: Deterministic serialization (sorted fields) ensures DB and blockchain hashes match
- Dead-Letter Topic: DLT is used for recoverable/terminal errors; configuration/receipt-timeout failures are rethrown to keep source offsets uncommitted


