Transactional outbox — side effects leave via a queue, not inline
Transactional outbox — side effects leave via a queue, not inline
When a request must both persist state and trigger side effects (email, SMS, webhooks), write the side effect to an outbox in the same transaction as the state change, and let a separate dispatcher deliver it. The handler never calls external services inline.
Problem it solves
- Inline side effects couple request latency to vendor health, lose effects on crash-after-commit, and make retries double-send. Dual deployment makes it worse: Lambda freezes mid-handler, containers restart on deploy.
Shape
- State change + outbox row in one atomic store write (the store's transaction boundary).
- Dispatcher (separate trigger: queue consumer, cron, or container worker) reads pending outbox rows, calls the sender, marks delivered.
- Senders are pluggable (email/sms/webhook) behind an interface; a
GET /v1/outbox-style inspection endpoint aids debugging (flow-builder exposes one). - Delivery is at-least-once: senders must tolerate duplicates (idempotency keys downstream).
Rules
- Never call an external vendor inside the request handler — enqueue and return.
- Outbox rows are state, not logs: they survive restarts and are queryable.
- Bound retries with backoff; dead-letter after N attempts rather than infinite retry.
- The outbox is per-service and store-backed (works on memstore in tests, Dynamo/SQL in production via
env-selected-store-adapters).
Anti-patterns
- "Fire and forget" goroutines for emails — frozen in Lambda, lost on restart.
- Exactly-once assumptions — design for at-least-once + downstream idempotency.
- Unbounded outbox growth — no TTL/cleanup policy.
- Vendor SDK types leaking into the outbox schema.
Sources
- factory/flow-builder/internal/actions/actions.go (Outbox + senders)
- factory/flow-builder/internal/httpapi/httpapi.go (GET /v1/outbox)
- factory/AGENTS.md (async via queues rule)