Data Management in Microservices

Splitting the monolith database is where microservices get hard. You lose single-transaction ACID across domains, cannot JOIN across service boundaries, and must choose how to keep writes consistent, reads fast, and events reliable. This chapter goes deep on database-per-service, why 2PC fails in production, sagas, CQRS, event sourcing, and the transactional outbox.

developer lead architect

Why data is the hardest part of microservices

Network diagrams hide the fact that most production incidents in distributed systems trace back to data: dual writes, missing compensations, stale read models, or a shared database pretending services are independent.

In a modular monolith, one PostgreSQL instance gives you ACID transactions that span orders, payments, and inventory. Schema migrations run once; reporting JOINs answer cross-domain questions in milliseconds; foreign keys enforce referential integrity in the database. Microservices push toward database per service—each team owns schema evolution, backup strategy, and scaling knobs. The instant storage splits, there is no atomic commit across services without distributed protocols that most teams deliberately reject for online traffic.

The replacement toolbox is explicit: eventual consistency with documented lag budgets, sagas for multi-step writes, CQRS and API composition for reads, transactional outbox for reliable integration events. None of these are free—product UX must show “pending” states, operations monitors projector lag, and engineers reason about failure mid-saga with correlation IDs.

🎯 Interview Tip

Open with: “We use database-per-service, sagas instead of 2PC, outbox for reliable events, CQRS or composition for cross-domain reads.” Then walk checkout: reserve inventory → charge payment → confirm order, including compensation if payment fails after reserve.

Database per service

Each microservice has a private data store that no other service accesses directly—integration happens only through well-defined APIs and events.

Why separate databases

Independent schema evolution — Order Service adds a delivery_slot column on Friday without a company-wide migration window or risking Inventory Service’s deployment calendar. Technology fit — choose storage for access patterns, not corporate standardization on one RDBMS shape. Failure isolation — a runaway analytics query on a shared reporting replica should not lock the checkout row. Team autonomy — bounded context from Service Design maps cleanly to an owned schema and backup/restore runbook.

Polyglot persistence — right store per job

Polyglot persistence means different services (or different aggregates within a platform) use different storage engines. The goal is not novelty—it is matching consistency, query shape, and throughput to the problem.

StoreStrengthsTypical microservice use
PostgreSQL ACID transactions, rich SQL, JSON columns, mature ops Order aggregate, payment ledger, anything needing invariants in one transaction
MongoDB Flexible document schema, nested arrays, horizontal sharding Product catalog with varying attributes, CMS content, configuration blobs
Redis Sub-millisecond reads, TTL, atomic counters, pub/sub Session cache, rate-limit counters, idempotency keys, distributed locks (with care)
Elasticsearch Full-text search, facets, analyzers, inverted indexes Product search, log analytics, CQRS read model for typeahead browse
Cassandra Linear scale writes, tunable consistency, wide-column time series IoT telemetry, click streams, high-volume audit append where partition key design is explicit

Order Service might use PostgreSQL for transactional state while Search Service projects the same domain events into Elasticsearch— two stores, one logical bounded context split by command vs query (CQRS), or two services with clear ownership boundaries. Operational cost rises with each engine: backups, monitoring, on-call runbooks, and hiring expertise multiply.

Shared database anti-pattern — why it persists and why it is wrong

Shared databases remain common because they are the path of least resistance during early microservice adoption: teams split HTTP services first but keep one database “temporarily” so reports still JOIN and transactions still work. That temporary state often lasts years.

What goes wrong:

  • Coupled deployments — renaming a column in “your” table breaks three other services’ ORM mappings; every release becomes coordinated.
  • Unclear ownership — no one owns migration policy; DBAs become bottlenecks; teams blame each other for locks and slow queries.
  • Leaky boundaries — Service A runs SQL against tables owned by Service B; domain language leaks into foreign schemas.
  • False microservices — independent deployability is a lie if schema is a shared contract; you have a distributed monolith with network overhead.
  • Scaling trap — cannot scale Order write path independently from Analytics read load on the same instance.

