go-atlas ships four mechanisms for keeping a state change consistent with whatever else has to happen alongside it: another domain reacting, a non-transactional side effect, a message to another service, or a multi-step workflow. They are not interchangeable. Each sits at a different point on two axes:
- Boundary. Does the work stay in-process (one goroutine, one transaction), or cross a process / service boundary (network, another database)?
- Durability. Is the coordination ephemeral (lost on crash) or persisted (survives a crash, recovers / resumes)?
| Mechanism | Boundary | Delivery | Durable | Atomicity scope | Rollback model |
|---|---|---|---|---|---|
domain/eventbus |
In-process | Synchronous | No | The caller's single transaction | Handler error → that transaction aborts |
domain/eventbus/uow |
In-process | Synchronous | No | One transaction + post-commit effects | LIFO compensation of applied effects |
data/outbox |
Cross-process | Async, at-least-once | Yes | The producing transaction only | No rollback — reliable forward delivery |
transport/broker |
Cross-process | Async pub/sub | Yes (with outbox) | None (it moves messages) | None — consumers must be idempotent |
data/saga |
Cross-service | Step sequence | Yes (persisted) | Each step's own transaction | Reverse-order compensation, crash recovery |
Almost every choice here comes down to one hazard. You change state in a database and you need a second thing to happen: notify another domain, put an object in a store, send a message to another service. If that second thing is not in the same database transaction, a crash or abort between the two leaves the system inconsistent: the row is written but the message never sent, or the message is sent but the row rolled back.
The four mechanisms answer that one hazard differently, and which you reach for depends on where the second thing lives.
A synchronous, in-process event bus. A handler runs in the publisher's goroutine, context, and transaction, so its writes join the same transaction and a handler error rolls the whole thing back (the bus's veto power). Use it to let one domain react to another's change without a direct code dependency, and to fail the originating operation if the reaction can't proceed. No durability, no network. See the Event Bus guide.
The companion unit of work. When a handler needs a non-transactional side effect (an object-storage put, a cache write, an external call), it does
not run it inline; it registers it with OnCommit, and uow applies it once after the commit, compensating already-applied effects in LIFO order
if a later one fails. Closes the dual-write gap inside one process without an outbox. It is not crash-safe: a crash between the commit and the
effect leaves inconsistency. That is the deliberate single-process trade-off.
The transactional-outbox pattern. The event is persisted to a store in the same transaction as the state change, then dispatched later by background
workers (dispatch, retry, cleanup, stuck-event recovery). This is the crash-safe answer to the dual write: because the event is written atomically with
the state, it cannot be lost; because it is delivered out-of-band, the transaction never waits on the network. Delivery is at-least-once,
transport-agnostic (the Handler decides how to deliver: broker, HTTP, gRPC). Lease and retention windows are evaluated against the database
server clock (MongoDB $$NOW), not the worker's wall clock, so a clock-skewed worker can neither prematurely steal a locked event nor leak one;
timestamps are stored as BSON Date (run MigrateTimestampsToDate once on a legacy collection).
A message-broker abstraction (NATS JetStream provider) for async pub/sub across processes and services. It is the transport an outbox delivers to.
transport/broker/outbox is the broker-specific adapter over data/outbox (msg.Message conversion + broker retry), implementing broker.Outboxer.
Use the broker for fire-and-forget or event-driven integration where producer and consumer are decoupled in time and space. Consumers must be
idempotent (at-least-once), so pair them with data/idempotency.
A durable, persisted orchestration saga for a business transaction that spans several steps, each its own local transaction, possibly in different
services. Each step has an optional compensating action; on failure the completed steps roll back in reverse order. A checkpoint is persisted after
every stage, so an instance survives a crash and can be resumed or automatically rolled back. The pivot marks the point of no return: failures
past it roll forward (retry) rather than compensate. See the Saga guide.
Walk the questions in order; the first match wins.
- Does the reaction need to run inside the same transaction and be able to veto it? →
eventbus(in-process, synchronous). If that reaction also has a non-transactional effect to undo on later failure, adduow. - Do you need a second thing to reliably happen in another process/service after this transaction commits? → persist it with
data/outboxand deliver viatransport/broker. Never publish to the broker directly inside the transaction; that is the dual write. - Is it a multi-step business transaction across services, where each step must be compensable and the whole thing must survive a crash? →
data/saga. - Is it just async pub/sub with no transactional coupling to a local write? →
transport/brokeralone.
They are layers, not alternatives. A common reliable-event pipeline uses three of them together:
- Inside the database transaction, the domain writes its state and publishes a domain event on
eventbus; in-process handlers react synchronously (and may veto). - An
eventbusadapter persists an outbox record in the same transaction (data/outbox). The cross-service hop is now crash-safe. - After commit, the outbox worker dispatches the record through
transport/brokerto the message broker; the remote service consumes it idempotently.
uow slots in for the local non-transactional effects that should not become messages (an object-store write you want to compensate, not relay).
data/saga sits above all of this when the operation is a multi-service workflow: the broker/outbox move its messages, while the saga owns the step
sequence, compensation, and crash recovery.
- Never dual-write. Writing the database and then publishing to the broker as two separate steps loses messages on a crash in between. Persist via
data/outboxin the transaction; deliver after commit. eventbusis in-process only. Do not use it to reach another process or service — it has no network and no durability. Use the broker for that.uowis not durability. Its compensation handles an effect that fails after a successful commit; it does not survive a crash between commit and effect. When you need crash-safety, route through the outbox instead.- At-least-once means idempotent consumers. Both
data/outboxandtransport/brokercan deliver a message more than once. Consumers must dedupe — usedata/idempotency. - Don't reach for a saga for a single local transaction. A saga is for multi-step, multi-transaction (often multi-service) workflows with
compensation and recovery. One local transaction with
eventbus/uowis simpler and atomic; a saga there is overkill. - Keep the boundary honest. In-process coordination (
eventbus/uow) gives true atomicity within one transaction. Cross-process coordination (outbox/broker/saga) gives eventual consistency with compensation, never cross-database atomicity. Choose the mechanism that matches the boundary, not the one that's convenient.
- Event Bus — in-process delivery semantics and the
uowcompanion - Saga — orchestration model, status lifecycle, crash recovery, examples
- Package references:
transport/broker,data/outbox,data/saga,data/idempotency