Chuyển tới nội dung chính

DDD and state-based CQRS

Riverbase uses domain-driven design and command/query separation, with state as the source of truth. This page explains that choice and what it means for your code.

The three ideas​

Domain-driven design. Organize the system around bounded contexts. Each context has its own model and language and owns its data. In Riverbase a bounded context is a domain with a namespace such as riverbase.todo.

Command/query separation (CQRS). Writes and reads take different paths. A command expresses intent to change state and goes through an aggregate that enforces the rules. A query reads a model shaped for reading and never changes anything.

State-based persistence. The aggregate's current state is stored and is the truth. Events describe what happened. They are kept for audit and published for integration, but state is never rebuilt from them.

Why not event sourcing​

Event sourcing rebuilds state by replaying every event. That makes history first-class, but it also makes everything else harder: schema evolution of old events, snapshotting, replay performance, and reasoning about projections that lag. Riverbase targets business backends where:

  • the current state must be queryable with ordinary SQL;
  • tenancy, row policy, and optimistic concurrency apply to rows;
  • the audit trail must be complete and atomic with the change, but nobody needs to replay it.

So Riverbase records the change and its audit trail in one transaction, and does not derive state from the trail.

What a command produces​

RecordWritten byPurpose
State row (todo_items)Aggregate upsert / update / removeThe truth
Command and context logCommand engineWho asked for what, with which context
Event (event_log)#[domain_action] on successWhat changed, keyed by the action (todo.created)
Activityemit_activityHuman-readable audit entries
Messageemit_message → outboxIntegration facts for other contexts
Response and idempotencyCommand engineReplay-safe responses for retried requests

When the command's state and the audit log share one Postgres database, all of it commits or none of it does. The outbox is delivered after commit, at least once, so consumers must deduplicate.

Operation classes​

Not every use case is simply a command or a query. Classify each one before building it:

ClassDoesImplement as
Authoritative mutationChanges aggregate stateA command
Read or calculationReturns data, changes nothingA query or report
Derived-model writeUpdates a projection or read modelAn idempotent projection handler fed by messages
External effectCalls a broker, provider, or remote systemA post-commit effect, from the outbox or a worker

A report that writes, or a command handler that calls a payment provider inside its transaction, breaks these boundaries.

Consequences for your code​

  • Put invariants in aggregate methods and change state only inside #[domain_action].
  • Use queries for every read a UI needs. Do not read through the command path.
  • Publish integration messages with emit_message, and consume them idempotently.
  • Coordinate across domains with process managers and messages. One command must not write two domains' state.

Related: Command and query engines · Persistence, audit and messaging.