Acceptable exception: strangler migration phase where legacy monolith DB is read through an anti-corruption layer while data is copied to new service stores— with a written exit date and feature flags, not “we’ll fix it later.” See Strangler Fig.

🚫 Anti-Pattern

Inventory Service “just SELECTs from order.orders” via shared DB credentials. Order team renames status enum; Inventory production crashes at 2 a.m.

The distributed transaction problem

ACID in one database is enforced by a single engine holding locks and a write-ahead log. Split across services, those guarantees fracture unless you pay coordination costs most online systems refuse.

What ACID gives you in a single DB — and what breaks when split

PropertySingle databaseSplit across services
Atomicity All statements commit or all rollback Order commits, Payment HTTP call fails—partial state unless you design saga/outbox
Consistency Constraints checked at commit No global FK; cross-service invariants enforced in application code asynchronously
Isolation Transactions see controlled snapshots No global lock; concurrent sagas interleave; lost updates without versioning
Durability WAL on one node Each service durable locally; no single “commit point” across the cluster

Checkout that debits inventory and charges a card in one monolith transaction becomes: local transaction in Inventory, local transaction in Payment, coordination in between over unreliable network with timeouts and retries. Retries without idempotency double-charge; synchronous coupling without timeouts hangs thread pools—see Resilience → Timeouts.

Two-Phase Commit (2PC) — coordinator and participants

Classic 2PC uses a coordinator and participants (databases or resource managers):

  1. Phase 1 — Prepare — coordinator asks each participant to vote; participants write durable prepare records and hold locks/resources, reply YES/NO.
  2. Phase 2 — Commit or Abort — if all YES, coordinator sends COMMIT; any NO or timeout → ABORT all.
sequenceDiagram
  participant C as Coordinator
  participant A as Order DB
  participant B as Payment DB

  C->>A: prepare
  C->>B: prepare
  A-->>C: vote YES
  B-->>C: vote YES
  C->>A: commit
  C->>B: commit

Why microservices avoid 2PC for request paths:

  • Blocking — participants hold locks through prepare until commit/abort; coordinator crash leaves participants stuck in doubt until recovery.
  • Availability — any participant slow or down blocks the global transaction; CAP forces choosing consistency over availability under partition.
  • Latency — two network round-trips minimum across every resource; multi-region checkout becomes unusable.
  • Operational fragility — XA transactions between Java and heterogeneous stores are brittle; cloud-managed DBs discourage external 2PC coordinators.

2PC still appears inside single distributed databases (Spanner, Galera) where the engine is the coordinator—different problem than orchestrating Payment + Inventory microservices over HTTP.

BASE — the pragmatic microservices posture

Where ACID is the monolith default, microservices often adopt BASE:

  • Basically Available — system responds even under partial failure or partition; degraded responses beat total outage.
  • Soft state — state may change without new input (async projectors catching up, replication lag, saga in COMPENSATING).
  • Eventually consistent — if updates stop, replicas and read models converge to the same state.

BASE is not “ignore consistency”—it means explicit consistency boundaries: strong inside one service’s aggregate transaction; eventual across services with sagas, outbox, and idempotent consumers. Product and support must understand intermediate states (payment pending, inventory reserved).

⚖️ Trade-off

2PC preserves cross-service atomicity at the cost of availability and tail latency. Sagas + outbox preserve availability; you implement compensations and teach UX to show pending/failed states honestly.

CAP theorem — deep explanation

CAP is not “pick two letters and ignore the third”—it describes behavior during a network partition and forces explicit product decisions about stale reads vs failed writes.

