Skip to content
A dark 3D workbench where five services own separate data stores, duplicate messages are absorbed, and malformed messages are diverted into isolation
Splitting a transaction across services turns delivery and deduplication into explicit operational contracts.

Part 1 kept idempotency, occurrence-time pricing, resumable close, and the immutable ledger in one PostgreSQL database. Part 2 made the Event Store the system of record and explained replay and projection rebuilding.

Now assume pricing, usage acceptance, rating, invoicing, and queries must deploy independently. Five databases alone do not solve the problem. A business effect and the notification that triggers the next service can no longer share one database transaction.

This article uses the bluetape4k-workshop usage-billing microservice composition to explain service ownership, Outbox and Inbox, at-least-once delivery, reordered versions, and poison-event recovery. It does not claim end-to-end exactly once. It ensures that each service retains enough durable evidence to detect and recover from duplicates and partial failure.

Split by data ownership, not by feature count

Section titled “Split by data ownership, not by feature count”

Each service owns its PostgreSQL database and integration decoder. No service reads another service’s tables.

Start with the deployment view. All five services run independently on Spring Boot 4 and a Java 25 runtime, and each uses an Exposed repository to modify only its own PostgreSQL database. Kafka transports published events to downstream services, but it does not own financial records. When delivery stops, each service can still decide processing state from its local Inbox, Outbox, and business records.

Meter, Usage, Billing, Invoice, and Query each own PostgreSQL and exchange events through Kafka topics
The five services share neither an application nor a database. Solid lines publish from local Outboxes, dashed lines deliver to local Inboxes, and the guardrails name the durable records needed after a failure.
ServiceDurable system of recordInputPublished fact
MeterImmutable price versions and command receiptsActivatePriceCommandPriceActivated
UsageUsage facts and accepted price evidencePriceActivated, AcceptUsageCommandUsageAccepted
BillingReplicated price evidence and immutable chargesPriceActivated, UsageAcceptedChargeRated, AdjustmentPosted, BillingPeriodClosed
InvoiceImmutable invoice lines and source-event linksChargeRated, AdjustmentPostedInvoiceIssued, InvoiceCorrectionIssued
QueryInbox, projections, checkpoints, quarantine, redrive auditPublic events from the other servicesTenant summaries and recovery diagnostics

Meter owns original prices, but Billing does not query Meter synchronously while rating usage. It consumes PriceActivated, stores local evidence, and rates only from that evidence. This prevents a temporary network fault from enclosing the entire billing operation, while adding responsibility for delayed or missing replicas.

The interactive visualization shows each service’s Inbox, Outbox, and recovery steps in more detail.

Commit the local effect and Outbox together

Section titled “Commit the local effect and Outbox together”

Writing local state and then failing to send to Kafka leaves the next service unaware of a successful operation. Sending first and then rolling back local state publishes a fact that never existed. The Outbox closes this gap by committing the business effect and its event in one local transaction.

transaction {
receipt = commandReceipts.acquire(command.idempotencyKey, command.fingerprint)
if (receipt is Replay) return receipt.savedResult
priceVersion = prices.append(command)
envelope = PriceActivated.from(priceVersion)
outbox.append(envelope, state = PENDING)
commandReceipts.complete(receipt.ownerToken, resultFor(priceVersion))
}

The publisher claims a PENDING row with a lease and owner token, sends it, and records PUBLISHED only with that token. A transport failure moves it to RETRY_WAIT. If Kafka accepts the event immediately before the process stops, the local row may remain CLAIMED; its lease expires and the same event is sent again. Duplicate delivery is the normal recovery path.

Inbox distinguishes a duplicate from a conflict

Section titled “Inbox distinguishes a duplicate from a conflict”

A consumer decides (tenantId, eventId, payloadDigest) before changing business data.

transaction {
decision = inbox.decide(
tenantId = envelope.tenantId,
eventId = envelope.eventId,
payloadDigest = envelope.payloadDigest,
aggregateVersion = envelope.aggregateVersion,
)
when (decision) {
APPLIED -> {
localEffect.apply(envelope)
outbox.append(nextEvent(envelope))
}
DUPLICATE -> Unit
DEFERRED -> scheduleAfterMissingVersion(envelope)
QUARANTINED -> recordPermanentFailure(envelope)
}
}

