Software Architecture

Transaction Design: ACID, Saga and the Outbox Pattern

Transaction Design: ACID, Saga and the Outbox Pattern

About this article

This article is the sixth deep dive in the “Software Architecture” category of the Architecture Crash Course for the Generative-AI Era series, covering transaction design.

The mechanism that guarantees “all-succeed-or-all-undo” — the design core that prevents money-disappearing or money-appearing accidents like bank transfers. The article covers ACID properties, isolation levels, distributed transactions, eventual consistency, Saga / Outbox patterns, the CAP theorem, and idempotency, with axes for sorting consistency level per business need.

Before you read this

This article is mostly about development: programs and APIs. If IT vocabulary is unfamiliar, reading the primer "Programs and APIs" first makes it far easier to follow. You can also look anything up in the glossary as you read.

What is a transaction in the first place

A transaction is “a mechanism that makes a series of operations either all succeed or all roll back.”

Imagine an ATM bank transfer. When A sends $100 to B, “deduct from A’s account” and “add to B’s account” must succeed as a pair. If only one succeeds, $100 either vanishes or appears out of thin air. The mechanism guaranteeing “all succeed or all roll back” is a transaction.

Why transaction design matters

What happens if transaction design is left vague? Money-disappearing or money-appearing accidents happen for real. Some data — bank-account balances — needs strict consistency; some — SNS “like counts” — can drift slightly without harm. Applying maximum consistency to everything makes the system too heavy; sorting precision per business need is the design core.

Transaction design must be considered in tandem with overall structure (especially microservices). If 1 DB suffices, ACID alone is enough — whether you can tilt toward a configuration that avoids distributed transactions directly affects operational cost.

Transaction design decides “which data needs what granularity of consistency” in the system. Some data — bank-account balances — needs strict consistency; some — SNS “like counts” — can drift slightly without harm. Applying maximum consistency to everything makes the system too heavy; sorting precision per business need is the design core.

It must be considered together with overall structure (especially microservices). If 1 DB is enough, ACID alone suffices; whether you can lean on a configuration that avoids distributed transactions ties directly to operational cost.

Consider it together with overall structure. If 1 DB is enough, ACID alone is enough.

ACID properties and isolation levels

The classic transaction guarantee RDBMSes have offered for decades is ACID — four properties whose initials guarantee data integrity strongly.

ACID matters because applications’ biggest losses come from “money / inventory / order data inconsistency.” Once data drifts, root-cause investigation, correction, and customer-facing costs balloon, shaking trust in the entire business.

ACID established with RDB development from the 1970s, designed assuming “strong integrity guaranteed within one DB.” It worked perfectly when servers and DBs were one machine each. In the cloud-era distributed environment, maintaining ACID across multiple DBs and services requires locking all nodes; one network outage stops everything, making it operationally impractical. That’s why eventual consistency and Saga (covered later) emerged.

PropertySubstance
AtomicityAll-or-nothing; no partial state
ConsistencyAlways fail on constraint violations
IsolationConcurrent execution doesn’t interfere
DurabilityCommitted data survives failures

Within a single DB, ACID is solidly sufficient — the default for finance, accounting, inventory, and other strong-consistency-required businesses.

Isolation levels are the dial on top of that.

In ACID, the realistic trade-off with performance is “I” (Isolation). The level you pick determines how strictly other transactions are kept apart. Stricter raises consistency but lowers concurrent performance.

LevelAllowed phenomenaPerformance
READ UNCOMMITTEDSee others’ uncommitted valuesFastest
READ COMMITTEDSame query may produce different results (non-repeatable read)Fast
REPEATABLE READNew rows may appear mid-transaction (phantom read)Mid
SERIALIZABLEAll prevented (strictest)Slow

PostgreSQL defaults to READ COMMITTED, MySQL (InnoDB) to REPEATABLE READ. Higher isolation costs performance, so strengthen only the parts that need it.

Distributed transactions and eventual consistency

In microservices and multi-DB configurations, transactions across multiple DBs sometimes become necessary. Traditionally 2PC (Two-Phase Commit — ask all DBs “prepared?” then commit together) solved this, but it has fatal flaws in the cloud era.

2PC forces a two-phase exchange of “all nodes prepared -> all nodes commit”, so any one node going down blocks everything. In cloud environments where network failures are routine, running 2PC in production frequently stops the whole service — “effectively non-functional” in practice.

The cloud-native principle is to avoid 2PC. Use eventual consistency instead.

Eventual consistency is the premise you fall back to once you cannot have ACID across the whole.

Eventual consistency allows a relaxed guarantee: “not consistent immediately, but consistent eventually.” Writes succeed instantly; propagation to other replicas and services happens asynchronously. So users may temporarily see stale data on screen.

