Containing AI Workflows with DDD, Hexagonal Architecture and Selective CQRS

A reference architecture for keeping probabilistic AI capabilities behind explicit contracts while domain policy and operational controls bound their consequences.

The useful question is not whether an AI model is deterministic enough to join an enterprise system. Most external dependencies have uncertainty: networks fail, queues redeliver, people enter bad data, and providers change behaviour. The question is where that uncertainty is allowed to influence state and authority.

For architects, a practical answer is to put a probabilistic capability behind an explicit application-facing contract, keep invariant-preserving policy in the domain, and apply controls in the workflow that has authority to spend money, change data, or affect people. Domain-Driven Design (DDD), hexagonal architecture, asynchronous messaging, and CQRS can help~but only when their terms and failure modes remain precise.

This is a reference architecture, not a claim that every classification, review, or automated action needs the same machinery. It is most useful when an AI-assisted workflow crosses bounded contexts or has meaningful financial, security, regulatory, or recovery consequences.

Start with the decision, not the model

An AI result should normally be treated as an untrusted proposal. It may inform a decision, but it should not silently acquire the authority to make one. The workflow owner decides what follows from an accepted result: whether to store it, request human review, retry through a different path, or refuse the operation.

That distinction avoids both a deterministic-software straw man and an “AI is special” trap. A model can be a useful classifier, extractor, summariser, or reviewer. It is also an external system with variable latency, cost, availability, and output quality. NIST’s AI risk guidance is a useful prompt to map context and manage the resulting risks rather than assume a universal control set (Map, Manage, Generative AI Profile).

Before choosing a pattern, assess the consequence of a wrong or unavailable result:

  • Authority: can it change a record, send a message, create a commit, or trigger a payment?
  • Reversibility and recovery cost: can the action be corrected, and who bears the cost?
  • Exposure: what is the plausible spend, data sensitivity, affected-user count, and regulatory impact?
  • Alternatives: is a deterministic rule, a conventional service, or a human decision adequate?
  • Operating conditions: what happens if the model, budget ledger, queue, lock store, or telemetry is unavailable?

Classification is not automatically low risk: a label that routes a benefits claim, security alert, or destructive action can be high consequence. Conversely, a low-volume internal summary may need only a timeout, a modest budget, and a human-visible result.

Put the right capability at the boundary

Hexagonal architecture keeps business logic independent of delivery mechanisms and external technologies. The important dependency direction is inwards: domain and application code define the capabilities they need; adapters implement them. It is not a promise that changing providers entails no other change~quality, latency, cost, data handling, and policy may all need reassessment.

Avoid making a domain port a thin disguise for a provider SDK:

# Simplified Python: application-facing capability, not a provider API.
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True)
class ClassificationInput:
    document_id: str
    text: str


@dataclass(frozen=True)
class ClassificationOutcome:
    label: str
    rationale: str
    model_confidence: float | None
    model_version: str
    prompt_version: str


class ModelClassifier(Protocol):
    async def classify(self, input: ClassificationInput) -> ClassificationOutcome: ...

The application service owns the use case and passes only the data needed for it. The infrastructure adapter owns prompt rendering, configured model selection, token settings, provider SDK calls, response parsing, and provider-specific retry classification. The model does not receive an identity, tenant, permitted transition, or financial limit as an authority-bearing field; those come from trusted request and workflow context.

The adapter should turn raw output into a bounded candidate before it returns it:

# Simplified Python. ProviderClient and configuration are infrastructure details.
class ProviderClassifier(ModelClassifier):
    def __init__(self, client: "ProviderClient", config: "ClassifierConfig") -> None:
        self._client = client
        self._config = config

    async def classify(self, input: ClassificationInput) -> ClassificationOutcome:
        raw = await self._client.complete(
            model=self._config.model_name,
            prompt=render_classification_prompt(input, self._config.prompt_version),
            timeout_seconds=self._config.provider_timeout_seconds,
        )
        parsed = parse_json_object_with_size_limit(raw.text)
        candidate = ModelClassification.model_validate(parsed, strict=True)
        return ClassificationOutcome(
            label=candidate.label,
            rationale=candidate.rationale,
            model_confidence=candidate.confidence,
            model_version=self._config.model_name,
            prompt_version=self._config.prompt_version,
        )

For a Pydantic v2 boundary, reject surplus structure and validate fields explicitly; v2 uses field_validator, and strict mode avoids many coercions (validator migration, strict mode).

from pydantic import BaseModel, ConfigDict, field_validator


