Outbox, Sagas & CQRS
A BoxOffice purchase touches three services that each own their data: order (the order), inventory (the seats) and payment (the charge, through an external provider). In a monolith with one database, the purchase would be one ACID transaction: hold the seats, charge, confirm β all or nothing. Split into services, there is no single database and no single transaction. The classic answer, a distributed transaction with two-phase commit (2PC), does not fit either: the payment provider will not join your transaction, and 2PC blocks every participant while a coordinator is unreachable.
This page collects the patterns that replace the distributed transaction in event-driven systems. Each one answers a precise question:
| Question | Pattern |
|---|---|
| How do I update my database and publish an event without losing either? | Transactional outbox |
| How do I turn changes in a database into a stream of events? | Change data capture |
| How do I run a business transaction that spans several services? | Saga |
| How do I serve queries that need data shaped differently from the writes? | CQRS |
| How do I keep the full history as the source of truth? | Event sourcing |
The dual-write problem
The simplest way to publish OrderConfirmed is to save the order and then send the event. That is two writes to two different systems β a dual write β and no transaction covers both:
sequenceDiagram
participant O as order
participant DB as order DB
participant K as Kafka
O->>DB: BEGIN Β· UPDATE orders SET status = 'CONFIRMED' Β· COMMIT
Note over O: pod killed by a deploy
O--xK: publish OrderConfirmed (never sent)
Note over DB,K: the order is confirmed, but nobody is told: no e-mail, no ticket, no analytics Swapping the order of the writes does not help:
| Order of writes | Crash between them | Result |
|---|---|---|
| DB commit, then publish | after the commit | Order confirmed, event lost (Bruno's missing e-mail) |
| Publish, then DB commit | after the publish | Event says "confirmed", but the transaction rolled back: phantom event |
| Publish inside the DB transaction | broker slow or down | The database transaction waits on the network; the broker's outage becomes your outage |
Transactional outbox
The transactional outbox1 removes the dual write by writing the event into the same database, in the same local transaction, as the business change. A separate relay later reads the outbox table and publishes to the broker.
sequenceDiagram
participant O as order
participant DB as order DB
participant R as outbox relay
participant K as Kafka
O->>DB: BEGIN
O->>DB: UPDATE orders SET status = 'CONFIRMED'
O->>DB: INSERT INTO outbox (OrderConfirmed)
O->>DB: COMMIT (both rows or neither)
R->>DB: SELECT unpublished rows
R->>K: publish OrderConfirmed (key o-7731)
K-->>R: ack
R->>DB: mark row as published The local transaction guarantees that the order and its event exist together or not at all. The relay guarantees that every committed event is eventually published β at least once: if it crashes after publishing but before marking the row, it publishes the row again on restart. Consumers must be idempotent anyway (Delivery semantics).
The outbox table:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY, -- publication order
event_id UUID NOT NULL UNIQUE, -- consumers deduplicate on it
aggregate_type TEXT NOT NULL, -- 'order'
aggregate_id TEXT NOT NULL, -- 'o-7731', becomes the Kafka key
type TEXT NOT NULL, -- 'OrderConfirmed'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;
The business code writes both rows in one transaction:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
@Transactional
public void confirm(String orderId) {
Order order = orders.findByIdForUpdate(orderId);
order.confirm(); // PENDING -> CONFIRMED
orders.save(order);
outbox.save(OutboxEvent.of("order", order.id(), "OrderConfirmed",
OrderConfirmed.from(order))); // same transaction
}
}
The relay has two common implementations:
A scheduled job reads unpublished rows in order, publishes them and marks them. FOR UPDATE SKIP LOCKED lets a second relay instance take over safely if the first one dies.
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<OutboxRow> rows = jdbc.query("""
SELECT id, aggregate_id, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED
""", OutboxRow.MAPPER);
for (OutboxRow row : rows) {
kafka.send("order-events", row.aggregateId(), row.payload()).join(); // wait for the ack
jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", row.id());
}
}
Simple and portable, but it adds polling load on the database and up to one polling interval of latency. Run a single active relay (or partition the work by aggregate) so events of the same order stay in order, and delete published rows after a few days.
Debezium reads the database's replication log and publishes each new outbox row; nothing polls the table. Its outbox event router turns rows into events on the right topic with the right key.
{
"name": "order-outbox",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "order-db",
"database.port": "5432",
"database.user": "debezium",
"database.password": "debezium",
"database.dbname": "order",
"topic.prefix": "order",
"plugin.name": "pgoutput",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "event_id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "${routedByValue}-events"
}
}
Lower latency and no polling, at the price of running Kafka Connect and Debezium. Rows can be deleted right after insertion: the log already captured them.
In the BoxOffice code: the outbox in order (lab 7)
Hands-on 7 β Transactional outbox builds the polling publisher. place() inserts the order and, in the same @Transactional method, appends OrderPlaced to the outbox (Outbox.append is Propagation.MANDATORY: it fails if anyone calls it outside a transaction). NOTIFY_MODE=sync keeps the "before" version, which calls notification over HTTP, so you can measure both. The relay sends a batch of up to 100 rows, waits for every Kafka ack and only then marks the rows: a crash in between republishes them (at least once), never loses them. Note one difference from the advice above: several order replicas may relay in parallel (SKIP LOCKED), which is safe here because each order produces a single event so far; with more events per order, per-order ordering would need a single relay or partitioning by aggregate. In a test run on a busy laptop, POST /orders p50 went from 4.12 s (sync) to 47 ms (outbox), and stopping notification for 20 s failed about a third of the orders in sync mode and none with the outbox (Measure it).
@Transactional
public PlaceResult place(OrderRequest req) {
int inserted = jdbc.sql("""
INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
ON CONFLICT (hold_id) DO NOTHING
""")
.params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
.update();
OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
.param(req.holdId())
.query(OrderView.class)
.single();
if (inserted == 0) {
// same hold already ordered: idempotent replay, no second event
return new PlaceResult(order, false);
}
OrderEvent event = OrderEvent.placed(order);
if (props.notifyMode() == NotifyMode.SYNC) {
notifications.send(event); // BEFORE: the fan waits for the e-mail
} else {
outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
}
return new PlaceResult(order, true);
}
@Scheduled(fixedDelayString = "${boxoffice.order.relay-interval-ms:200}")
@Transactional
public void relay() throws Exception {
// SKIP LOCKED: several order replicas can relay in parallel without taking the same rows
List<Pending> batch = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload::text AS payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""")
.query(Pending.class)
.list();
if (batch.isEmpty()) {
return;
}
List<CompletableFuture<SendResult<String, String>>> acks = new ArrayList<>();
for (Pending p : batch) {
ProducerRecord<String, String> record =
new ProducerRecord<>(topic, p.aggregateId().toString(), p.payload());
record.headers().add("eventId", p.id().toString().getBytes(StandardCharsets.UTF_8));
record.headers().add("eventType", p.eventType().getBytes(StandardCharsets.UTF_8));
acks.add(kafka.send(record));
}
// wait for every ack; on failure the transaction rolls back and the rows are retried
CompletableFuture.allOf(acks.toArray(new CompletableFuture<?>[0])).get(10, TimeUnit.SECONDS);
jdbcTemplate.batchUpdate("UPDATE outbox SET published_at = clock_timestamp() WHERE id = ?",
batch.stream().map(p -> new Object[] {p.id()}).toList());
log.info("relayed {} event(s) to {}", batch.size(), topic);
}
Change data capture
Every relational database keeps a replication log β PostgreSQL's write-ahead log (WAL), MySQL's binlog β that records every committed change in commit order; that is how replicas stay in sync (see Replication). Change data capture (CDC) reads that log and turns each change into an event2. The outbox relay above is one use of CDC; there are many others.
flowchart LR
cat["catalog"] -->|"INSERT / UPDATE events"| db[("catalog DB<br/>PostgreSQL")]
db -->|"WAL (logical decoding)"| dbz["Debezium"]
dbz -->|"catalog.public.events"| k{{"Kafka"}}
k --> s["search indexer"]
k --> c["cache invalidator"]
k --> w["warehouse loader"] For BoxOffice, CDC on the catalog database keeps three derived systems in sync without changing the catalog code: the search index (new shows appear in search seconds after being created), the cache (entries are invalidated when an event changes; see Caching) and the data warehouse.
| Transactional outbox (with CDC or polling) | Raw CDC on business tables | |
|---|---|---|
| What is published | Events designed for consumers (OrderConfirmed) | Row changes (orders row 7731: status P β C) |
| Contract | An explicit, versioned event schema | The table schema β a refactoring of the table breaks consumers |
| Code changes in the service | Write the outbox row | None |
| Best for | Public domain events between services | Internal derived data: search, cache, warehouse, migrations |
Raw CDC leaks your schema
Publishing raw table changes makes your internal table layout a public API: renaming a column becomes a breaking change for consumers you may not know about. Use raw CDC for data you own end to end (your own search index, your warehouse), and an outbox with designed events for everything other teams consume.
Sagas
A saga is a business transaction implemented as a sequence of local transactions, one per service; each step publishes an event or reply that triggers the next. If a step fails, the saga runs compensating transactions that semantically undo the steps already completed31. The idea comes from a 1987 database paper about long-lived transactions β older than microservices by decades.
The BoxOffice purchase saga
| Step | Service | Local transaction | Compensation | Kind |
|---|---|---|---|---|
| 1 | order | Create order PENDING | Mark order CANCELLED | compensatable |
| 2 | inventory | Hold seats B-12, B-13 (hold expires in 10 min) | Release the seats | compensatable |
| 3 | payment | Charge the card through the provider (idempotency key = order id) | Refund | pivot |
| 4 | inventory | Mark seats SOLD | β | retriable |
| 5 | order | Mark order CONFIRMED, publish OrderConfirmed | β | retriable |
The pivot is the go/no-go point. Before it, every step can be compensated; after it, the saga never goes back β the remaining steps are retriable: they must eventually succeed, retried as many times as needed. That is why the seat hold must outlive the payment: if the hold could expire while the card is being charged, step 4 could fail permanently after the pivot and force a refund β a bad experience the design should make impossible (or at least rare and monitored).
There are two ways to coordinate the steps.
No central coordinator: each service reacts to the others' events.
sequenceDiagram
participant O as order
participant I as inventory
participant P as payment
O->>I: OrderPlaced
I->>I: hold seats
I->>P: SeatsHeld
P->>P: charge card
alt payment succeeded
P->>I: PaymentCaptured
I->>I: mark seats SOLD
I->>O: SeatsSold
O->>O: CONFIRMED, publish OrderConfirmed
else payment failed
P->>I: PaymentFailed
I->>I: release seats (compensation)
I->>O: SeatsReleased
O->>O: CANCELLED (compensation)
end A saga orchestrator (here, inside order) sends commands and reacts to replies. The whole flow is in one place.
sequenceDiagram
participant S as purchase saga (order)
participant I as inventory
participant P as payment
S->>I: HoldSeats
I-->>S: SeatsHeld
S->>P: ChargeCard (key o-7731)
alt charged
P-->>S: PaymentCaptured
S->>I: MarkSeatsSold
I-->>S: SeatsSold
S->>S: CONFIRMED, publish OrderConfirmed
else declined
P-->>S: PaymentFailed
S->>I: ReleaseSeats (compensation)
I-->>S: SeatsReleased
S->>S: CANCELLED
end The order's status is the saga's state, so the saga is a state machine:
stateDiagram-v2
[*] --> PENDING
PENDING --> SEATS_HELD : SeatsHeld
PENDING --> CANCELLED : NoSeats
SEATS_HELD --> PAID : PaymentCaptured (pivot)
SEATS_HELD --> RELEASING : PaymentFailed / timeout
RELEASING --> CANCELLED : SeatsReleased
PAID --> CONFIRMED : SeatsSold (retried until success)
CONFIRMED --> [*]
CANCELLED --> [*] A minimal orchestrator handles each reply in a local transaction, moves the state machine and sends the next command through the outbox:
@Component
@RequiredArgsConstructor
public class PurchaseSaga {
private final SagaRepository sagas;
private final CommandPublisher commands; // writes commands to the outbox
@Transactional
public void handle(SagaReply reply) {
PurchaseState s = sagas.lock(reply.orderId()); // SELECT ... FOR UPDATE
if (s.alreadyApplied(reply.replyId())) return; // duplicate reply: ignore
switch (s.step()) {
case HOLDING_SEATS -> {
if (reply.ok()) { s.moveTo(CHARGING); commands.send(new ChargeCard(s.orderId(), s.total())); }
else { s.moveTo(CANCELLED); commands.send(new CancelOrder(s.orderId())); }
}
case CHARGING -> {
if (reply.ok()) { s.moveTo(CONFIRMING); commands.send(new MarkSeatsSold(s.orderId())); }
else { s.moveTo(RELEASING); commands.send(new ReleaseSeats(s.orderId())); }
}
case CONFIRMING -> { s.moveTo(CONFIRMED); commands.send(new ConfirmOrder(s.orderId())); }
case RELEASING -> { s.moveTo(CANCELLED); commands.send(new CancelOrder(s.orderId())); }
default -> throw new IllegalStateException("unexpected reply in " + s.step());
}
sagas.save(s.applied(reply.replyId()));
}
}
| Choreography | Orchestration | |
|---|---|---|
| Coordination | Each service reacts to events | One orchestrator sends commands and tracks state |
| Coupling | Services know each other's events | Participants only know their commands; the orchestrator knows the flow |
| Visibility of the flow | Spread over several codebases; hard to answer "where is order o-7731?" | One state machine; easy to inspect, test and change |
| Risk | Cyclic dependencies, "event spaghetti" as steps grow | The orchestrator can become a god service holding business logic |
| Good for | 2β3 steps, simple flows | 4+ steps, timeouts, branches β like the purchase |
In production, orchestrated sagas are often written on a durable execution / workflow engine β Temporal, AWS Step Functions, Azure Durable Functions, Camunda β which persists the state after each step, handles timers ("release the hold if no payment in 10 minutes") and retries, and survives restarts of the process running it.
Sagas are not transactions
A saga is ACD without I: it is atomic (eventually all steps or all compensations), consistent and durable, but not isolated. Other requests see intermediate states β seats held but not sold, an order paid but not yet confirmed. Design for it:
- Semantic lock: the intermediate state is explicit and respected by everyone. Seats in
HELDare not offered to other fans; the order page says "processing payment". - Compensations are business actions, not rollbacks. A refund is a new transaction with its own trace in the books (and a notification to the fan), not an erase.
- Every step and every compensation is idempotent, because commands and events are delivered at least once.
- Every step has a deadline. If the payment provider does not answer, do not compensate blindly β the charge may have succeeded. Query the provider (by idempotency key) and reconcile first (see Integrations).
BoxOffice: what the fan sees during the saga
The purchase API answers 202 Accepted with the order id right after step 1. The page shows "Seats held β processing payment" (semantic lock) and listens for the outcome (polling GET /orders/o-7731 or a server-sent event). Most sagas finish in about a second, so the fan barely notices; when the provider is slow, the page keeps an honest status instead of a spinner that ends in a timeout.
CQRS
Command Query Responsibility Segregation (CQRS) separates the model that handles writes (commands) from the models that serve reads (queries)4. The write model is optimized for correctness and invariants (normalized tables, transactions, locks); each read model is a denormalized projection shaped for one screen or query, built from the write side's events.
flowchart LR
fan["fan"] -->|"POST /orders (command)"| order["order<br/>write model"]
order --> odb[("order DB<br/>normalized")]
order -->|"outbox: order events"| k{{"Kafka"}}
k --> p1["my-tickets projector"]
k --> p2["search projector"]
p1 --> v1[("my_tickets view<br/>one row per ticket")]
p2 --> v2[("search index")]
fan -->|"GET /me/tickets (query)"| v1 The "My tickets" page needs, for one fan, every ticket with show name, date, venue, seat and QR code β data owned by order, catalog and inventory. Joining across three services on every page view is slow and fragile. Instead, a projector consumes their events and maintains a my_tickets table with exactly the columns the page needs. Reads become a single indexed query, and the read side scales independently (replicas, cache, even a different database).
The price is eventual consistency between write and read: right after buying, the fan opens "My tickets" and the new ticket is not there yet, because the projector is a few hundred milliseconds behind. Common fixes for this read-your-writes problem (see Consistency):
- Return the new state in the command's response and let the client show it immediately.
- Pass a version token: the client sends "I need at least version 42" and the read side waits briefly (or falls back to the write model) until it has it.
- Read the user's own recent writes from the write model, everything else from the projection.
CQRS is not a default
CQRS adds moving parts: projectors, more storage, lag to monitor, rebuild procedures. Use it where reads and writes truly differ in shape or scale (BoxOffice's catalog browsing, "My tickets", search), not for every CRUD entity.
Event sourcing
In most systems the database stores the current state and events are a side product. Event sourcing inverts this: the sequence of events is the source of truth, and the current state is computed by replaying them5.
CREATE TABLE order_events (
order_id TEXT NOT NULL,
version INT NOT NULL, -- 1, 2, 3... per order
type TEXT NOT NULL, -- 'OrderPlaced', 'SeatsHeld', ...
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (order_id, version) -- two writers cannot both append version 4
);
public record Order(String id, OrderStatus status, List<String> seats, long totalCents) {
public static Order replay(List<OrderEvent> history) {
Order order = null;
for (OrderEvent e : history) { // oldest first
order = switch (e) {
case OrderPlaced p -> new Order(p.orderId(), PENDING, p.seats(), p.totalCents());
case SeatsHeld h -> order.with(SEATS_HELD);
case PaymentCaptured c -> order.with(PAID);
case OrderConfirmed c -> order.with(CONFIRMED);
case OrderCancelled c -> order.with(CANCELLED);
};
}
return order;
}
Order with(OrderStatus s) { return new Order(id, s, seats, totalCents); }
}
To change an order, the service loads its events, rebuilds the state, checks the business rules, and appends new events with the next version; the primary key turns a concurrent append into a conflict (optimistic concurrency). Long histories are loaded from a snapshot plus the events after it. Read models are projections, as in CQRS β which is why the two patterns usually appear together.
| Benefits | Costs |
|---|---|
| Complete audit trail: why and when every change happened | Events are a permanent contract: every version must stay readable forever |
| Temporal queries: "what did this order look like at 10:03?" | Queries need projections; "all orders over R$ 1,000" is not a simple SELECT |
| Debugging by replay; new read models built from history | Unfamiliar to most teams; easy to get wrong |
| Natural fit for event-driven integration | Deleting personal data (LGPD/GDPR) in an immutable log needs care (e.g. encrypt per customer and delete the key) |
Where event sourcing fits in BoxOffice
Good candidates: the payment ledger (money must be auditable, and an append-only ledger is how accounting already works) and the order history. Bad candidates: the event catalog (a plain table with CDC is enough) and the seat map during an on-sale (it needs fast, locked updates of current state, not a growing history).
Choosing the patterns
| Pattern | Solves | Costs | BoxOffice use |
|---|---|---|---|
| Transactional outbox | Lost or phantom events on publish | An extra table and a relay; at-least-once publication | Every service that publishes domain events |
| Change data capture | Keeping derived systems in sync with a database | Kafka Connect/Debezium to run; schema coupling if raw | Catalog β search, cache invalidation, warehouse |
| Saga | Business transactions across services | No isolation; compensations to design; harder to debug | Purchase: order β hold β payment β confirm |
| CQRS | Reads shaped differently from writes, or at a different scale | Projections, lag, read-your-writes handling | "My tickets", catalog browsing |
| Event sourcing | Full history as the source of truth | Event versioning forever; projections for every query | Payment ledger, order history |
In the interview
For any flow that crosses services and moves money or inventory, interviewers expect you to say "saga" and to make it concrete: list the steps with their compensations, name the pivot (the payment), explain what the user sees in the intermediate state (seats held, payment processing) and how timeouts are handled (reconcile with the provider before compensating). Publishing events? Say "transactional outbox" before they ask about the crash between the commit and the publish. Mention CQRS or event sourcing only when a requirement calls for them (audit, very different read shapes), and say what they cost.
Best practices
- Make the outbox write impossible outside the business transaction β a helper that requires an existing transaction fails in tests instead of losing events in production. In BoxOffice:
Outbox.appendis@Transactional(propagation = MANDATORY)(outbox box). - Alert on the age of the oldest unpublished outbox row β the relay runs every 200 ms, so a row older than about 30 s means the relay or the broker is stuck, long before a fan complains.
- Purge published outbox rows on a schedule and index the unpublished ones β otherwise the table grows by one row per event forever and the relay's query slows down. In BoxOffice:
OutboxRelay.purgedeletes rows published more than 1 day ago, every hour. - Persist the saga's state and move it only with conditional updates β
UPDATE orders SET status = 'CONFIRMED' WHERE id = ? AND status = 'PENDING_PAYMENT': a late or duplicated event cannot move a finished order back. - Order saga steps so the irreversible one comes last β what can be undone (the seat hold) before the pivot (the charge), what cannot be undone (issuing the ticket) after it.
- Keep CQRS read models rebuildable, and rehearse the rebuild β keep the events (or the CDC stream) long enough to replay a projection from scratch, and time how long it takes.
Common pitfalls
- Dual writes "because the crash window is tiny" β at BoxOffice's volume, tiny windows happen every day.
- Compensations that are not idempotent β a duplicated
ReleaseSeatsmust not release seats someone else has since bought (release only seats held by this order). - Compensating on timeout without reconciling β refunding (or cancelling) a payment that actually succeeded, or leaving a charged customer without tickets.
- No deadline on saga steps β orders stuck in
PENDINGforever, seats held forever. - Raw CDC as an integration contract between teams.
- Event sourcing everything β most data is fine as current state plus an outbox.
How it connects
flowchart LR
DW["dual write:<br/>database + broker"] --> OUT["transactional outbox<br/>one local transaction"]
OUT --> RELAY["relay: polling or<br/>Debezium"]
RELAY --> K["kafka topic"]
WAL["WAL / binlog<br/>β Databases replication"] --> CDC["change data capture"]
CDC --> RELAY
CDC --> DER["search index, cache,<br/>warehouse"]
K --> SAGA["saga: local transactions<br/>+ compensations"]
SAGA --> PIVOT["pivot = payment<br/>β Integrations"]
SAGA --> ISO["no isolation:<br/>semantic locks, deadlines"]
SAGA --> TPC["2PC vs sagas<br/>β Distributed transactions"]
K --> CQRS["CQRS projections<br/>read-your-writes"]
ES["event sourcing:<br/>events are the truth"] --> CQRS Palette components: sql (business rows + outbox, WAL for CDC) Β· kafka Β· stream Β· search (CDC-fed index) Β· cache (CDC invalidation) Β· dwh (CDC-fed warehouse) Β· payment (the saga's pivot). Scenarios: payment-system, flash-sale. Continues in: Distributed β transactions (2PC vs sagas), Databases β replication (the log CDC reads), Caching β invalidation from CDC, Compute β integrations, Case study: payment system, Hands-on 7 β outbox, Exercise β event-driven.
-
RICHARDSON, C. Microservices Patterns. Manning, 2018. See also microservices.io β Transactional outbox, Saga, CQRS, Event sourcing.Β β©β©
-
Debezium Documentation β Outbox Event Router; KLEPPMANN, M.; RICCOMINI, C. Designing Data-Intensive Applications, 2nd ed. O'Reilly. Section on change data capture.Β β©
-
GARCIA-MOLINA, H.; SALEM, K. Sagas. In: Proceedings of the ACM SIGMOD International Conference on Management of Data, 1987, p. 249β259.Β β©
-
FOWLER, M. CQRS. martinfowler.com, 2011; YOUNG, G. CQRS Documents, 2010.Β β©
-
FOWLER, M. Event Sourcing. martinfowler.com, 2005.Β β©