Bank accounts can’t drift even momentarily, but “like counts,” “follower counts,” “review counts” on e-commerce sites work fine even if 1 second stale. Treating “data where slight drift is fine” as eventually consistent secures the system’s overall availability and scalability.

Examples: Amazon inventory counts, Twitter follower counts, Instagram likes, YouTube view counts.

Per data type, judge from business requirements whether strong consistency is required or eventual is sufficient.

The CAP theorem is what forces that trade-off in the first place.

The foundation of distributed-system architecture is the CAP theorem: Consistency, Availability, and Partition tolerance can’t be simultaneously satisfied — that’s the constraint.

Network partitions happen in reality, so “P” is essentially required. Practical choice becomes “CP (consistency-priority)” vs “AP (availability-priority).” Business requirements determine whether “err out but be accurate” or “keep running with stale data.”

ChoiceRepresentative systemsBusiness
CP (consistency)Traditional RDB, MongoDB (configurable)Banking, payments, inventory
AP (availability)DynamoDB, Cassandra, RedisSNS, e-commerce browsing, analytics

Reverse from business requirements. Picking distributed DBs without CAP awareness produces unexpected behavior.

Saga pattern — operating distribution realistically

Saga is a pattern that implements distributed transactions as “a chain of small local transactions + compensation.” In a hotel-booking flow, “reservation creation,” “payment,” “inventory reservation,” “notification” run on different services; failure mid-flow triggers compensation to undo earlier steps.

Saga Pattern Processing Flow Achieve distributed transactions through local TX chaining + compensating actions on failure Happy path: Each step completes sequentially with local TX Step 1 Create Reservation Local TX: INSERT Step 2 Payment Processing Local TX: charge Step 3 Reserve Inventory Local TX: reserve Step 4 Send Notification Local TX: notify Error path: Step 3 fails → Execute compensating actions in reverse Failed! Reserve Inventory Error Occurred Compensate: Refund refund(payment.id) Idempotency Required Compensate: cancel it cancel(reservation.id) Idempotency Required Orchestration Type Central orchestrator controls sequence. Flow is easy to see Required Prerequisites Compensating actions must be idempotent. Combining with Outbox pattern is standard Saga + Outbox + Idempotency trio is the required pattern for microservices

Unlike 2PC, this doesn’t lock all services; each step completes locally, so it works in cloud environments. But incomplete compensation design risks leaving half-done state.

Pseudocode for orchestration-style Saga: each step completes in its own local TX; on failure, compensation runs in reverse order.

async function bookHotel(input: BookingInput) {
  const completed: Array<() => Promise<void>> = [];
  try {
    const reservation = await reservationSvc.create(input);
    completed.push(() => reservationSvc.cancel(reservation.id));

    const payment = await paymentSvc.charge(input.amount);
    completed.push(() => paymentSvc.refund(payment.id));

    await inventorySvc.reserve(input.roomId);
    completed.push(() => inventorySvc.release(input.roomId));

    await notificationSvc.notify(input.userId);
    return { ok: true, reservationId: reservation.id };
  } catch (err) {
    // Compensate in reverse order (idempotent assumed)
    for (const compensate of completed.reverse()) {
      await compensate().catch(logCompensationFailure);
    }
    throw err;
  }
}

Compensation must be idempotent. If retries running double don’t produce stable results, the saga itself becomes a new source of consistency incidents. In production, combine with the Outbox pattern to reliably synchronize message sending and DB updates — the standard approach.

Saga comes in two forms.

Saga has two implementation styles, differing in where flow control sits.

StyleTraitProsCons
OrchestrationCentral orchestrator controls orderVisible flow, easy debuggingCentral-aggregation risk
ChoreographyEvent-driven, services act autonomouslyLoose coupling, scalesHard to grasp the whole

When business is complex and order matters, Orchestration. When simple and services are independent, Choreography.

Default is starting from Orchestration. Systems where the whole isn’t visible become operational hell.

Outbox pattern and idempotency — the mandatory kit for distribution

Outbox harmonizes “DB write” and “message-queue send” in a single DB transaction. It prevents the recurring microservices trouble “DB succeeded but Kafka send failed” (Kafka: distributed message-queue infrastructure) — the standard pattern.

  1. Record the message-to-send into the outbox table together with business data.
  2. A separate process (Relay) reads outbox and sends to the message queue.
  3. Update the sent flag.

This prevents the accident of only one of “DB commit” or “send” succeeding. For event-driven microservice architecture, near-mandatory.

Saga + Outbox is the default; near-mandatory for microservices.

Idempotency is the other half of that kit.

In distributed systems, “the same request arriving multiple times” due to network failures and timeouts is routine. The property guaranteeing the result is the same regardless of repeat execution is idempotency.