The same ID and digest is DUPLICATE; it can complete successfully without applying money twice. The same ID with a different digest is a correctness conflict: either the producer reused an identity for different content or the payload changed in transit. It must be QUARANTINED.

Inbox resultMeaningNext action
APPLIEDFirst valid event with all prerequisitesCommit the local effect and next Outbox event together
DUPLICATESame ID and digest, or an already applied older versionFinish without creating new money
DEFERREDPrice evidence or a prior aggregate version is missingRetry after the prerequisite arrives
QUARANTINEDSame ID with another digest, or a permanent contract errorIsolate it from healthy traffic and record an operator case

Wait for reordered versions instead of guessing

Section titled “Wait for reordered versions instead of guessing”

Kafka can preserve order for a key within a partition, but it cannot prove ordering across every redrive, topic migration, and operator action. When aggregate version 2 reaches Billing before version 1, the example returns DEFERRED; it does not rate with guessed state.

After the missing version arrives, the same Inbox record can be decided again. Skipping a version or rating with the current price removes the evidence needed to determine which amount is correct later. Observe aggregate key, expected version, received version, and gap age together.

Route transient faults and permanent contract errors differently

Section titled “Route transient faults and permanent contract errors differently”

If every exception is retried through Kafka, one unprocessable event can block a partition forever. If every exception is quarantined, a temporary database outage becomes a manual incident.

FailureClassificationAutomatic protectionRecovery
Kafka send failure or temporary database errorTransientPreserve Outbox as RETRY_WAIT or propagate for redeliveryRetry the same event from local durable state
Service process restartTransientPreserve Inbox, Outbox, pricing evidence, and checkpoint in PostgreSQLResume after lease expiry from persisted state
Aggregate version gapOrdering waitBlock rating as DEFERREDRe-evaluate after the prior version arrives
Unsupported event type or schema, missing required fieldPermanent contract errorQuarantine the event while unrelated valid records continueRepair decoder, upcaster, or producer contract, then redrive
Same event ID with another digestConsistency conflictIsolate before applying the business effectCompare the producer record with an external retained source

Query’s redrive API does not edit or recreate the source event. It records the operator, correlation ID, and requested action. An external retained source must provide the immutable original envelope for actual replay. This prevents a “recovery” from rewriting a financial fact.

An empty Outbox after broker connectivity returns is insufficient. Verify the complete boundary:

  1. Identify the affected topics, tenants, aggregate keys, and event IDs.
  2. Inspect each service’s Outbox backlog, oldest retry, Inbox result, and quarantine reason.
  3. Retry transient faults with the existing Outbox row and envelope; isolate permanent faults from healthy events.
  4. Verify that every deferred version gap closes and checkpoints reach the target position.
  5. Reconcile Usage facts, Billing charges, Invoice lines, and Query totals by source event ID.
  6. Declare recovery complete only after quarantine and redrive audit are closed and new lag remains within its target.

Do not delete Outbox rows to make the backlog zero, edit Inbox state to APPLIED, or mutate charges and invoice lines. When money needs correction, append AdjustmentPosted and a new invoice correction line linked to the original event.

Microservices are close to the last option

Section titled “Microservices are close to the last option”

If independent deployment, ownership, and scaling boundaries are not required, Part 1’s single PostgreSQL ledger is simpler. If historical replay and multiple projections matter without service separation, Part 2 may be the better fit.

Five services require the team to operate topics, lag, schema compatibility, Outbox, Inbox, quarantine, redrive, and cross-service reconciliation. Distribution does not justify that cost by itself. Choose it when the organization truly owns, deploys, and recovers those data boundaries independently.

Part 4 will combine the failure handling from all three architectures into one operational runbook: what to retry, what to isolate, when to replay, rebuild, or adjust, and which evidence permits traffic to resume.

Comments

Leave a note or reaction with your GitHub account.