Eric Brewer’s CAP theorem: for a distributed data store, during a network partition (messages lost or delayed between nodes), you cannot simultaneously guarantee:

  • Consistency (C) — every read receives the most recent write or an error (linearizable / strong consistency).
  • Availability (A) — every request to a non-failing node receives a non-error response (no guarantee it is the latest write).
  • Partition tolerance (P) — system continues despite arbitrary message loss between nodes (required in real networks).

Since partitions happen in production, the practical choice during a split is CP or AP for that operation—not a one-time logo pick for the whole company.

ChoiceReal systemsBehavior under partition
CP ZooKeeper, etcd, Consul (raft), single-leader PostgreSQL with sync replica Minority side stops accepting writes or elects new leader; prefers consistency over serving stale leadership data
AP Cassandra (default), DynamoDB-style stores, Riak Both sides accept reads/writes; conflicts resolved later (LWW, vector clocks, application merge)

Amazon Dynamo shopping cart: availability during partition means cart merges on reconciliation—better empty cart confusion than hard outage during network blip. Bank ledger inside the same company often stays CP on PostgreSQL: reject write if quorum unavailable rather than double-spend. Microservice teams map this per operation: inventory reservation may need strong consistency within Inventory DB; recommendation scores can be AP with minutes of lag.

PACELC — latency when the network is healthy

Daniel Abadi’s PACELC extends the lens: if Partition, choose A or C; Else (normal operation), choose between Latency and Consistency.

Even without partition, synchronous replication to three regions increases write latency. Async replication to read replicas is fast but readers see stale data—classic EL trade-off. Conversation template: “For operation X, max staleness is Y seconds; for operation Z, we require linearizable reads and accept +40 ms.”

SystemPACEL
Dynamo / CassandraPAEL — fast writes, eventual replica convergence
MongoDB (default primary)PC on single primaryEL with secondary reads
PostgreSQL sync replicaPCEC — consistent but slower cross-region commits
📦 Real World

Document CAP/PACELC choices in architecture decision records per service—not one slide for the whole platform. Payment team’s CP choice does not obligate catalog search to reject reads during a blip.

Saga pattern — the distributed transaction alternative

A saga is a sequence of local transactions—each step commits in its own service. If a later step fails, earlier steps run compensating transactions that undo business effect, not database ROLLBACK across the wire.

There is no global two-phase commit. Instead you accept visible intermediate state: inventory reserved while payment is in flight; order marked PAYMENT_FAILED after compensation. Sagas require idempotent steps, durable saga state, correlation IDs, and reliable event publication—usually via transactional outbox.

Choreography-based saga

Services react to each other’s domain events without a central orchestrator. Order Service publishes OrderPlaced after local commit; Inventory listens, reserves stock, publishes InventoryReserved or ReservationFailed; Payment listens and charges card; Order listens for outcome and confirms or cancels.

Pros:

  • Loose coupling—no central orchestrator dependency or deployment bottleneck
  • Aligns with event-driven architecture and Kafka topology
  • Each service owns its reactions—fits team boundaries

Cons:

  • Hard to track global state — “where is order 48291?” requires tracing events across five topics
  • Cyclic dependencies — A waits for B waits for A if event chains poorly designed
  • Implicit workflow — onboarding engineers must read event catalog, not one state machine diagram
  • Compensation scatter — each service must know which compensating event to emit on failure
flowchart LR
  O[Order Placed event] --> I[Inventory reserves]
  I --> E1[InventoryReserved event]
  E1 --> P[Payment charges]
  P --> E2[PaymentFailed event]
  E2 --> I2[Inventory releases]

Orchestration-based saga

A central saga orchestrator (dedicated service or workflow engine) sends commands to participants, tracks state machine (STARTED → INVENTORY_RESERVED → PAYMENT_PENDING → COMPLETED), and decides next step or compensation on failure.

Pros:

  • Visible end-to-end flow in one place—easier debugging and support tooling
  • Centralized timeouts, retries, and policy (“skip loyalty points if Payment slow”)
  • Easier to change workflow without rewiring five event consumers