class ModelClassification(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)

    label: str
    rationale: str
    confidence: float | None = None

    @field_validator("label", "rationale")
    @classmethod
    def non_empty(cls, value: str) -> str:
        if not value.strip():
            raise ValueError("must not be empty")
        return value

    @field_validator("confidence")
    @classmethod
    def confidence_is_a_range(cls, value: float | None) -> float | None:
        if value is not None and not 0.0 <= value <= 1.0:
            raise ValueError("must be between 0 and 1")
        return value

Structural validation is necessary but not sufficient. The full path is: raw model output → bounded parse and size limit → structural validation → semantic/domain validation → authorised state transition. Domain policy might reject an unknown label, require supporting evidence, or route a sensitive label to a person. A model-reported confidence is metadata, not automatically calibrated evidence or permission to act.

When the AI behaviour itself is the core product behaviourfor example, a system whose domain is adjudicating a model-generated assessmentthe model contract may belong closer to that bounded context. Even then, provider protocols, prompts, credentials, and parsing remain adapter concerns; the domain still owns the meaning, constraints, and consequences of an outcome.

Two views of the same boundary

This diagram shows source-code dependency direction, not runtime traffic:

flowchart LR
    Domain[Domain policy and aggregates]
    Application[Classification application service]
    Port[ModelClassifier port]
    Adapter[Provider classifier adapter]
    Provider[Model provider]

    Application --> Domain
    Application --> Port
    Adapter --> Port
    Adapter --> Provider

At runtime the application invokes the port and the adapter calls the provider; the returned candidate is then assessed by application and domain policy. Controls such as budget reservation and authorisation sit around the workflow invocation, not inside the model call as if an LLM could enforce them.

Commands, events, and webhooks are different things

Loose naming is a reliable way to make asynchronous systems harder to reason about. Use distinct semantics:

TermMeaningHandler/subscriber rule
CommandAn imperative request to do something, such as ClassifyDocumentOne command handler owns the decision and result.
Domain eventA past-tense fact inside a bounded context, such as DocumentClassifiedZero or more local handlers may react. Multiple handlers are a normal domain-event use.
Integration eventA versioned fact published across a boundary, such as DocumentClassificationRecordedV1Consumers independently process a contract that must evolve compatibly.
WebhookAn HTTP transport/delivery mechanismIt carries a message; it is not a command, event, or query by definition.
Mediator/dispatcherAn in-process routing mechanismIt can dispatch commands or events; it does not define their business meaning.

*Requested names usually describe commands, not domain events. Event fan-out is not inherently wrong; it is often the point of publishing a fact. The design question is whether a subscriber is a deliberate reaction to an established fact, or whether hidden synchronous work has made an apparently simple transaction unpredictable. Microsoft’s DDD guidance explicitly describes domain events with multiple handlers (domain events).

For example, a worker completion is durable write-side input. After the receiver authenticates and accepts it into its inbox, an application service handles RecordClassificationAttempt. Domain policy decides whether the reported attempt permits a transition and records DocumentClassified locally. The aggregate transition and a versioned integration-event entry are persisted in one transaction through an outbox. An outbox publisher later sends DocumentClassificationRecordedV1; another bounded context may subscribe without sharing the write model.

sequenceDiagram
    participant Receiver as Completion webhook receiver + durable inbox
    participant App as Application service
    participant Domain as Domain aggregate
    participant Write as Write model + outbox
    participant Publisher as Outbox publisher
    participant Bus as Event bus
    participant Projector as Read-model projector
    participant Read as Read-model store
    participant Query as Query API

    Receiver->>Receiver: authenticate and durably accept completion
    Receiver->>App: RecordClassificationAttempt command
    App->>Domain: apply attempt with trusted workflow context
    Domain-->>App: DocumentClassified fact
    App->>Write: atomically persist transition and outbox entry
    Write-->>Publisher: pending DocumentClassificationRecordedV1
    Publisher->>Bus: publish integration event
    Bus->>Projector: at-least-once delivery
    Projector->>Read: idempotently update classification view
    Query->>Read: GET classification view
    Read-->>Query: view or pending state

This is an asynchronous completion flow. A worker’s completion webhook is write-side input, not a query. Its receiver durably accepts the authenticated delivery before presenting RecordClassificationAttempt to the command handler; the handler decides whether it can update the write model. The outbox publisher, event bus, projector, and read-model store then make the result queryable. The read side answers questions from the store built by that projection.

CQRS is optional, not a synonym for webhooks

