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
| Record | Written by | Purpose |
|---|---|---|
State row (todo_items) | Aggregate upsert / update / remove | The truth |
| Command and context log | Command engine | Who asked for what, with which context |
Event (event_log) | #[domain_action] on success | What changed, keyed by the action (todo.created) |
| Activity | emit_activity | Human-readable audit entries |
| Message | emit_message → outbox | Integration facts for other contexts |
| Response and idempotency | Command engine | Replay-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:
| Class | Does | Implement as |
|---|---|---|
| Authoritative mutation | Changes aggregate state | A command |
| Read or calculation | Returns data, changes nothing | A query or report |
| Derived-model write | Updates a projection or read model | An idempotent projection handler fed by messages |
| External effect | Calls a broker, provider, or remote system | A 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.