Cons:

  • Orchestrator becomes critical dependency—must be HA and versioned carefully
  • Risk of “god orchestrator” accumulating business rules that belong in domain services
  • Participants can drift toward anemic CRUD if all logic lives in orchestrator
sequenceDiagram
  participant O as Order Service
  participant S as Saga Orchestrator
  participant I as Inventory
  participant P as Payment

  O->>S: startPlaceOrder
  S->>I: reserveStock
  I-->>S: OK
  S->>P: chargeCard
  P-->>S: declined
  S->>I: releaseStock
  S-->>O: failed

Compensating transactions

Compensations are semantic undo, not automatic DB rollback:

  • Reserve inventory → compensate with release reservation
  • Charge card → compensate with refund (may be async and subject to payment provider SLA)
  • Send email → compensate with send cancellation email (often no undo—design idempotent “ignore if already sent”)

Compensations must be idempotent—orchestrator may retry releaseStock three times after timeout. Some steps are non-compensatable (physical shipment)—sagas must not start irreversible work until prior steps succeed or use human workflow.

Forward recovery vs backward recovery

StrategyWhenExample
Backward recovery Business step succeeded but later step failed permanently Payment declined → release inventory, mark order CANCELLED
Forward recovery Failure is transient; retry safe and idempotent Payment gateway 503 → retry charge with same idempotency key until success or hard decline

Production sagas combine both: forward retry with exponential backoff on 503/timeout; backward compensation on business rejection or exhausted retries. Saga state table records COMPENSATING until all compensations ack—never delete saga row on first failure.

Axon Framework and Temporal as orchestration tools

Axon Framework (Java) — DDD-friendly command/event model, saga manager with annotation-based flows, integrates with Axon Server or Kafka for event bus. Fits teams already doing event sourcing/CQRS in JVM estate. Saga state persisted; associates events with @SagaEventHandler; good when orchestration stays in-process with domain language.

Temporal (polyglot workflow engine) — durable execution: workflow code looks sequential but survives process restarts; activities call Inventory/Payment with configured retries; workflow history is source of truth. Strong when sagas are long-running (days), need human approval steps, or span many languages. Spring integration via Temporal Java SDK; workers poll task queues—ops runs Temporal cluster separately.

Temporal workflow sketch
@WorkflowInterface
public interface PlaceOrderWorkflow {
  @WorkflowMethod
  void placeOrder(PlaceOrderRequest request);
}

public class PlaceOrderWorkflowImpl implements PlaceOrderWorkflow {
  private final InventoryActivities inventory = Workflow.newActivityStub(
      InventoryActivities.class,
      ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build());

  public void placeOrder(PlaceOrderRequest req) {
    try {
      inventory.reserve(req.orderId(), req.lines());
      payment.charge(req.orderId(), req.amount());
      order.confirm(req.orderId());
    } catch (PaymentDeclinedException e) {
      inventory.release(req.orderId());
      order.fail(req.orderId());
    }
  }
}

Outbox pattern + saga — reliable saga events

Choreographed sagas fail if Order commits but OrderPlaced never reaches Kafka—Inventory never reserves, payment never runs, order stuck forever. Every saga step that publishes an event must use transactional outbox in the same DB transaction as business write. Orchestrated sagas likewise persist orchestrator state + outbox for outbound commands/events atomically.

⚠️ Pitfall

Choreography without sagaId and correlationId on every event—support cannot reconstruct workflow state; distributed traces show disconnected spans.

CQRS — Command Query Responsibility Segregation

Read and write models often have conflicting requirements—CQRS separates the path that mutates state from the path that answers queries, optionally with different storage engines.

Why separate commands and queries