Without idempotent “payment APIs,” retries cause double charges. Idempotency is achieved by clients attaching a request ID and servers deduplicating by that ID — the default. By HTTP method, GET / PUT / DELETE are spec-defined idempotent; POST is not, requiring care.

In microservices, event-driven, and retry processing, idempotency is required; bolting it on later is hard, so design from the start.

How to choose — sorting by consistency level

The core of transaction design is identifying the consistency level needed per business. Demanding strong consistency for all data crashes performance; making everything eventual breaks the business.

The decision axis: “if this data is even briefly inconsistent, will there be financial, legal, or reputational damage?” Money-moving processing, inventory allocation, records subject to taxation or audit can’t tolerate even one cent / one unit drift, so strong consistency is mandatory; compromising here shakes the business itself.

Conversely, “data that works business-wise even with slight drift” like like counts, view counts, recommendations, log aggregations is fine with eventual consistency; applying strong consistency would sacrifice performance and scalability. Asking the business “how would it hurt if this data were a few seconds stale?” and reasoning back is most reliable.

BusinessRequired consistencyReason
Bank transfers / paymentsStrong (ACID)Drift = financial damage
InventoryStrong or SagaOverselling = refunds and trust loss
Order historyStrongTax / audit requirements
Likes, view countsEventualSlight drift is harmless
RecommendationsEventualStale is OK if working
Logs / analyticsEventualScale over real-time

Strong consistency is a high-cost requirement; limit it to where it’s truly needed.

By data type, the consistency level sorts out as follows.

Note: industry rates as of April 2026. Periodic refresh required.

“All strong consistency” is excess and “all eventual” is dangerous; sorting consistency level per data type is the operational core. Typical industry split:

Data typeConsistency levelReasonImplementation
Bank balance / paymentsStrong (ACID, SERIALIZABLE)1-cent drift = damage1-DB ACID
Orders / inventory allocationStrongOverselling = refunds and trust loss1-DB ACID or Saga
Member registration / authStrongDirect security risk1-DB ACID
Tax / audit recordsStrong + tamper protectionLegal requirementACID + WORM
Cart infoMid (READ COMMITTED)Temporary drift OK1-DB TX
Likes / view countsEventualSeconds-stale OKAsync aggregation
RecommendationsEventualStale is OK if workingBatch recompute
Logs / analyticsEventualNo real-time needKafka + DWH

The isolation-level numeric gate: PostgreSQL’s default READ COMMITTED as the basis, with “only inventory allocation and financial transactions individually elevated to SERIALIZABLE as the rule. Applying SERIALIZABLE to all tables drops concurrent performance several-fold; limit it to the necessary parts.

Strong consistency is high-cost; limit it to genuinely necessary business.

Three scenarios

If you are building solo or at a startup

Close everything inside ACID transactions in a single PostgreSQL database. With a monolith and one database, the hard problem of distributed transactions simply does not exist from the start. That, I think, is the single largest design advantage of solo development.

Personal / Startup: Ship in One Month Is Correcten.senkohome.com/arch-intro-case-startup/

If you are a small or mid-size SaaS

Keep ACID inside one database as the baseline, and introduce the transactional outbox pattern for integrations with external services such as payments and email. That prevents the inconsistency of “the database commit succeeded but the Stripe charge did not, or the other way round” by moving it to retryable asynchronous processing.

Small-Mid SaaS - Lean on Managed and Run with Few Peopleen.senkohome.com/arch-intro-case-saas/

If you are a large enterprise

Consistency after splitting services is designed with the Saga pattern and compensating transactions. That said, work that genuinely needs strong consistency — payments, stock allocation, accounting — belongs in the same service and the same database in the first place; distributing it and then straining with Saga is the wrong order. For data subject to audit, include WORM storage in the requirements as well.

Large-Enterprise Core: Design That Holds Up for Yearsen.senkohome.com/arch-intro-case-enterprise/

AI decision axes — Staying inside one database is the AI’s safe zone

Typical holes in AI-generated transaction code

When you instruct AI to “write order processing,” it writes single-DB ACID transactions accurately. The BEGIN→INSERT→UPDATE→COMMIT basic pattern exists abundantly in training data.

Problems emerge in cross-service scenarios. For example, if you instruct “charge the payment service → decrement inventory service → write to orders table” as 3 steps, AI writes code calling each step sequentially but often omits compensation (refund) for step 1 when step 2 fails.

Specific areas AI tends to miss:

  • Refund logic when inventory decrement fails after payment API success
  • Atomicity between message-queue sends and DB writes (scenes requiring the Outbox pattern)
  • Idempotency-key design to prevent double-processing on retries
  • Recovery when server-side processing succeeded after a timeout

These are typical “works on the happy path but breaks on error paths” code, undetectable by unit tests.

This is why an architecture that closes inside one database is the safest ground in the AI era.