CQRS separates write models from read models and introduces useful flexibility at the price of more moving parts and eventual consistency. It is justified when read shapes, scale, permissions, or latency needs differ substantially from the write model.

In the flow above:

  1. RecordClassificationAttempt is a command handled against the write model after durable completion acceptance.
  2. The write transaction records an outbox entry for a versioned integration event.
  3. An outbox publisher sends that event to the bus, from which a projector builds a classification view keyed by document and tenant.
  4. GET /documents/{id}/classification reads the read-model store and can expose a “pending” state while propagation completes.

If a workflow is a low-volume monolith with one transactional store and one straightforward read shape, a single write/read model may be clearer. A queue and an idempotent completion handler can still be valuable without CQRS. Do not add separate stores merely to make an architecture diagram look mature.

Durability and replay claims require mechanics. A transactional outbox prevents the common gap where a database commit succeeds but event publication is lost. A durable inbox records an authenticated delivery key before processing so an at-least-once consumer can reject duplicates. Handlers need idempotency keys, stable job_id, attempt_id, event_id, and delivery_id, and reconciliation must repair entries stranded between states. This is not exactly-once delivery; it is a design that makes duplicate delivery and restart survivable. Event-driven systems trade simpler decoupling for delivery, ordering, and observability concerns (event-driven architecture).

Derive controls from consequences

Controls belong at the application/orchestration boundary because that is where the system has authority. The following are mechanisms with bounded guarantees, not a universal taxonomy.

Budget: meter separately from enforce

Telemetry can estimate cost and support investigation, but it must not be the authoritative budget ledger. Before each provider call, an application-owned ledger should atomically reserve a conservative worst-case amount using integer micros or decimal currency. All concurrent subtasks for the operation draw from that same ledger. After the call, reconcile actual usage and release the difference.

An illustrative policy might fail closed if the ledger is unavailable for a spend-bearing action, while allowing a non-billable cached result to proceed. It must also be honest about residual risk: an in-flight request can exceed an estimate, a local cancellation may not reverse provider billing, and a provider can report usage late. Alerts and metering describe those conditions; the reservation transaction enforces the limit as far as the chosen boundary permits.

Concurrency, timeouts, and retries: ownership matters

A bounded queue or semaphore limits admitted work only when ownership, heartbeat/recovery, and abandonment rules are defined. Decide whether the queue outage fails closed (common for an authority-bearing job) or degrades to a visible pending state. A local timeout stops waiting; it does not prove that provider work stopped or will not be billed.

Classify failures before retrying. A transient connection failure may merit bounded exponential backoff with jitter; invalid input, denied authorisation, malformed output, or a budget rejection should not be retried blindly. Retried handlers must be idempotent, especially where they enqueue jobs or transition state. After the retry limit, send the item to a dead-letter/reconciliation path rather than silently dropping it.

Locks: improve liveness, do not invent certainty

Distributed locks can reduce duplicate work, but they do not replace transactional state control. Keep acquisition timeout distinct from lease duration. Use a unique ownership token, release only when the token matches, renew deliberately, and use fencing tokens or stale-owner rejection at the protected resource. A TTL can recover liveness after a crash, but it is not proof of mutual exclusion under pauses, partitions, or delayed clients; Redis documents these caveats in its distributed-lock guidance.

For an irreversibly expensive or state-changing action, combine the lock with a compare-and-swap version check in the authoritative store. If the lock store fails, choose explicitly: fail closed for a high-consequence action, or permit a duplicate-tolerant read-only task with an audit record.

State transitions and approvals: bind authority to immutable facts

The domain should accept an allowed transition only under transactional or compare-and-swap versioning. Persist the idempotency key and expected aggregate version, and reconcile uncertain attempts. An approval for code review must be bound to an immutable commit SHA; an approval of an earlier commit does not authorise a newer one.

Repeated outputs from a single model are correlated checks, not independent consensus. Stronger evidence may come from genuinely independent review, deterministic tests, or a human policy decision. Repository branch protections remain authoritative over an application’s opinion of whether a merge is allowed (protected branches).

Worktrees organise files; they do not sandbox execution

Git worktrees create additional working trees attached to a repository; they are useful workspace organisation, not a security boundary (git worktree). Use argument-safe Git invocation rather than interpolated shell strings, unique paths, and a detached immutable commit SHA where appropriate. Clean up only after proving associated processes have stopped.

If an untrusted patch, dependency, or build must execute, use a container or VM with least-privilege credentials, restricted network and filesystem access, and resource limits. A worktree alone does not protect the host or the primary checkout.

Receive worker webhooks as hostile delivery attempts