Writes want normalized tables, invariants, small transactional boundaries, optimistic locking, and audit trails. Reads want denormalized shapes for screens, full-text search, aggregations, and cache-friendly DTOs. One JPA entity serving REST GET and POST leads to over-fetching, lazy-load explosions, and cache invalidation spanning the whole aggregate graph. CQRS accepts that the model optimized for PlaceOrderCommand is not the model for OrderSearchQuery.

Simple CQRS — same database, separate models

Single PostgreSQL instance; different classes and repositories: OrderCommandRepository loads aggregate for writes; OrderSummaryQueryDao runs tailored SQL or views for reads. No async lag; lowest operational cost. Good first step when read complexity grows but load is moderate.

Full CQRS — separate read and write stores

Write store: normalized PostgreSQL holding aggregates and business invariants. Read store: Elasticsearch index, Redis materialized view, or dedicated order_read tables optimized per screen. Writes never query read store; reads never touch write tables directly—only projectors update read side.

flowchart LR
  CMD[Commands] --> W[(Write store)]
  W --> EV[Domain events]
  EV --> PR[Projector]
  PR --> R[(Read store)]
  Q[Queries] --> R

Synchronizing read and write stores via events

After successful command transaction, emit event (via outbox): OrderPlaced. Projector consumer updates read model: insert row in order_summaries, index document in Elasticsearch. Lag is typically milliseconds to seconds—UI shows “Processing…” or polls until read model catches up for read-your-writes scenarios. Projectors must be idempotent (at-least-once delivery) and handle out-of-order events where business allows.

CQRS + Event Sourcing as natural pairing

Event sourcing makes the write side an append-only event log; CQRS read models are projections of that log—see Event Sourcing. You can do CQRS without ES (CRUD write DB + event/projector to read store), or ES without full CQRS (replay to single materialized view). Together they give audit, replay after projector bug fix, and independent scaling of read tiers—but operational complexity is highest.

💡 Pro Tip

Start simple CQRS in one service before full dual-store. Measure read/write ratio and projector lag—jump to Elasticsearch only when PostgreSQL read replicas or materialized views fail SLO.

Event sourcing — store events, not state

The append-only event log is the source of truth. Current state is derived by folding events—or loaded from a snapshot plus tail events.

Events as source of truth

CRUD updates rows in place—previous values lost unless you bolt on audit tables. Event sourcing persists immutable facts in past tense: OrderCreated, LineItemAdded, OrderPlaced, OrderCancelled. Aggregate reload: fold(empty, events) → current Order state. Deletes become compensating events (LineItemRemoved), never physical DELETE of history.

Replaying events — rebuild, time travel, audit

  • Rebuild read models — projector had bug; fix code, replay log from offset 0 into new Elasticsearch index.
  • Time travel — “account balance at 2024-12-31 23:59” by replaying events until timestamp cutoff.
  • Audit log — regulators and support ask what happened; answer is the event stream, not guessing from current row.
  • Debugging — reproduce production aggregate state in test by replaying same event sequence.

Snapshots — performance optimization

Replaying 80,000 events per aggregate on every command is too slow. Periodic snapshot persists materialized state at sequence N (JSON blob or serialized aggregate). Load path: fetch latest snapshot + events with sequence > N. Tune snapshot frequency: every 100 events vs every 1000—balance storage vs replay CPU on hot aggregates.

Event schema evolution — versioning and upcasting

Events are immutable history—you cannot UPDATE old JSON in the log. Strategies:

  • Upcasting — on read, transform OrderPlacedV1OrderPlacedV2 in memory before apply.
  • Dual schema period — writers emit V2 only; projectors handle both V1 and V2 during migration.
  • Weak schema — optional fields, unknown fields ignored (careful with semantic changes).
  • New stream typeOrderPlacedV2 as separate event type; projectors map both to same read model.

Breaking changes (split currency field into amount + code) need explicit migration plan—replay old events through upcaster in projector tests.

Eventual consistency implications for queries

