Skip to main content

The CQRS Bus

What this covers / who it's for. Why every domain read and write flows through one chokepoint, the verified gate order inside that chokepoint, what each stage emits, and how the audit and DLP platforms ride on the bus events. For message/handler recipes and worked examples, defer to the skill reference and the foundation module README.

One chokepoint, on purpose

Every domain write is a Command dispatched through the CommandBus; every domain read is a Query asked through the QueryBus (app-modules/foundation/src/Bus/ — a bespoke, app-owned pattern, no third-party package). The bus is where the platform gates (authorization and the business gates), tracks (structured audit lines + telemetry events), and executes (commands transactionally by default). Because all of that lives in one place, presentation code — HTTP controllers, Livewire components — is structurally forbidden from touching Eloquent/DB: tests/Architecture/CqrsBoundaryTest.php fails the build on any presentation-layer model import. The single sanctioned carve-out is Fortify's credential Action classes (framework-mandated credential management, not domain mutations).

The payoff is that cross-cutting guarantees are impossible to forget: a new money-movement command gets authorization, freeze-gating, audit, and transactionality by construction, because there is no other way to reach the data.

Message anatomy

A message is an immutable (readonly) description of intent with exactly two obligations: handledBy(): class-string names the handler (the only layer that touches models), and authorize(?Authenticatable $actor): bool is abstract — every message must consciously declare who may run it. For the standard guarded message, authorization is declared, not hand-written (D51): the message implements ChecksPermission (or ChecksPermissions + a mode) and uses the Authorization module's AuthorizesViaDeclaredPermission concern, which resolves the declared PermissionKey through the PermissionGate. That single declaration feeds both the runtime gate and the exported endpoint↔permission map. Bespoke authorize() bodies and public return true messages must sit on AuthorizationCoverageTest's explicit allow-lists — see auth-and-rbac.md.

All other behaviour is opted into via marker interfaces on the message (app-modules/foundation/src/Bus/Contracts/) — the pipeline below reacts to each.

The dispatch pipeline — verified order

The order inside LaravelCommandBus::dispatch() (app-modules/foundation/src/Bus/LaravelCommandBus.php) is load-bearing: a denied actor is never told whether a feature exists or an account is frozen, because authorize() runs first.

  1. authorize($actor) — actor resolved from the active guard (Auth::user()). false → audit command.denied (warning), fire CommandDenied (the denied attempt is persisted durably, D69), throw AccessDeniedHttpException (403). The handler never runs.
  2. Step-up MFARequiresFreshMfa + stale StepUpMfa window → audit command.step_up_required, throw STEP_UP_REQUIRED (403).
  3. Freeze gateRequiresAccountActive → the bound AccountFreezeGuard (compliance's implementation; Foundation ships a fail-open no-op default) asserts the tenant isn't frozen/closed → audit command.account_frozen, ACCOUNT_FROZEN (403).
  4. Feature gateRequiresFeature → the bound FeatureGuard (features module; fail-closed — an unbound guard throws) → audit command.feature_denied; FeatureUnavailable, QuotaExceeded (429), or LimitReached (409).
  5. Rationale gateRequiresRationale with a blank rationale() → audit command.rationale_required, RATIONALE_REQUIRED (422).
  6. Audit command.dispatched, then execute the handler in one of three modes: IdempotentDB::transaction wrapping the CommandDeduplicator (unique INSERT into command_dedup_records first; a completed duplicate replays the stored result — D28/D56); WithoutTransaction → run bare (auth/session commands with no atomic multi-write invariant); default → DB::transaction.
  7. On throw, classify via ExpectedClientError (D76): an expected client 4xx (a coded DomainException in 400–499) → audit command.rejected (warning, sanitized code + status only) and — only if isDurableRejection() (D76.1) — fire CommandRejected; anything else → audit command.failed (error, exception class + code only, never the raw message) and fire CommandFailed. Either way the exception is re-thrown unchanged.
  8. On success: audit command.executed (with duration_ms) and fire CommandExecuted carrying the message, actor, tenant id, duration, and result.

Attempt events (CommandDenied/CommandFailed/CommandRejected) are fired through a recordAttempt() wrapper: an audit-write failure is logged but never masks the real domain outcome — the durable trail is best-effort relative to the domain result, never the reverse.

LaravelQueryBus::ask() is the same shape minus the business gates and the transaction: authorize() → handler (no DB::transaction, ever) → query.denied/query.failed/ query.executed + QueryExecuted. Queries emit no dispatched line and no durable attempt events.

The D76 rejection split — why three failure shapes

A thrown exception is not one thing. The bus distinguishes:

  • Genuine failures (5xx DomainException, raw HttpException, any unforeseen throwable) — always durable (CommandFailed), logged at error with class + code only (raw messages can carry PII).
  • Routine expected 4xx (an ordinary not-found, a plain domain conflict) — client noise: command.rejected on the file channel only, excluded from the durable trail so it isn't flooded.
  • Durable rejections (D76.1) — a 4xx that is a security/compliance-significant attempt: payout body-tamper, dual-control/segregation-of-duties breaches (self-approval, SAR self-review/submit), privilege-escalation grants. Recognised by the Foundation\Exceptions\DurableRejection marker on the exception class or by ErrorCode::isDurableRejection(), these also fire CommandRejected — a durable event_type='rejected' row, consistent with the denied-403 path.

Durability across rollback is the point: the attempt events are persisted synchronously on the independent pgsql_logs session (D54/D69), so the record of a denied/failed/rejected attempt survives the command transaction that just rolled back — see tenancy-and-rls.md for that connection's mechanics.

What listens: the event fan-out

The bus itself only writes structured lines to the audit log channel (stamped with actor, tenant, and the per-request correlation_id). The durable platforms subscribe to the events:

EventConsumerResult
CommandExecuted / QueryExecutedaudit module (RecordSuccessfulBusMessage, async post-commit)hash-chained audit_events row — queries only when marked AuditableRead
CommandDenied / CommandFailed / CommandRejectedaudit module (RecordBusAttemptOutcome, synchronous)durable attempt row that survives the rollback
QueryExecuted (+ ReadsClassifiedData marker)DLP module (RecordClassifiedAccess)data_access_logs / tenant_data_access_logs entry — declare-driven, never reflection (D58)
CommandExecuted / QueryExecuted (+ EgressesData marker)DLP module (RecordEgress)dlp_egress_events entry: kind, resource, redaction-safe volume read off the handler result

The read-side markers are the mirror image of the write gates: AuditableRead, ReadsClassifiedData, and EgressesData are not enforced by the bus — they are declarations consumed by the audit/DLP listeners, so a compliance-significant read or an export is recorded without the DLP layer ever seeing the data. Messages may also contribute extra redaction-safe audit context via ProvidesAuditPayload.

Going deeper

  • Recipes, worked examples, arch/behaviour test contracts: the cqrs-bus skill reference.
  • The marker/guard-contract table (full set, when to use each): same reference; guard implementations live in the compliance (AccountFreezeGuard) and features (FeatureGuard) modules — see their READMEs.
  • Authorization declaration mechanics: auth-and-rbac.md and the permission-management skill.
  • The consumers: app-modules/audit/README.md, app-modules/dlp/README.md.
  • Decision rationale: D28, D51, D56, D69, D76 (+ D76.1) in the decision log.

← Engineering wiki index