An HMAC can establish that a holder of a signing key produced a byte sequence; it does not establish tenant permission, prevent replay by itself, impose ordering, or remove duplicate delivery. A receiver should:

  1. preserve the exact raw request bytes and authenticate before decoding the body;
  2. select the sender/key identity and version, then compute the expected signature over an unambiguous, documented signed message containing the version, timestamp, delivery ID, and exact raw body bytes;
  3. validate decoding and equal byte lengths before constant-time comparison~Node’s timingSafeEqual requires equal-length inputs (Node crypto);
  4. after that verification, check timestamp freshness and record a durable, sender-scoped delivery-ID inbox key for replay/deduplication; an unsigned delivery-ID header must never form this key, because an attacker could change it to bypass the inbox during the freshness window;
  5. decode and structurally validate only after authentication, then separately authorise tenant, entity, and permitted action from trusted records; and
  6. acknowledge only after durable acceptance, not merely after parsing.

If a sender cannot include a delivery ID in the signed message, derive the inbox key only after verification from the verified sender identity plus the authenticated payload digest or signature; do not trust a mutable identifier supplied outside the authenticated material. Key rotation requires accepting a declared current or previous key for a bounded period and recording which key verified the delivery. GitHub likewise recommends validating webhook payloads, using delivery IDs, responding quickly, and handling redeliveries (webhook best practices); Stripe’s guidance illustrates why frameworks must make the raw body available for signature verification (raw-body signature verification).

Make failures observable without making telemetry authoritative

Record correlation and causation IDs through command, job, attempt, event, and delivery boundaries. Attribute model and prompt versions, schema/event versions, policy version, provider response classification, and decision outcome. Roll event and schema changes through compatible readers and writers before removing an old representation.

Observability needs its own policy: redact or minimise prompts and outputs containing personal or confidential data; avoid unbounded high-cardinality labels; set retention deliberately; and protect traces as potentially sensitive records. Telemetry outages should normally degrade observability rather than become a hidden source of incorrect business state, but a budget ledger, inbox, queue, or lock store may require fail-closed behaviour depending on the authority of the operation. State that choice in the workflow design rather than inherit it accidentally from a library default.

Verify the failure paths

Happy-path demos conceal the properties that matter. A useful test suite includes:

  • HMAC fixed vectors, malformed headers, replayed deliveries, sender/key rotation, and unequal-length signature inputs.
  • Duplicate job and webhook deliveries, outbox publish crashes, inbox restarts, and reconciliation of stranded records.
  • Concurrent budget reservations, process crashes between reservation and reconciliation, and delayed usage reports.
  • Lease expiry, stale release, fencing/stale-owner rejection, and concurrent aggregate transitions.
  • Approval of an old commit after a new SHA is pushed.
  • Malformed, oversized, or semantically invalid model output, plus an uncalibrated high-confidence response.
  • Provider, queue, lock-store, ledger, and telemetry outages with asserted fail-open/fail-closed outcomes.
  • Hostile Git path and branch inputs, detached-SHA workspace creation, and cleanup after process termination.

The point is not to prove that an AI workflow is safe in all circumstances. It is to prove that known failures are bounded, visible, and recoverable according to a policy someone owns.

Adopt it in stages

A rewrite is rarely the safest migration. A low-volume monolith can begin with one explicit application service, a typed capability port, strict output validation, an idempotency key, and a recorded decision. Add a queue and durable completion handling when the operation outlives an HTTP request. Add an outbox, integration events, and a projection when another bounded context or read shape genuinely needs them. Add budget reservations, locking, human approval, isolation, or a workflow engine as authority and recovery costs rise.

There are sound alternatives. A latency-sensitive request may use a deterministic/rules-based fallback rather than wait for a model. A single-provider workflow may not warrant a provider abstraction beyond a narrow adapter. A regulated or high-stakes decision may require human-only approval regardless of model quality. A workflow engine can coordinate long-running work without introducing CQRS, and CQRS can be unwarranted where one transactional model remains clear.

Conclusion

DDD and hexagonal architecture provide a place to contain probabilistic capabilities: explicit application-facing contracts at the edge, and domain policy where invariants and consequences belong. Asynchronous messaging and CQRS can make long-running, cross-context workflows more resilient when their delivery semantics, outbox/inbox mechanics, and eventual consistency are accepted deliberately.

The resulting design does not make AI unable to cause damage. It reduces and bounds the ways an uncertain capability can spend money, alter state, expose data, or claim authority~and makes the remaining risk an explicit engineering and policy decision.