Commands append to log; read models update asynchronously—queries immediately after command may not see write on read side. Mitigations: return command acceptance with write-model version; confirmation screen reads write aggregate synchronously; client polls read API until version ≥ expected; or synchronous projection for critical path only (defeats some CQRS benefits).

Event stores — EventStore, Axon, Kafka

TechnologyRole
EventStoreDB Purpose-built event database, streams, optimistic concurrency on expected version, subscriptions
Axon Server JVM-centric event store + messaging; tight Axon Framework integration; enterprise support
Apache Kafka Log as event store with topic per aggregate type; compaction retains latest per key for changelog; ops already runs Kafka for integration
PostgreSQL events table with monotonic sequence—pragmatic for moderate volume; use outbox patterns for publish

When event sourcing is overkill

  • Simple CRUD admin entities with no audit requirement
  • Low-value domains where replay and versioning cost exceeds benefit
  • Teams without ops maturity to monitor lag, disk growth, and poison event handling
  • Reporting that only needs nightly batch ETL from current state

Event sourcing shines for: financial ledgers, order lifecycle, collaborative domains with conflict resolution, and systems requiring regulatory audit trails.

🔧 Under the Hood

Optimistic concurrency on aggregate version prevents lost updates: command includes expectedVersion; append fails if stream advanced—client retries or merges.

API composition — querying across services without JOINs

There is no SQL JOIN across microservice databases. API composition gathers data by calling multiple services and merging results in the BFF, gateway, or a dedicated composer service.

The problem

Product detail page needs: title and images from Catalog, price from Pricing, stock from Inventory, reviews from Reviews, shipping estimate from Fulfillment. Each owns a private database—one query cannot span them. Options: composition at request time, or pre-joined read model updated asynchronously.

API Gateway / BFF aggregation

API Gateway (Spring Cloud Gateway, Kong) or BFF fans out parallel HTTP/gRPC calls, merges JSON, handles partial failure (show product without reviews if Reviews is down). See Communication → BFF and API Gateway.

Parallel composition with WebClient
public Mono<ProductPageDto> loadPage(String sku) {
  Mono<ProductDto> product = catalogClient.getProduct(sku);
  Mono<PriceDto> price = pricingClient.getPrice(sku);
  Mono<StockDto> stock = inventoryClient.getStock(sku);
  Mono<ReviewsDto> reviews = reviewsClient.getReviews(sku)
      .onErrorReturn(ReviewsDto.empty());

  return Mono.zip(product, price, stock, reviews)
      .map(t -> ProductPageDto.of(t.getT1(), t.getT2(), t.getT3(), t.getT4()));
}

Downsides: latency ≈ slowest dependency; availability = product of availabilities; composer becomes change hotspot when any downstream API changes. Mitigate with timeouts, circuit breakers (Resilience), caching stable catalog fields, and limiting fan-out depth.

CQRS read model as pre-joined denormalized view

Instead of five REST calls per page view, a projector listens to ProductUpdated, PriceChanged, StockAdjusted and maintains product_page_documents in Elasticsearch or PostgreSQL read table—one query serves the screen. Trade-off: eventual consistency and projector maintenance vs request-time composition simplicity. Hot paths with high QPS almost always move to materialized read models after composition pain appears in production metrics.

⚖️ Trade-off

API composition is fine for low-traffic admin screens and early MVPs. Customer-facing product search at 10k RPS needs CQRS read models, not 10k × 5 fan-out calls per second.

Outbox pattern — solving the dual-write problem

Business code must update the database and publish an integration event. Doing them separately guarantees one will succeed and the other fail— the outbox makes both durable in one local ACID transaction.

The dual-write problem

Naive sequence A: commit order row → publish to Kafka. If broker is down after commit, downstream never sees OrderPlaced—saga stuck, search index stale forever. Sequence B: publish first → DB rollback on error. Consumers saw ghost event for order that does not exist—Inventory reserves phantom stock. There is no distributed atomicity between PostgreSQL and Kafka without outbox or XA (which you rejected).

