Persistence & Coordination Patterns
Backend architecture patterns are often introduced as global choices: CRUD, DDD, Event Sourcing, CQRS, RPC, Pub/Sub, Actors, Sagas, Durable Execution, Outbox, and so on.
Cohesive treats them differently. These names are not competing worlds. They are common bundles of persistence, reconstitution, interaction, delivery, commit, coordination, and recovery choices.
The practical questions are:
- Which facts must be durable?
- Which effects must be committed together?
- Which work can happen later?
- Which duplicates, delays, and failures are acceptable?
- Which mechanism preserves those guarantees at the right boundary?
Correctness comes from matching semantic requirements with substrate capabilities. Pattern-level guarantees must then be composed until they cover the system-level requirements.
Architectural Patterns are Bundles of Guarantees
A useful backend architecture does not start by choosing one pattern for the whole system. It starts by identifying what must remain true when work crosses boundaries.
Cohesive separates that question into operational dimensions:
- Persistence: what is made durable and authoritative?
- Reconstitution: how is usable state recovered from durable material?
- Interaction and delivery: how do observers exchange work, and what are the delivery guarantees?
- Commit and coordination: which effects must commit together, and how is multi-participant work made consistent?
- Recovery and idempotency: what happens after crash, retry, redelivery, replay, or partial progress?
These dimensions are independent. A broker can accept a message without making a domain transition correct. A workflow engine can retry an activity without defining entity invariants. An actor runtime can serialize work for one identity without making that state durable. A database can commit a row without telling downstream observers what changed.
The Boundary Ladder
Engineers often use one word, "success" or "completion" for several different boundaries, but those boundaries are not equivalent:
- transport accepted bytes
- broker accepted message
- consumer received input
- consumer acknowledged or committed offset
- domain transition committed
- outbox publication responsibility committed
- downstream observer processed input
- downstream entity transition committed
- read model caught up
- business transaction completed
A success at one boundary does not imply success at the others.
An HTTP 200 can mean the transport finished, not that a downstream business process completed. A broker acknowledgment can mean the broker retained a message, not that the consumer updated its local state. A committed offset can mean the consumer promised not to read the input again, not that the domain transition committed safely. A read model can lag even when the write side is correct.
Architecture must be explicit about which boundary each guarantee applies to. Cohesive makes those boundaries part of the system model instead of leaving them implicit in infrastructure behavior.
Persistence and Reconstitution
Persistence asks what becomes durable and authoritative. A system might persist current rows, documents, aggregate snapshots, appended events, outbox records, inbox records, retained messages, offsets, workflow history, projection rows, or search documents.
Reconstitution asks how state is recovered from durable storage. A service can load the latest row. An aggregate can hydrate from a repository. An event-sourced entity can replay a stream, possibly from a snapshot. A projector can rebuild a read model. A workflow can replay history or resume from a checkpoint. A consumer can resume from offsets or redeliver retained inputs.
The choice of persistence mechanism determines which reconstitution paths are available, and therefore which guarantees the system can make about state recovery after failure.
Commit Boundaries and The Dual-Write Problem
Many reliability bugs are commit-boundary bugs. The simplest form is the dual-write problem:
Dual write without a shared commit boundary
Update database, then Crash window, Publish message
If those effects do not share a commit boundary or a recovery protocol, two bad outcomes appear:
- The database commit succeeds, but message publication fails. Authoritative state changed, but downstream observers are never told.
- Message publication succeeds, but the database commit fails. Downstream observers react to a fact that did not commit.
This is not only a database-plus-broker issue. The same failure mode appears in many pairs:
- entity transition plus search index update
- broker offset commit plus local database update
- workflow checkpoint plus external API call
- event append plus unrelated broker write
- local state change plus notification
This is not a timing issue. It is a commit-boundary issue. "Do this, then that" is not a correctness proof when the process can crash between the two effects.
Outbox, Event Sourcing, and Atomic Coordination
Outbox and event sourcing are both ways to address the dual-write problem by atomically combining persistence with coordination material.
Outbox does this by committing the local state change and the responsibility to publish in the same atomic boundary:
Event sourcing does this by committing the event history as the durable source for both state reconstitution and downstream coordination. The event append is both the local commit and the coordination trigger. Entity state is reconstituted from the committed history, and projections, process managers, subscribers, and publishers can derive their work from that same history.
Committed event history coordinates downstream work
Committed event history is the shared source for Reconstitute entity state, Drive projections, Drive process managers/subscribers, Publish output events.
Atomic coordination does not mean exactly-once messaging, immediate consistency, downstream completion, or read-model freshness. Publication and downstream processing remain asynchronous, so ordering, retry, redelivery, deduplication, and consumer correctness still need design.
Neither pattern eliminates commit-boundary problems on its own. The correctness claim depends on the coordination material being committed in the same atomic boundary as the authoritative change, or being derived reliably from material that was. If a system persists state and separately emits coordination material with no atomic commit or recovery derivation, it reintroduces the dual-write problem.
Inbox, Idempotency, and Recovery
Outbox solves the producer-side handoff. It does not solve consumer-side duplication.
The consumer-side complement is a transactional inbox or another idempotent receiver strategy:
Transactional outbox and inbox handoff
Producer state + outbox commit, then Relay, Publish, possibly more than once, then Retry / redelivery, Consumer inbox + local transition commit, then Ack after commit, Acknowledge delivery boundary
The consumer must decide when it is safe to acknowledge the delivery. If it acknowledges before the local transition commits, a crash can lose work. If it commits locally but crashes before acknowledgment, the broker may redeliver the input.
Effectively-once processing is therefore achieved through cross-participant constraints, not a single broker feature. It usually depends on:
- local atomic commits
- stable input identifiers
- deduplication or inbox records
- expected-version checks
- idempotent interpretation
- explicit acknowledgment timing
- retry and recovery
Recovery mechanisms must account for these moving parts to maintain correctness after crashes, retries, redelivery, replay, or partial progress.
How Patterns Compose
Patterns give compact names for recurring operational concerns and the solutions that address them.
CRUD
- Operational Concern
- Persistence and reconstitution of state through a simple interface.
- What It Does Not Decide
- Cross-boundary coordination. Side effects around CRUD writes still need a shared commit or recovery protocol.
DDD
- Operational Concern
- Entities and aggregates as transactional consistency boundaries for domain invariants.
- What It Does Not Decide
- Coordination concerns such as publication, projection, workflow, delivery, and distributed recovery semantics.
Event Sourcing
- Operational Concern
- Committed events as authoritative entity history for reconstitution, replay, audit, projection, and async coordination.
- What It Does Not Decide
- Atomic coordination across unrelated entities, or cases where the overhead of a committed event history is not justified.
Outbox
- Operational Concern
- Atomic commit of both a local state change and an event as an async coordination trigger.
- What It Does Not Decide
- Exactly-once processing, fanout distribution, event history, ordering, consumer idempotency, or downstream completion.
Inbox
- Operational Concern
- Receiver-side counterpart to outbox that records processed inputs to provide effectively-once local effects.
- What It Does Not Decide
- Producer-side reliability or ordering, atomicity across multiple consumer effects, or global exactly-once delivery.
CQRS
- Operational Concern
- Segregation of query-facing read models from command-facing write models.
- What It Does Not Decide
- Projection synchronization, replication latency, freshness guarantees, or the read consistency model.
RPC
- Operational Concern
- Synchronous request/response interaction between caller and callee.
- What It Does Not Decide
- Persistence semantics or asynchronous coordination.
Pub/Sub
- Operational Concern
- Asynchronous interaction between a publisher and one or more subscribers using a point-to-point queue or a broker.
- What It Does Not Decide
- Persistence semantics, atomicity with a local state change, or domain-level completion beyond protocol-level interaction.
Actors
- Operational Concern
- Externally addressable processors with mailbox interfaces, serialized message handling, isolated state, and synchronous or asynchronous interaction.
- What It Does Not Decide
- Durability of entity state or events, entity semantics, multi-entity reads or writes.
Sagas and Durable Workflows
- Operational Concern
- Explicit process state that coordinates long-running, multi-step interactions with first-class recovery, retries, timeouts, and compensation.
- What It Does Not Decide
- Strict atomicity or isolation across participants, or domain model semantics by themselves.
A real system usually composes several of these at once.
Recursive coordination across processing nodes
External Trigger connects to Processing Node A as acknowledgment: input starts work. Processing Node A connects to Repeatable External Call as observation: repeatable query call. Processing Node A connects to Atomic Commit as fact: state decision. Atomic Commit connects to Local State as fact: state committed. Atomic Commit connects to Coordination Trigger as obligation: trigger committed. Coordination Trigger connects to Broker / Relay as obligation: published after commit. Broker / Relay connects to Node B Inbox Repeats Shape as acknowledgment: at-least-once delivery.
The architecture is not the name of any one component. It is the composed set of guarantees across the boundaries where facts, triggers, deliveries, receipts, and follow-up transitions move.
Infrastructure as Realization Substrate
Infrastructure components are realization substrates, not architectures. They must be configured and composed so the system actually satisfies the required operational semantics.
Databases can support local commit boundaries, authoritative state, projections, and event histories, but they still need explicit coordination semantics.
- PostgreSQL - local ACID commit, current-state persistence, relational projections, outbox tables, inbox records, constraints, and transactional reporting views.
- Cosmos DB - partitioned document state, aggregate snapshots, change-feed-driven projections, and document-level outbox patterns.
- EventStoreDB - append-only histories, stream-level optimistic concurrency, event-sourced reconstitution, replay, and projections.
Choosing infrastructure is therefore the second step. The first step is naming the semantics the substrate must preserve.
For the Cohesive-specific realization model, see Cohesive.Infra, which binds compute, runtimes, services, capabilities, and projection targets to the semantics they must preserve.
A Cohesive View
Cohesive captures semantic structure independently of any one realization. Persistence, execution, indexing, messaging, API, and infrastructure can all be bound to the model while the semantics themselves remain stable.
- Cohesive.Entities captures entity transitions, invariants, and effects that can be bound to storage, runtimes, indexes, messaging, and API surfaces.
- Cohesive.Api models APIs independently of a transport realization such as ASP.NET, OpenAPI, AsyncAPI, gRPC, or GraphQL, while still binding API operations to entity and process models.
- Cohesive.Processes captures multi-step coordination so it can be bound to workflow runtimes or durable execution engines.
- Cohesive.Relations captures relationships for queries, hydration, DTOs, and indexing without requiring the underlying database to be relational.
- Cohesive.Infra models infrastructure semantics and binds those blocks to realization substrates.
The same semantic structure can be viewed through several authoring surfaces:
public sealed class Tender : Entity<Tender>
{
public sealed record AcceptTenderInput(string PartnerId, DateTimeOffset AcceptedAt);
public sealed record Emit990Accept(string TenderId, string PartnerId) : IEffectRequest<Nothing>;
public Tender()
{
Status = Field(nameof(Status), TenderStatus.Pending);
Expiration = Field<DateTimeOffset>(nameof(Expiration));
PartnerId = Field<string?>(nameof(PartnerId), initialValue: null);
AcceptedAt = Field<DateTimeOffset?>(nameof(AcceptedAt), initialValue: null);
AcceptTender = Transition<AcceptTenderInput>(
nameof(AcceptTender),
t => t
.Requires("CanAccept", (tender, input) =>
tender.Status == TenderStatus.Pending &&
tender.Expiration > input.AcceptedAt
)
.Set(tender => tender.Status, (_, _) => TenderStatus.Accepted)
.Set(tender => tender.PartnerId, (_, input) => input.PartnerId)
.Set(tender => tender.AcceptedAt, (_, input) => input.AcceptedAt)
.EmitSnapshot("TenderAccepted", (snapshot, input) => new
{
tenderId = snapshot.EntityId.Value,
partnerId = input.PartnerId
})
.RequestSnapshot<Emit990Accept, Nothing>((snapshot, input) =>
new(snapshot.EntityId.Value, input.PartnerId)));
}
public Field<TenderStatus> Status { get; }
public Field<DateTimeOffset> Expiration { get; }
public Field<string?> PartnerId { get; }
public Field<DateTimeOffset?> AcceptedAt { get; }
public Transition<Tender, AcceptTenderInput> AcceptTender { get; }
}
// Runtime evaluation: the transition is applied to an observed entity state.
var tender = new Tender();
var state = tender.CreateState("tender-123", new
{
Status = TenderStatus.Pending,
Expiration = DateTimeOffset.UtcNow.AddHours(4)
});
var result = tender.AcceptTender.Apply(
state,
new Tender.AcceptTenderInput("partner-42", DateTimeOffset.UtcNow)
);
// result.State contains the accepted tender.
// result.Effects contains TenderAccepted and Emit990Accept coordination material.The result is not a pattern taxonomy. It is an explicit path from semantic intent to the operational choices that preserve that intent across persistence, delivery, commit, coordination, and recovery boundaries.