Interview Prep Zoneby CuriouserLabs

Question 5 of 5 · architect level

Transactions across services: why is 2PC avoided, and how do sagas and the outbox pattern replace it?

🎤 Say this first

2PC/XA gives atomicity across resources but couples every participant's availability and holds locks across the slowest member — coordinator failure can block everyone (it sacrifices availability, and modern brokers/cloud DBs often don't even support it). The industry's answer: local ACID + asynchronous propagation. The outbox pattern makes 'update my DB + publish event' atomic (event row committed in the same local TX, relayed by CDC/poller). Sagas replace the global TX with a sequence of local TXs plus compensating actions for rollback — orchestrated (explicit state machine) or choreographed (event-driven). The price: designing for eventual consistency and idempotency everywhere.

The full picture

  • Why not 2PC: synchronous coupling of availability (all participants + coordinator up), in-doubt transactions holding locks during coordinator recovery, throughput collapse under latency variance, poor/no support in Kafka, many NoSQL stores and serverless DBs — and it still doesn't cover 'call a third-party REST API'.
  • Outbox mechanics: INSERT business rows + INSERT event row in one local TX → Debezium/CDC (or a poller) publishes → consumer processes idempotently. Guarantee: at-least-once, ordered per aggregate — pair with consumer dedup (event id table or natural idempotency).
  • Saga trade-offs: orchestration centralizes visibility/retry/timeout logic (Temporal, Camunda, a state-machine table); choreography decentralizes but the flow becomes emergent — hard to answer 'where is order 123 stuck?'. Compensations are business actions (refund, release stock), not DB rollbacks — some things can't be uncompensated (sent email), so sequence risky steps last.
  • Semantic anchors: isolation is gone between steps — other transactions see intermediate states; design 'pending' states into the domain model explicitly.
Outbox: atomicity restored locally
@Transactional
public void placeOrder(Cart cart) {
    var order = orderRepo.save(Order.from(cart));          // same local TX
    outboxRepo.save(OutboxEvent.of("OrderPlaced",           // same local TX
        order.id(), toJson(order)));                        // -> atomic ✅
}   // CDC tails the outbox table and publishes to Kafka.
    // Crash anywhere: either both rows exist or neither. No dual-write gap.

🔄 Likely follow-up questions

  • Design the compensation flow for order → payment → inventory when inventory fails.
  • How do consumers deduplicate — exactly-once vs effectively-once?
  • Where does 'read your own writes' break in a saga, and how do you shield users from it?