Outbox table — same transaction as business data

In the same @Transactional method: INSERT into orders AND INSERT into outbox. Either both commit or neither. Relay process publishes from outbox to message broker asynchronously.

Outbox table shape
CREATE TABLE outbox (
  id            UUID PRIMARY KEY,
  aggregate_id  VARCHAR(64) NOT NULL,
  event_type    VARCHAR(128) NOT NULL,
  payload       JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
Spring — same transaction
@Transactional
public OrderId placeOrder(PlaceOrderCommand cmd) {
  Order order = orderRepository.save(Order.create(cmd));
  outboxRepository.save(OutboxEntry.builder()
      .aggregateId(order.id().toString())
      .eventType("OrderPlaced")
      .payload(json.writeValueAsString(OrderPlacedEvent.from(order)))
      .build());
  return order.id();
}

Polling publisher vs Debezium CDC

RelayHow it worksPros / cons
Polling publisher Scheduled job: SELECT ... FROM outbox WHERE published_at IS NULL FOR UPDATE SKIP LOCKED, publish, UPDATE published_at Simple, no extra infra; polling interval adds lag; app owns retry logic
Debezium CDC Reads PostgreSQL WAL via logical replication slot; streams INSERTs on outbox to Kafka topic Low lag, app code thin; ops runs Kafka Connect + connector; schema changes need connector care
sequenceDiagram
  autonumber
  participant App as Order Service
  participant PG as PostgreSQL
  participant RL as Relay or Debezium
  participant KA as Kafka

  rect rgb(30, 40, 70)
    Note over App,PG: Single local transaction
    App->>PG: INSERT orders
    App->>PG: INSERT outbox
    PG-->>App: COMMIT
  end

  RL->>PG: Read new outbox rows
  RL->>KA: Publish OrderPlaced
  RL->>PG: Mark published or offset

Transactional outbox in Spring with Debezium

Typical stack: PostgreSQL with wal_level=logical, Debezium PostgreSQL connector monitoring public.outbox, Kafka topic per event type or single topic with headers. Application only writes business + outbox rows—no KafkaTemplate.send in request thread. Consumer services use idempotent handlers keyed by outbox.id or business idempotency key.

Debezium connector excerpt
{
  "name": "order-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.dbname": "orders",
    "table.include.list": "public.outbox",
    "plugin.name": "pgoutput",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.field.event.key": "aggregate_id"
  }
}

Debezium Outbox Event Router maps event_type column to Kafka topic name and payload envelope— reduces custom relay code. Sagas depend on this reliability: every choreography step and orchestrator command publication should originate from outbox. Deeper relay comparison: Communication → Outbox.

⚠️ Pitfall

Publishing then marking published in a second transaction without idempotent consumers—crash after publish duplicates events. Use outbox row ID as Kafka key + consumer dedup table, or Debezium exactly-once with Kafka transactions where justified.

Choosing the right data pattern

Patterns stack in production systems—they are not mutually exclusive pick-one menus. Use this table to start design conversations.

ProblemPattern
Multi-service write with rollbackOrchestrated or choreographed saga + outbox + idempotent consumers
Reliable integration events after local commitTransactional outbox + Debezium or polling relay
Cross-service read for one screenAPI composition (MVP) → CQRS read model (scale)
Read/write load or shape mismatchSimple CQRS → full CQRS with async projection
Audit, replay, temporal queriesEvent sourcing + snapshots + upcasting
Long-running workflow with human stepsTemporal or orchestrated saga with durable state
Strong consistency everywhereReconsider microservices—or modular monolith with one DB
🎯 Interview Tip

Whiteboard: three services with private DBs, outbox arrow to Kafka, saga for checkout, CQRS read model for search. State what happens when Payment succeeds but Order confirm fails—forward retry vs compensating refund, DLQ for poison messages, manual reconciliation queue.