Skip to content

Latest commit

 

History

History
227 lines (183 loc) · 13.9 KB

cqrs.md

File metadata and controls

227 lines (183 loc) · 13.9 KB

CQRS (Command / Query)

firefly/cqrs adds the command/query dispatch surface — a synchronous, in-process mediator — on top of the shipped seams. It writes no interception code: a #[Transactional] command handler gets transaction semantics for free from the firefly/data proxy. Read models / projections ship in firefly/eventsourcing: see Projections for FunctionProjection, the ProjectionRunner and the firefly:eventsourcing:projections commands.

CQRS / EDA bridge

The buses

  • CommandBus::send(object $command): mixed — the write side.
  • QueryBus::ask(object $query): mixed — the read side (ask, not query, to avoid colliding with Eloquent's query()).

Both run a bounded pipeline of injected, no-op-by-default collaborators:

  • Command: correlate → validate → authorize → resolve + invoke handler → metrics.
  • Query: correlate → validate → authorize → cache-get (short-circuit on hit) → resolve + invoke handler → cache-put → metrics.

Any handler/stage throwable is re-thrown wrapped in a category-preserving CommandProcessingException / QueryProcessingException (unless it already is one). "Category-preserving" means a ValidationException (422) or a not-found (500 framework) keeps its own error code / HTTP status / category / severity for RFC-7807 rendering — the wrapper never masks an expected fault to a generic 500 — and its RFC 9457 extension members and title too, so a refused #[PreAuthorize] publishes the same requiredAuthorities through the bus as it does from a controller.

The wrapper's message and its published detail are two different sentences. The message — Processing command [App\Withdraw] failed: <the cause's message> — names the message class and quotes the cause verbatim, for the log and the previous chain. The problem document publishes detail() instead, which the wrapper sets to the cause's own: an authored FireflyException cause reaches the client word for word, exactly as if a controller had thrown it. A cause that is not a FireflyException — a QueryException carrying its SQL and bindings, a TypeError naming a server path — has no sentence for a client, so the wrapper's detail is null and the web layer answers it the way it answers that throwable thrown from a controller: the opaque An unexpected error occurred. It has been logged; quote reference <traceId> if you report it. with firefly.web.problem.disclose off (the default), and the cause's own message, without the prefix, with it on. The wrapper keeps its own 500 COMMAND_PROCESSING_ERROR / QUERY_PROCESSING_ERROR code either way; only the sentence is withheld.

Handlers

Annotate a class with #[CommandHandler] or #[QueryHandler] (both specialise #[Component], so the handler is a constructor-injected DI bean automatically — no separate #[Service]). The handler exposes one public handle() method taking the message and returning the result. This is the handler the package's own banking capstone dispatches, imports cut — and it is deliberately not final: a #[Transactional] method makes TransactionalBeanPostProcessor swap the bean for a generated final class …Proxy extends \{Target}, which cannot extend a final class, so marking the handler final would fatal at boot:

// …
#[CommandHandler]
class OpenAccountHandler
{
    public function __construct(private readonly AccountRepository $accounts) {}

    #[Transactional]
    public function handle(OpenAccount $command): int
    {
        $account = new Account(['owner' => $command->owner, 'balance' => $command->balance]);
        $account->open();
        $this->accounts->save($account);

        return $account->id;
    }
}

The handled message type is inferred from the handle() parameter at scan time (mirroring #[AsEventListener]); pass an explicit class to disambiguate a union/object parameter: #[CommandHandler(OpenAccount::class)]. An unresolvable type or a duplicate handler fails loud (CqrsConfigurationException) at build/boot, never silently. HandlerScanner (the sole reflection site in the package) discovers handlers and compiles them into a HandlerManifest; CqrsHandlerWiringPass populates the HandlerRegistry from that manifest at boot with fresh-per-dispatch invokers (so a #[Transactional] handler is always observed through its proxy).

The marker interfaces Command/Query/CommandHandler/QueryHandler are optional documentary type-markers — discovery is by attribute, and PHP parameter invariance means the handler interfaces declare no handle() method.

Pipeline seams

All default to no-ops so the pipeline is a series of skippable collaborators:

  • Validation — reuse the shipped Validator: a message implementing Validatable (validationData() / validationRules()) is validated; others are not.
  • Authorization — interface-only seam (CommandAuthorizer / QueryAuthorizer), allow-all default; real policy evaluation is M11-security.
  • Correlation — CorrelationContext propagates a uuid4 correlation id, restored after each dispatch and stamped into integration-event headers (x-correlation-id).
  • Metrics — CqrsMetrics seam (no-op default); the real recorder is M12-observability.
  • Tracing — CqrsTracing seam (NoOpCqrsTracing default) wrapping validate → authorize → resolve → invoke; observability's TracerCqrsTracing puts an INTERNAL span around every message (see Tracing).
  • Query cache — QueryCache seam, NoOpQueryCache (always-miss) by default; firefly.cqrs.query.cache.provider=cache binds CacheQueryCache on the Laravel cache — see Query cache.

The domain → integration-event bridge

The bridge re-emits every committed in-process DomainEvent onto the firefly/eda EventPublisher as an integration event, mapping eventType = the event's short class name, payload = the event's public fields, and destination = an explicit argument → the event's #[PublishDomainEvent(destination:)] → firefly.cqrs.default_destination (cqrs.events).

How it triggers: firefly/data publishes each committed DomainEvent to the in-process ApplicationEventPublisher inside DB::afterCommit. DomainEventBridgeWiringPass registers a single guarded wildcard listener on the event dispatcher that filters instanceof DomainEvent and delegates to DomainEventBridge, which applies the failure strategy and publishes through the resolved CommandEventPublisher (NoOpEventPublisher or EdaCommandEventPublisher). (It does NOT use #[AsEventListener] typed on DomainEvent: Laravel's dispatcher matches an object event by its concrete class plus interfaces — never parent classes — so a base-class listener would never fire for a concrete subclass.)

Semantic scope (read this): the bridge fires for any aggregate committed through a firefly/data repository — not only writes originating from a CommandBus command. This is deliberate and correct integration-event semantics: an integration event is published because a fact was committed to the write model, regardless of whether a CQRS command, a controller, a scheduled job, or a message consumer drove the mutation. This is also the deliberate improvement over pyfly, which drains events by inspecting the current handler's aggregate (duck-typing on the command-handling call) rather than listening for any committed aggregate. An app that wants to emit an integration event WITHOUT an in-process domain event injects CommandEventPublisher directly; an app that wants a domain event to stay in-process only does not enable the bridge (or scopes destinations via #[PublishDomainEvent]).

Failure strategy (firefly.cqrs.event_failure_strategy): the publish runs AFTER the DB commit, inside DB::afterCommit, so the write already succeeded. log (default) swallows + logs a publish failure — the command result stands, the publish is best-effort; raise re-throws it wrapped in CommandProcessingException. Either way, the bridge fires only on a real commit — a rolled-back unit of work raises no DomainEvent at all (Laravel discards afterCommit callbacks on rollback), so nothing is ever published for it.

Non-atomic caveat: the DB commit and the broker publish are two steps with a gap — an honest at-least-once-ish, non-atomic guarantee. Atomic durability is the same-transaction outbox (firefly.eda.provider=database, or postgres), which writes the event inside the aggregate's transaction through firefly/data's PreCommitEventHook.

When no firefly/eda EventPublisher is bound, the bridge falls back to NoOpEventPublisher (drops silently), so cqrs installed without eda still boots and simply emits no integration events.

firefly/cqrs is an independent layer that does not depend on firefly/data (no Cqrs → Data Deptrac edge): the bridge listens for DomainEvents on the in-process Context dispatcher, never on firefly/data's AggregateTracker/DomainEventDispatcher directly.

Query cache

With firefly.cqrs.query.cache.provider=cache, a query class marked #[CachedQuery] is cached under a key derived from its public properties — two equal value objects share an entry, 3 and '3' do not — for its ttl: (falling back to firefly.cqrs.query.cache_ttl), in its group: (default: the query class). A Cacheable query keeps its own cacheKey(). A null result is never cached, and a cache failure is logged and runs the handler: the cache can make a query faster, never make it fail. A query holding private or protected state, or a generator, cannot be keyed and simply runs uncached.

#[InvalidatesQuery] on an event class says which cached results the event makes stale: the whole group, one entry by mapping the query's properties to the event's (with:), or one Cacheable key (key: 'statement:{account}'). Eviction happens when the event is dispatched after its transaction commits — a rolled-back write evicts nothing — and when it arrives over the event bus from another process, matched by its event type (eventType:, default the short class name). An envelope whose payload cannot rebuild the typed key (JSON delivers an enum as its backing value, a date as an array) evicts the whole group instead, with a warning: serving stale data is the failure worth preventing.

<?php

declare(strict_types=1);

namespace App\Ledger;

use Firefly\Cqrs\Attributes\CachedQuery;
use Firefly\Cqrs\Attributes\InvalidatesQuery;
use Firefly\Cqrs\Query\Query;
use Firefly\Domain\DomainEvent;

#[CachedQuery(ttl: 300)]
final readonly class AccountBalance implements Query
{
    public function __construct(public string $account) {}
}

#[InvalidatesQuery(AccountBalance::class, with: ['account' => 'account'])]
final readonly class EntryPosted extends DomainEvent
{
    public function __construct(public string $account, public int $amount)
    {
        parent::__construct();
    }
}

Groups are evicted by GENERATIONS, not cache tags, so they work on every store (file and database included): an evicted group's entries become unreachable at once and expire on their TTL — set one. The key is the value object, so a query whose answer depends on the tenant or the user must carry them as properties.

Configuration (firefly.cqrs.*)

The original keys are snake_case and stay so; the query-cache keys are kebab-case.

Key Default Meaning
firefly.cqrs.default_destination cqrs.events Fallback integration-event destination.
firefly.cqrs.event_failure_strategy log log (swallow) or raise (re-throw) on a post-commit publish failure.
firefly.cqrs.query.cache_ttl (unset) Default TTL in seconds for a cached query whose #[CachedQuery] names none (and for a Cacheable query). Unset means "no TTL", not zero.
firefly.cqrs.query.cache.provider none none (NoOp) or cache (CacheQueryCache). Anything else refuses the boot.
firefly.cqrs.query.cache.store '' Laravel cache store ('' = default).
firefly.cqrs.query.cache.prefix firefly:cqrs: Key prefix.
firefly.cqrs.query.cache.invalidation [] Event type ⇒ list of query classes whose groups it evicts (for events with no local class).

Laravel comparison

Concern Plain Laravel LaraFly (firefly/cqrs)
Command/query dispatch dispatch() a job, or call a service directly; no read/write split CommandBus::send / QueryBus::ask with attribute-discovered handlers + a bounded pipeline
Handler discovery manual binding / $dispatchesJobs #[CommandHandler]/#[QueryHandler] compiled to a manifest, zero boot reflection
Transactions DB::transaction(fn () => …) in the handler body #[Transactional] on handle() — interception is free (firefly/data proxy)
Domain → integration events hand-rolled (dispatch a job in the model event) committed DomainEvents auto-re-emitted onto the eda EventPublisher after commit

Known-latent

Read-model / projection scaffolding ships in firefly/eventsourcing. Real authorization (→ M11), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent builder / distributed-tracing ergonomics are deferred and documented honestly. The HandlerManifest needs no hand-wiring: like every other compiled manifest it resolves to the firefly:cache artifact if present, otherwise an in-process scan of firefly.scan.paths, otherwise empty — so an uncached app registers the same handlers a cached one does. A handler outside firefly.scan.paths still never registers. Under Octane, CorrelationContext is per-request state and the HandlerRegistry is rebuilt per worker boot.