Skip to main content

D22 — CQRS command/query bus + JSON:API envelope + Scramble extensions (RESOLVED, supersedes D21)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

User decision — reverses D21's "skip CQRS" recommendation and resolves D21's "API response contract" user-decision. Route every write through a CommandBus and every read through a QueryBus, so the bus is the single place the app gates (authorize) and tracks (audit) work; serialise all API responses in the JSON:API envelope; and port the legacy repo's Scramble type extensions so the generated OpenAPI doc reflects both.

The bus (Modules\Foundation\Bus):

  • Contracts\{Command,Query} — each message declares handledBy(): class-string and an abstract authorize(?Authenticatable): bool, forcing every message to consciously declare its gate (fail-safe). Contracts\{CommandBus,QueryBus} expose dispatch() / ask() returning the handler's generic TResult (no native return type — the only entries in ReturnTypeTest's exception list).
  • AbstractCommand/AbstractQueryabstract readonly class; a message is an immutable value.
  • LaravelCommandBus::dispatch() — on deny: audit command.denied (warning) + throw AccessDeniedHttpException; else audit command.dispatched, run the handler inside DB::transaction, audit command.executed (+ duration), fire CommandExecuted; command.failed on throw. LaravelQueryBus::ask() is identical minus the transaction (fires QueryExecuted).
  • Concerns\AuditsBusMessages → dedicated audit log channel (config/logging.php: daily, 90-day retention) with actor, tenant_id, correlation_id. Facades Bus\Facades\{CommandBus, QueryBus}; bound as singletons in FoundationServiceProvider.

JSON:API runtime (Modules\Foundation\Http):

  • Resources\JsonApiResource — base extending Laravel 13.12's native Illuminate\Http\Resources\JsonApi\JsonApiResource, merging meta (JsonApiMeta: request_id/correlation_id/timestamp).
  • Resources\JsonApiEnvelope::make(){data, meta, jsonapi:{version:"1.1"}}, application/vnd.api+json.
  • Http\JsonApiExceptionHandler — renders JSON-expecting errors as {errors[], meta} (validation carries source.pointer = /data/attributes/<field>); registered in bootstrap/app.php.
  • Http\Middleware\AssignCorrelationId (alias correlation) — request/correlation id into Context
    • echoed response headers; applied to both API route groups.

Scramble extensions (Modules\Foundation\Scramble, registered in config/scramble.php): ported 7 of the legacy repo's 8 (paginated-collection deferred until a paginated endpoint exists), re-namespaced to Foundation. They (a) infer dispatch()/ask() return types from the message's handler, (b) render spatie-data DTOs + JSON:API resources/envelope/errors under application/vnd.api+json. Auth is documented via MiddlewareAuthSecurityStrategy (Sanctum SPA cookie scheme) so only auth:* routes are marked secured — login + discovery stay public. scramble:export generates clean.

Identity converted as the reference implementation: ClientAuthController (login/logout/me) + TenantDiscoveryController dispatch through the bus and return JSON:API; Commands\{LoginClient, LogoutClient}Command, Queries\{DiscoverTenants,GetCurrentClient}Query, their Handlers/, ClientUserResource, and the {ClientUser,TenantSummary}Data DTOs.

Tests: tests/Architecture/CqrsTest (messages implement the contract, are readonly, route to a real handler — discovered from disk so new modules are covered automatically); tests/Feature/Foundation/CommandBusTest (denied command throws + is audited + handler never runs; authorized command runs in a transaction, returns the result, fires CommandExecuted); the Identity HTTP tests updated to JSON:API shapes (data.attributes.*, data[], errors.0.source.pointer). 79 tests green.

Convention going forward: business logic is invoked only via the bus — a controller/Livewire action builds a Command/Query and dispatch()/ask()s it; handlers hold the logic; the gate + audit are non-negotiable. This supersedes D21's "invokable Actions, not CQRS": where an Action is still a useful unit of logic, it becomes a bus handler.


← Decision log index