Within the scope where ACID works on a single PostgreSQL instance, AI writes transaction control correctly. INSERT/UPDATE within BEGIN~COMMIT, data integrity via foreign-key constraints, optimistic locking with WHERE version = ? — all are classic patterns where AI generation accuracy is high.

In other words, deciding at the architecture level to “keep things 1-DB-complete as much as possible” maximizes the range where AI can write safely. Distributed transactions are an area where even humans struggle to implement correctly, and maintaining quality when delegating to AI is even harder.

Complexity Comparison: Single-DB vs Distributed Transactions Keeping to single-DB maximizes the scope where AI can write safely Single-DB (ACID) BEGIN INSERT/UPDATE COMMIT 1 DB ACID Guarantee Foreign Key Constraints Optimistic Locking AI can write safely. High accuracy with standard patterns Distributed Transaction Service A Service B Service C Required Additional Design Saga Pattern Outbox Pattern Idempotency Key Compensating Action Distributed Tracing Eventual consistency acceptance decision → Hard even for humans → Even harder for AI → Error paths are complex Extremely difficult for AI to write correctly Prioritize single-DB. If distributed is needed, use the Saga + Outbox + Idempotency trio

Caveats when delegating idempotency-key design to AI

When you instruct AI to “add idempotency to this API,” it writes an implementation retrieving Idempotency-Key from the request header and storing it in Redis. Due to Stripe API influence, many examples exist in training data, and the basic pattern is accurate.

However, the following design decisions are not made automatically by AI, so they need to be decided in advance:

  • Key expiration period (24 hours or 7 days)
  • Behavior when the same key arrives with a different request body (return 409 or ignore)
  • Key storage destination (Redis / DB / both)
  • Key format (fixed UUID v4, or user-ID + operation-type combination)

These depend on business requirements, so the realistic approach is to codify them in design documents or ADRs before delegating implementation to AI.

Pitfalls and forbidden moves

Here are the six most dangerous of the typical accidents in transactions that cross microservices or databases.

Forbidden moveWhy it is bad → what to do instead
Running two-phase commit in production in the clouda network failure blocks every service → design with Saga and eventual consistency
Implementing retries with no idempotency keydouble payments, double stock decrements and duplicate notifications follow → design idempotency before retries
Not designing the Saga compensating actionshalf-finished states survive — payment succeeded, stock did not → give every step an idempotent compensating action
Writing to the database and sending to the queue in separate transactions”database succeeded, send failed” breaks consistency → use the Outbox pattern
SERIALIZABLE on all dataconcurrent performance drops several times over → promote only stock and financial data individually
Implementing distributed transactions yourselfbuilding without knowing Saga or Outbox always ends in collapse → hand it to a proven library such as Temporal

The Knight Capital incident of 2012 was not strictly a distributed-transaction failure, but the scenario — “one machine still running old code, plus a runaway retry” producing 45 minutes, 440 million dollars of losses and the end of the company — shows what neglecting idempotency and consistency design can cost (details in the appendix on major incidents).

Author’s note — the day we decided “timeout means failure”

A project added 3-retry to the client side as timeout protection for the payment API; the result was duplicate charges to a small number of users. Server-side processing had succeeded; only the response was delayed, and all retries went through.

Few developers haven’t watched a similar failure happen nearby. Retry implementation tends to come in optimistically, and the “probably failed, let’s resend” mindset is the textbook path to production double-charges.

Without an idempotency key, “timeout = failure” is almost never a safe assumption. The “don’t know if it succeeded” state across the network is routine in distributed systems. Design idempotency keys before adding retries — that’s the rule.

Distributed + retry + no idempotency = double-processing landmine. Triple-set is the safety condition.

What you must decide — what’s your project’s answer?

Articulate your project’s answer in 1-2 sentences for each:

  • Per-data consistency requirement (strong / eventual)
  • Isolation level (the lowest line business tolerates)
  • Distributed-transaction handling (Saga / Outbox / avoid)
  • Retry policy and idempotency
  • Locking strategy (optimistic / pessimistic)
  • CAP choice (CP / AP)

Write your answers down as an ADR. A concrete guide to writing them is here.

[DevOps Architecture] Documentationen.senkohome.com/arch-intro-devops-docs/

Summary

This article covered transaction designACID, isolation levels, Saga, Outbox, the CAP theorem, idempotency.

Sort consistency level per data, prioritize 1-DB completion, and use the Saga + Outbox + idempotency triple-set when distributed is needed. The 2026 realistic answer including AI era.

The next article is the Software Architecture category’s final installment: authentication and sessions (server session / JWT / OAuth).

Back to series TOC -> ‘Architecture Crash Course for the Generative-AI Era’: How to Read This Book

I hope you’ll read the next article as well.