How Do Microservices Share Data If They Don’t Share a Database?
A complete, beginner-to-production walkthrough of every real pattern used to move data between independent microservices — synchronous APIs, event-driven messaging, sagas, CQRS, event sourcing, change data capture, and the outbox pattern — explained with diagrams, Java code, and the same techniques used inside Netflix, Amazon, Uber, and modern fintech platforms.
Introduction
Why the modern industry decided that microservices must not share a database — and what that immediately forces us to solve.
Imagine a school with many classrooms. Each classroom has its own teacher, its own attendance register, and its own set of rules. The Math teacher doesn’t walk into the Science classroom and rewrite the Science attendance register. If the Math teacher needs to know something about a student’s Science marks, they ask the Science teacher — they don’t reach into the Science teacher’s private notebook.
This is exactly the situation modern software systems find themselves in. A single big application used to be like one giant classroom where every teacher shared one notebook. That was simple, but it caused chaos when the school grew to thousands of students. So schools split into departments. Software did the same thing — it split into microservices.
Each microservice is a small, independent piece of software that owns one job and one private database. No other service is allowed to touch that database directly. This is called the Database-per-Service pattern, and it is the foundation of everything you will learn in this tutorial.
But this immediately creates a very real, very practical question — the one this entire tutorial is built to answer:
The central question
If the Order service cannot look inside the Inventory service’s database, how does it know whether an item is in stock? If every service is an island, how does the whole system still work together as one coherent product?
By the end of this tutorial, you will understand every major answer to that question — from simple API calls, to event-driven messaging, to advanced patterns like Sagas, CQRS, Event Sourcing, and Change Data Capture — with diagrams, Java code, and real production examples from companies like Netflix, Amazon, and Uber.
1.1 A short history: why we ended up here
In the early 2000s, most companies built monoliths — one large application, one large shared database. It was simple to build and simple to query, because every table sat right next to every other table. You could write one SQL join and get any answer you wanted.
But as companies like Amazon and Netflix grew from thousands to hundreds of millions of users, the monolith became a bottleneck. One small bug in the “reviews” module could crash the entire website, including checkout. One team’s database migration could block every other team. Deploying a one-line fix meant redeploying the entire application, tested by hundreds of engineers who all touched the same codebase.
Amazon famously moved to a “two-pizza team” model around the mid-2000s: small, independent teams, each owning a small service and its own data. Netflix followed with a large-scale migration to microservices between 2009 and 2012, forced partly by a major database corruption incident that made them rethink single points of failure. This is where the modern microservices movement, and the data-sharing problem we’re solving today, really took shape.
The Problem & Motivation
Why we deliberately gave up the convenience of one shared database — and what “sharing data” actually means in a microservices world.
2.1 Why not just share one database?
It seems easier, right? One database, every service reads and writes to it, no need for all these fancy patterns. This is exactly what early distributed systems tried — and exactly what caused serious pain. Let’s understand why with a simple, real-life analogy.
Real-life analogy: the shared roommate diary
Picture five roommates sharing one single shared diary for grocery shopping, bills, chores, and guest lists — all in the same notebook. Anyone can flip to any page and scribble on it. One roommate’s messy handwriting confuses another. Two roommates write on the same page at the same time and one entry gets overwritten. If the diary goes missing, nobody can do anything. That is a shared database in a microservices world.
Here are the concrete engineering problems a shared database creates:
- Tight coupling: If the Inventory team changes a column name, the Order service, the Shipping service, and the Analytics service might all break at the same time, even though nobody told them.
- No independent deployment: You can’t safely deploy Inventory changes without coordinating with every other team that touches the same tables.
- No independent scaling: If the Payments table is under heavy load, the whole database — including tables used by unrelated services — slows down.
- Single point of failure: One giant database going down takes every service down with it.
- Technology lock-in: Every service is forced to use the same database engine, even if a different service would work better with a different one (e.g., a graph database for a recommendation engine vs. a relational database for orders).
- Unclear ownership: When ten teams can write to the same table, nobody truly owns the data’s correctness.
So the industry made a trade: give up the convenience of “just JOIN the tables” in exchange for independence, resilience, and the ability to scale teams and systems separately. That trade is the entire reason this tutorial exists — because giving up shared databases means we now need new ways to move data between services.
2.2 What “sharing data” actually means
When we say microservices need to “share data,” we usually mean one of three things:
- Querying: Service A needs a piece of information that lives in Service B’s database right now (e.g., “what is this product’s current price?”).
- Reacting: Service A needs to know when something changes in Service B (e.g., “tell me the moment an order is placed”).
- Coordinating: Multiple services need to agree on a multi-step business process that spans all of them (e.g., “reserve stock, charge the card, and schedule shipping — and if any step fails, undo the previous ones”).
Each of these three needs is solved by a different family of patterns, which is exactly how this tutorial is organized.
Core Concepts You Must Know First
The vocabulary you need before anything else makes sense. Read this section slowly — every later section leans on it.
3.1 Database-per-Service
What it is: Every microservice owns exactly one database (or schema), and no other service is allowed to access it directly — not even to read.
Why it exists: It enforces a hard boundary so services can change their internal data model freely without breaking others.
Simple analogy: Each department in a company has its own locked filing cabinet. If HR wants a Finance document, they call Finance and ask for a copy — they don’t pick the lock.
3.2 Bounded Context
What it is: A concept from Domain-Driven Design (DDD) describing the boundary within which a particular business model and its terms have one consistent meaning. Inside the “Orders” bounded context, a “Customer” might just be a name and shipping address. Inside the “Support” bounded context, a “Customer” might include ticket history and satisfaction scores. Same word, different meaning, different service.
Why it exists: It stops teams from trying to build one giant “Customer” object that tries to satisfy every team at once, which always ends up bloated and fragile.
Practical example: Amazon’s Order service doesn’t need to know a customer’s browsing history — that belongs to the Recommendations service’s bounded context.
3.3 Coupling vs. Autonomy
What it is: Coupling measures how much one service depends on the internal details of another. Autonomy measures how independently a service can be built, deployed, and scaled.
Why it exists: The entire point of microservices is to trade some convenience for high autonomy and low coupling. Every data-sharing pattern in this tutorial is really just a different way of managing this trade-off.
3.4 Consistency: Strong vs. Eventual
Strong consistency means that the moment data is written, every subsequent read sees that exact value — like a single shared bank ledger where everyone sees the same balance instantly.
Eventual consistency means that after a write, it may take a short time (milliseconds to seconds, sometimes longer) before every part of the system reflects the new value — but it is guaranteed to get there eventually, assuming no permanent failures.
Real-life analogy: scoreboard vs. word of mouth
Strong consistency is like a live scoreboard at a cricket match — the moment a run is scored, everyone watching sees it instantly. Eventual consistency is like the news of that run spreading by word of mouth in a stadium — everyone will know within a few seconds, but not in the exact same instant.
Most microservice data-sharing patterns embrace eventual consistency because demanding strong consistency across independent databases is slow, fragile, and sometimes provably impossible at the same time as availability (see the CAP Theorem section later).
3.5 Idempotency
What it is: An operation is idempotent if doing it once, or doing it five times by accident, produces the same result.
Why it exists: In distributed systems, messages can be delivered more than once due to retries and network failures. If “charge the customer $50” isn’t idempotent, a retried message could charge them $250.
Practical example: Instead of “add $50 to balance” (not idempotent), design it as “set balance after transaction #4471 to $450” (idempotent) or track processed transaction IDs and skip duplicates.
Architecture Overview
The big picture before we zoom into individual patterns.
Before diving into individual patterns, let’s see the whole picture. Below is a simplified e-commerce system with several services, each owning its own database, connected by both direct API calls and an event bus.
Notice two very different communication styles in this diagram:
- A direct REST call from Order to Inventory (synchronous) — used when the caller cannot proceed until it gets a fresh answer.
- An event published to a bus that Payment, Shipping, and Notification all react to independently (asynchronous) — used when the caller only needs to announce that something happened, and doesn’t care who listens.
These are the two fundamental building blocks that every other pattern in this tutorial is built from.
Pattern 1: Synchronous APIs (Request / Response)
The simplest pattern — and the easiest to get wrong at scale.
What it is: One service directly calls another service’s API (usually REST or gRPC) and waits for a response before continuing.
Why it exists: Sometimes you need an immediate, up-to-date answer right now — you cannot proceed without it. “Is this item in stock?” needs an answer before you can confirm an order.
Where it’s used: Read-heavy, real-time lookups; simple point-to-point queries; systems where a small amount of added latency is acceptable.
Simple analogy: a phone call
This is like phoning a friend and waiting on the line until they answer your question. You can’t hang up and do something else — you’re stuck waiting for their reply.
5.1 How it works, step by step
Order receives a request
The Order service receives a request to place an order.
Order calls Inventory
It calls
GET /inventory/{productId}/stockon the Inventory service.Order blocks and waits
It waits (blocks) for the HTTP response — nothing else happens on this thread meanwhile.
Inventory answers from its own DB
Inventory service reads its own local database and replies with the stock count.
Order decides
Order service uses that answer to decide whether to proceed.
Java example — a simple synchronous client call
// Order Service calling Inventory Service synchronously using RestTemplate
@Service
public class InventoryClient {
private final RestTemplate restTemplate;
private final String inventoryServiceUrl = "http://inventory-service/api/stock/";
public InventoryClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public boolean isInStock(String productId, int quantity) {
StockResponse response = restTemplate.getForObject(
inventoryServiceUrl + productId, StockResponse.class);
return response != null && response.getAvailableQuantity() >= quantity;
}
}
This snippet blocks the calling thread until Inventory responds. That’s simple to reason about, but it means if Inventory is slow, Order becomes slow too.
5.2 The hidden danger: cascading failures
If Inventory service goes down or becomes slow, every service that calls it synchronously starts piling up waiting threads. This can spread like a chain reaction and take down the whole system — a phenomenon engineers call a cascading failure.
Common mistake: a distributed monolith
Calling five different services synchronously, one after another, to build a single API response. Every added synchronous hop adds latency and a new point of failure. This chain of calls is sometimes nicknamed a “distributed monolith” because the services are deployed separately but still behave like one giant fragile chain.
5.3 Protecting synchronous calls: timeouts, retries, and circuit breakers
Production systems never call another service “naked.” They wrap the call with safety nets:
- Timeouts: Don’t wait forever — give up after, say, 500ms.
- Retries with backoff: Try again a few times, waiting a little longer each time, in case it was a temporary blip.
- Circuit breaker: If a downstream service keeps failing, stop calling it for a while and fail fast, giving it time to recover — just like an electrical circuit breaker trips to stop a surge from destroying your whole house wiring.
Java example — circuit breaker with Resilience4j
// Circuit breaker example using Resilience4j
@CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackStock")
public StockResponse getStock(String productId) {
return restTemplate.getForObject(
"http://inventory-service/api/stock/" + productId, StockResponse.class);
}
public StockResponse fallbackStock(String productId, Throwable t) {
// Return a safe default instead of failing the whole order flow
return new StockResponse(productId, 0, "UNKNOWN - please verify manually");
}
Pattern 2: Event-Driven Messaging (Asynchronous)
Announce a fact once and let anyone who cares listen — no waiting, no direct coupling.
What it is: Instead of directly calling another service and waiting, a service publishes a small message called an event — a fact that something happened — onto a message broker (like Kafka or RabbitMQ). Other services subscribe to events they care about and react whenever they arrive.
Why it exists: It removes the need to wait. The publisher doesn’t even need to know who is listening. This dramatically reduces coupling and improves resilience — if a subscriber is temporarily down, the event just waits in the queue until it comes back.
Real-life analogy: the school notice board
This is like posting a notice on a school notice board instead of calling every single teacher individually. Any teacher who cares reads the notice whenever they walk past. You don’t have to track down each teacher, and new teachers can start reading the board any time without you changing anything.
6.1 Key vocabulary
| Term | Meaning |
|---|---|
| Event | A fact about something that already happened, e.g. OrderPlaced, PaymentFailed. |
| Producer / Publisher | The service that creates and sends the event. |
| Consumer / Subscriber | The service that listens for and reacts to the event. |
| Message Broker | The middleman software (Kafka, RabbitMQ, AWS SQS/SNS) that stores and delivers events reliably. |
| Topic / Queue | A named channel that groups related events, e.g. the orders topic. |
6.2 Step-by-step data flow
Java example — publishing an event with Spring Kafka
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderPlaced(Order order) {
OrderPlacedEvent event = new OrderPlacedEvent(
order.getId(), order.getCustomerId(), order.getTotalAmount());
kafkaTemplate.send("order-events", order.getId(), event);
}
}
Java example — consuming the event in Payment Service
@Component
public class OrderEventListener {
private final PaymentService paymentService;
public OrderEventListener(PaymentService paymentService) {
this.paymentService = paymentService;
}
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onOrderPlaced(OrderPlacedEvent event) {
// Idempotency check prevents double-charging on redelivery
if (paymentService.alreadyProcessed(event.getOrderId())) {
return;
}
paymentService.chargeCustomer(
event.getCustomerId(), event.getTotalAmount(), event.getOrderId());
}
}
6.3 Two flavors of events
- Notification events (thin events): “OrderPlaced with ID 4471” — just enough to identify what happened. The consumer calls back via API for details if needed.
- Event-carried state transfer (fat events): The event carries the full data needed, e.g. the entire order payload, so consumers don’t need to call back at all. This reduces coupling further but makes events larger and versioning harder.
Pattern 3: The Saga Pattern
What replaces a cross-database ACID transaction once every service has its own private database.
What it is: A Saga is a sequence of local transactions across multiple services, where each service updates its own database and publishes an event to trigger the next step. If any step fails, a series of compensating actions undo the previous steps.
Why it exists: In a monolith, a multi-step business process (reserve stock → charge card → schedule shipping) could be wrapped in one ACID database transaction. Across microservices with separate databases, you cannot do that — there is no single transaction that spans multiple databases safely at scale. Sagas are the accepted replacement.
Real-life analogy: booking a multi-city trip
Booking a multi-city trip: you book a flight, then a hotel, then a rental car — three separate bookings with three separate companies. If the rental car booking fails, you don’t expect the flight company to magically undo your ticket. Instead, you call the flight company and cancel it yourself. That cancellation call is the “compensating action.”
7.1 Two ways to coordinate a Saga
Choreography-based Saga
Each service listens for events and decides what to do next on its own — no central coordinator. Simple for a few steps, but hard to trace as the number of steps grows.
Orchestration-based Saga
A dedicated orchestrator service tells each participant what to do, step by step, and handles failures centrally. Easier to understand and monitor, but adds a new component that must itself be reliable.
7.2 Choreography example: Order → Payment → Shipping
7.3 Orchestration example
Java example — a simplified saga orchestrator
@Service
public class OrderSagaOrchestrator {
public void executeOrderSaga(Order order) {
boolean stockReserved = false;
boolean paymentCharged = false;
try {
inventoryClient.reserveStock(order.getItems());
stockReserved = true;
paymentClient.chargeCustomer(order.getCustomerId(), order.getTotalAmount());
paymentCharged = true;
shippingClient.scheduleShipment(order.getId(), order.getAddress());
orderRepository.markConfirmed(order.getId());
} catch (Exception ex) {
// Compensating actions run in reverse order
if (paymentCharged) {
paymentClient.refund(order.getCustomerId(), order.getTotalAmount());
}
if (stockReserved) {
inventoryClient.releaseStock(order.getItems());
}
orderRepository.markFailed(order.getId(), ex.getMessage());
}
}
}
Important trade-off: intermediate states are visible
Sagas give you eventual consistency, not the instant all-or-nothing guarantee of a single database transaction. For a short window, the system can be in an intermediate state (e.g. stock reserved but payment not yet confirmed). Your UI and business processes must be designed to tolerate this — typically by showing statuses like “processing” rather than pretending everything is instantaneous.
Pattern 4: CQRS (Command Query Responsibility Segregation)
Assemble the read view ahead of time, so queries never have to fan out live across services.
What it is: CQRS splits the model used to write data (commands) from the model used to read data (queries). In a microservices data-sharing context, this often means: each service publishes events when its data changes, and a separate “read model” service subscribes to those events and builds a fast, denormalized copy of the data optimized purely for querying — often combining data from multiple services.
Why it exists: Without CQRS, answering a question like “show me this customer’s order history, current loyalty points, and shipping status, all on one screen” would require the UI to call three or four different services synchronously and stitch the results together on every single request — slow and fragile. CQRS solves this by pre-building the combined view ahead of time.
Real-life analogy: the newspaper
Think of a newspaper. Reporters (writes) go out, investigate, and file individual stories with their own sources. But readers (queries) don’t want to visit every reporter’s desk — they want one neatly organized newspaper, already assembled, delivered to their door every morning. The editorial team’s job (the CQRS read model) is to pull raw reports together into one easy-to-read package ahead of time.
8.1 How it works, step by step
Order writes and emits
Order service updates its database (write model) and publishes an
OrderUpdatedevent.Shipping writes and emits
Shipping service updates its database and publishes a
ShipmentStatusChangedevent.Read Model Builder listens
A dedicated “Customer Dashboard” read-model service subscribes to both events.
It writes a denormalized doc
It writes a single denormalized document per customer into a fast store (often a document database or a cache like Redis or Elasticsearch), containing everything needed for the dashboard screen.
The UI queries only that store
The UI queries this one read-optimized store directly — no fan-out calls to four services on every page load.
8.2 Trade-offs
Pros
- Extremely fast, simple reads; queries never touch the “source of truth” services under load.
- Read and write sides can be scaled completely independently.
- Enables screens that combine data from many services without fan-out calls.
Cons
- The read model is eventually consistent — it might lag behind the write model by milliseconds to a few seconds.
- More moving parts to build, deploy, and monitor.
- Requires disciplined event-schema versioning so read models don’t silently drift.
Pattern 5: Event Sourcing
Instead of storing only the current state, store every change that led to it.
What it is: Instead of storing only the current state of an entity (e.g. “order status = SHIPPED”), Event Sourcing stores the full sequence of events that led to that state (OrderCreated → PaymentReceived → OrderShipped). The current state is calculated by replaying all events in order.
Why it exists: It gives you a perfect, permanent audit trail, and it pairs naturally with event-driven microservices — since the events you already publish to other services can also serve as your own source of truth.
Real-life analogy: a bank statement
A bank statement doesn’t just show your current balance — it shows every deposit and withdrawal that led to that balance. If you ever doubt the final number, you can re-add every transaction from day one and get the same answer. Event Sourcing applies this same idea to any entity in your system.
9.1 Practical example
| Event | Stored Data |
|---|---|
OrderCreated | orderId, customerId, items, timestamp |
PaymentReceived | orderId, amount, timestamp |
OrderShipped | orderId, trackingNumber, timestamp |
Current state (“SHIPPED”, tracking number X) is derived by replaying these three events. Other services that need the current state can subscribe to the same event stream and build their own local read models — which is exactly how Event Sourcing and CQRS often work together in the same system.
Common mistake: sourcing every entity
Using Event Sourcing for every entity in your system “because it sounds powerful.” It adds real complexity — event schema versioning, snapshotting for performance, and replay logic. Reserve it for entities where the audit history itself has real business value, like financial ledgers or order lifecycles.
Pattern 6: Change Data Capture (CDC)
Turn a database’s own transaction log into an event stream — without changing a single line of application code.
What it is: CDC is a technique where a tool watches a service’s database transaction log (the same internal log the database uses for its own crash recovery) and automatically turns every insert, update, and delete into a stream of events — without the application code having to manually publish anything.
Why it exists: Sometimes you’re dealing with an existing service (maybe even a legacy one) whose code you don’t want to modify to add manual event publishing. CDC lets you get reliable events “for free” straight from the database itself. The most popular open-source tool for this is Debezium, usually paired with Kafka.
Real-life analogy: a camera on the filing cabinet
Think of CDC like a security camera pointed at a filing cabinet. Instead of asking every clerk to separately report every time they add or remove a file (which they might forget to do), the camera automatically records every single change, and that recording is turned into a live feed anyone can subscribe to.
Production example: Companies with large legacy estates (banks, airlines, big retailers) often use CDC to gradually connect old monolithic databases into a modern event-driven ecosystem without rewriting the legacy application first.
The Outbox Pattern: Solving the Dual-Write Problem
The single most important safety pattern in event-driven microservices — the one that keeps your database and your event stream from ever silently disagreeing.
There is a subtle but very common bug hiding in the event-driven examples we’ve seen so far. Consider this code:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // Write 1: database
kafkaTemplate.send("order-events", order.getId(), new OrderPlacedEvent(order)); // Write 2: message broker
}
This looks fine, but it writes to two different systems — the database and the message broker — and there is no way to guarantee both succeed or both fail together. If the service crashes right after the database commit but before the Kafka send, the order exists in the database but the event is lost forever. Other services never learn the order was placed. This is called the dual-write problem.
Common mistake: thinking “save + publish” is atomic
Assuming that saving to the database and publishing an event are basically “the same operation.” They are two completely separate network calls to two completely separate systems, and only one of them is protected by your local database transaction.
The solution — the Outbox Pattern: Instead of publishing the event directly, write the event into a special “outbox” table in the same database, same local transaction as your actual business data. A separate background process (or a CDC tool like Debezium) reads new rows from the outbox table and reliably publishes them to Kafka, then marks them as sent.
Java example — writing to the outbox in the same transaction
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"Order",
order.getId(),
"OrderPlaced",
toJson(order),
Instant.now()
);
outboxRepository.save(event); // same transaction, same database
}
Because both orderRepository.save() and outboxRepository.save() run inside the same @Transactional boundary against the same database, they either both commit or both roll back together — no lost events, no phantom events.
Internal Working: How Message Brokers Actually Deliver Events
A minimum mental model of what happens inside Kafka (or a similar broker) — enough to reason about failure, ordering, and scaling instead of treating the broker as a magic box.
To use event-driven patterns confidently in production, you need a basic mental model of what happens inside the broker.
12.1 Partitions and ordering
Kafka splits a topic into partitions. Events with the same key (e.g. the same orderId) always go to the same partition, and Kafka guarantees strict ordering within a partition — but not across the whole topic. This is why choosing a good partition key matters: if you need all events for one order processed in the order they happened, key by orderId.
12.2 Delivery guarantees
| Guarantee | Meaning | Risk |
|---|---|---|
| At-most-once | Message sent, no confirmation waited for | Message can be lost |
| At-least-once | Message resent until acknowledged | Message can arrive more than once (needs idempotency) |
| Exactly-once | Message processed exactly one time | Achievable in specific setups (e.g. Kafka transactions) but adds complexity and cost |
Most real systems choose at-least-once delivery combined with idempotent consumers — it’s simpler and cheaper than true exactly-once, and just as safe when done correctly.
12.3 Consumer groups
Multiple instances of the same service (say, three replicas of Payment Service) join the same consumer group. Kafka automatically splits the topic’s partitions across them, so each event is processed by only one instance — giving you both parallel processing and no duplicate work within the group.
CAP Theorem & Consistency Models
The fundamental trade-off that explains why almost every pattern in this tutorial embraces eventual consistency.
The CAP Theorem, formalized by Eric Brewer, states that a distributed data system can only fully guarantee two out of these three properties at the same time, during a network failure:
- Consistency (C): Every read sees the latest write.
- Availability (A): Every request gets a response, even if it might not be the latest data.
- Partition Tolerance (P): The system keeps working even if network communication between nodes breaks down.
Because real networks do fail sometimes, Partition Tolerance is essentially non-negotiable for any distributed system — so in practice, the real choice during a network partition is between Consistency and Availability.
Simple analogy: two bank branches, one broken phone line
Imagine two bank branches in different cities that occasionally lose their connection to each other. During that outage, you can either (A) let customers withdraw money at both branches even though the branches can’t confirm each other’s latest balance (choosing Availability), or (C) freeze withdrawals at both branches until the connection is restored, to guarantee no one overdraws (choosing Consistency).
Microservices data-sharing patterns almost always lean toward AP (Availability + eventual Consistency) because a slightly stale read is usually far less damaging to the business than the entire system refusing to respond. This is exactly why Sagas, CQRS, and event-driven messaging all embrace eventual consistency rather than trying to force strong consistency across service boundaries.
Advanced Topics: Algorithms & Data Structures Behind the Scenes
The elegant, well-studied pieces of computer science quietly running underneath every broker, cache, and sharded database in the diagrams above.
The patterns above rely on some elegant, well-studied algorithms and data structures working quietly underneath. Understanding them helps you reason about failure modes instead of treating message brokers as magic boxes.
14.1 Consistent hashing
What it is: A hashing technique used to decide which node (or partition) owns a given piece of data, designed so that adding or removing a node only reshuffles a small fraction of the data instead of almost everything.
Why it exists: With plain hashing (hash(key) % numberOfNodes), adding a single new node changes the result of the modulo for nearly every key, forcing a massive, expensive data reshuffle. Consistent hashing arranges nodes and keys on a conceptual circle (“hash ring”), so each key only cares about its nearest neighbor on the ring — adding a node only affects the small slice of keys near it.
Simple analogy: adding one chair to a round table
Picture a circular seating arrangement at a large round table. If one more chair is added, only the two people sitting next to the new chair need to shuffle slightly. Everyone else stays exactly where they were.
Where it’s used: Kafka partition assignment, distributed caches like Redis Cluster and Memcached, and sharded databases like Cassandra and DynamoDB all lean on consistent hashing (or close variants) to spread data evenly while minimizing reshuffling when the cluster grows or shrinks.
14.2 Vector clocks and logical ordering
What it is: A small data structure — essentially a counter per node — attached to each update, used to detect whether one event happened before another, or whether two events happened independently (“concurrently”) without any real-world server clock being perfectly synchronized.
Why it exists: In a distributed system, physical clocks on different machines are never perfectly in sync, so you cannot reliably say “this update happened first” just by comparing timestamps. Vector clocks (and simpler cousins like Lamport timestamps) give a logical, clock-independent way to order events causally.
Practical example: If two services update the same customer record while briefly disconnected from each other, a vector clock lets the system detect this was a genuine conflict (concurrent, unordered writes) rather than incorrectly assuming one simply happened after the other.
14.3 Idempotency keys and deduplication with hash sets / Bloom filters
We already discussed why consumers must be idempotent. In practice this is usually implemented with a simple data structure: a table or in-memory set that stores the unique ID of every event already processed. Before processing a new event, the consumer checks the set — if the ID is already present, it skips reprocessing.
At very high volumes, checking a full database table for every single event can be slow, so some systems use a Bloom filter — a compact, probabilistic data structure that can quickly tell you “this ID has definitely not been seen before” or “this ID has probably been seen before” using a small, fixed amount of memory, with a tunable, very small false-positive rate. It’s a fast first check before falling back to the authoritative database lookup only when needed.
14.4 Concurrency control: optimistic vs. pessimistic locking
Within a single service’s own database, concurrent updates to the same row are still a real concern even though the whole point of microservices is to avoid cross-service locking.
- Pessimistic locking: Lock the row before reading it, forcing other transactions to wait. Safe, but can hurt throughput under high contention.
- Optimistic locking: Read the row along with a version number; when writing, check the version hasn’t changed, and reject (retry) the write if it has. This scales much better when conflicts are rare, which is the common case in most business applications.
// Optimistic locking with a version column (JPA / Hibernate)
@Entity
public class Order {
@Id
private String id;
@Version
private long version; // Hibernate auto-checks this on update
private String status;
// ...
}
If two threads load the same order and both try to update its status, the second write fails because the version number no longer matches, and the application can safely retry with fresh data instead of silently overwriting the first change.
14.5 Consensus: Raft in a little more depth
Earlier we mentioned that Raft is used to elect a leader and agree on state across nodes. Its core idea is refreshingly simple: nodes hold periodic elections, a candidate becomes leader only if it wins votes from a strict majority, and once elected, only that leader accepts new writes and replicates them to followers. If the leader disappears (crashes, network partition), a new election happens automatically after a short timeout. This majority-vote requirement is exactly what prevents two leaders from operating at once (a “split-brain” scenario), which would otherwise let two parts of the system accept conflicting writes simultaneously.
Advantages, Disadvantages & Trade-offs
Every pattern earns its place through a specific trade-off. Read this whole table before you pick one for a real project.
| Pattern | Best for | Main trade-off |
|---|---|---|
| Synchronous API | Immediate, must-have-now answers | Tight coupling, risk of cascading failure |
| Async Events | Reactions, notifications, decoupled workflows | Eventual consistency, harder debugging |
| Saga | Multi-step business transactions | Complex compensation logic |
| CQRS | Fast, combined reads across services | Extra infrastructure, read lag |
| Event Sourcing | Full audit history, financial ledgers | Schema versioning, replay complexity |
| CDC | Connecting legacy systems without code changes | Coupling to internal DB schema, extra ops tooling |
There is no single “best” pattern — production systems typically combine several of these depending on the specific need of each interaction.
Performance & Scalability
Why the whole architecture is worth the trouble — independent scaling and back-pressure absorption.
Decoupled data sharing directly enables independent scaling — arguably the biggest performance win of the whole architecture.
- Horizontal scaling per service: If Payment Service is under heavy load on Black Friday, you scale only Payment Service’s pods, without touching Inventory or Shipping.
- Read replicas: A service can add read-only replicas of its own database to spread out query load, completely invisible to other services.
- Backpressure via queues: A message broker naturally smooths out traffic spikes — events queue up during a burst and get processed steadily, instead of overwhelming a downstream service in real time.
- Batching: Consumers can process events in small batches (e.g. 500 at a time) to improve throughput at the cost of a little added latency.
High Availability & Reliability
How the same architecture keeps standing when disks fail, brokers die, and networks split.
17.1 Replication
Each service’s database is typically replicated across multiple nodes (one primary, several replicas). If the primary fails, a replica is promoted, minimizing downtime. Message brokers like Kafka replicate each partition across multiple brokers too, so losing one broker doesn’t lose data.
17.2 Partitioning (Sharding)
As a service’s data grows huge, its database can be split (sharded) across multiple machines by a key, such as customerId. This is a separate concept from Kafka “partitions” but shares the same core idea: split data by key to spread load.
17.3 Consensus algorithms
Distributed databases and brokers need to agree on things like “who is the current primary” even when some nodes are unreachable. Algorithms like Raft and Paxos solve this by requiring a majority (“quorum”) of nodes to agree before any decision is finalized, which prevents split-brain scenarios where two nodes both think they’re in charge.
17.4 Failure recovery patterns
- Dead-letter queues: Events that repeatedly fail to process get moved to a separate queue for manual inspection, instead of blocking the whole pipeline forever.
- Retry with exponential backoff: Wait 1s, then 2s, then 4s, then 8s between retries, so a struggling downstream service isn’t hammered.
- Bulkheads: Isolate thread pools and connections per downstream dependency, so one slow dependency can’t exhaust all your resources — like separate watertight compartments in a ship’s hull.
Security
How sensitive data stays safe as it moves between many independent services and a shared broker.
- Service-to-service authentication: Services authenticate each other using mutual TLS (mTLS) or short-lived JWT tokens, not shared passwords, so a compromised service can’t freely impersonate another.
- Least-privilege data access: A service should only ever receive the specific fields it needs in an event or API response — not a full dump of another service’s database record.
- Broker-level access control: Kafka and RabbitMQ support ACLs so only authorized services can publish or subscribe to sensitive topics (e.g. only Payment Service can publish to
payment-events). - Encryption in transit and at rest: All inter-service traffic and stored event logs should be encrypted, since events often carry sensitive business data as they flow across the network.
- PII minimization in events: Avoid putting sensitive personal data directly into widely-broadcast events; consider passing a reference ID and letting authorized consumers fetch sensitive details through a controlled, audited API instead.
Monitoring, Logging & Tracing
When one user click quietly touches five services and a broker, “where did this break?” becomes a hard question without the right tooling.
When data flows through five services and a message broker, “where did this break?” becomes a genuinely hard question without the right tools.
- Correlation IDs: A unique ID generated at the very first request and passed through every subsequent API call and event, so you can search logs across every service and reconstruct the entire journey of one request.
- Distributed tracing: Tools like OpenTelemetry, Jaeger, and Zipkin visualize the full path of a request as it hops across services, showing exactly where time was spent and where it failed.
- Event lag monitoring: Track how far behind each consumer is from the latest event in the topic (“consumer lag”) — a growing lag is an early warning sign of trouble.
- Structured logging: Logs in JSON format with consistent fields (service name, correlation ID, event type) so they can be easily searched in tools like Elasticsearch or Splunk.
- Business-level dashboards: Beyond CPU and memory, track domain metrics like “orders placed per minute” or “average time from order to shipment,” which reveal problems technical metrics alone might miss.
Deployment & Cloud
Where these patterns actually live in a modern cloud environment.
Modern microservices data-sharing patterns are almost always deployed on containers orchestrated by Kubernetes, with managed cloud services handling the heavy infrastructure:
- Managed message brokers: AWS MSK / Amazon SQS+SNS, Google Pub/Sub, Azure Service Bus, or Confluent Cloud, so teams don’t have to operate Kafka clusters by hand.
- Service mesh: Tools like Istio or Linkerd handle mTLS, retries, and traffic routing between services transparently, without changing application code.
- Independent CI/CD pipelines: Each service has its own pipeline, so a Payment Service deploy never has to wait for Inventory Service’s tests to pass.
- Schema registries: Tools like Confluent Schema Registry enforce that event formats (Avro, Protobuf, JSON Schema) stay backward-compatible as services evolve, preventing a producer’s change from silently breaking consumers.
Databases, Caching & Load Balancing
Where the mesh of services meets the messy world of actual storage.
21.1 Polyglot persistence
Because each service owns its own database, different services are free to pick the database engine that best fits their needs — this is called polyglot persistence. Order Service might use PostgreSQL for strong relational guarantees, Product Search might use Elasticsearch for fast text search, and a Recommendations service might use a graph database like Neo4j.
21.2 Caching read models
Data pulled from other services (via API or events) is often cached locally with a short time-to-live (TTL) using Redis or an in-memory cache, so repeated lookups don’t hit the network every time. This trades a small amount of freshness for a large amount of speed.
21.3 Load balancing
Synchronous API calls between services usually go through a load balancer or service discovery mechanism (like Kubernetes Services, Eureka, or Consul), spreading requests evenly across all healthy instances of the target service, and automatically routing around unhealthy ones.
Design Patterns & Anti-Patterns Recap
A quick, side-by-side reference to the patterns worth reaching for — and the shortcuts worth actively avoiding.
API Composition
A dedicated aggregator service calls multiple services and merges results for a single UI screen, when a full CQRS read model would be overkill.
Outbox + CDC
Guarantees events are never lost, without ever performing an unsafe dual write.
Backend for Frontend (BFF)
A thin service tailored to one specific client (mobile app vs. web) that composes calls to multiple backend services.
Shared Database
Multiple services reading/writing the same tables directly. Destroys independent deployability and ownership.
Distributed Monolith
Services are deployed separately but so tightly chained by synchronous calls that they must all be deployed and scaled together anyway.
Chatty Services
A single user action triggers dozens of tiny synchronous calls between services, causing high latency and fragility.
Two-Phase Commit (2PC) Across Services
Trying to force a classic distributed transaction across independent service databases. It technically exists, but it’s slow, blocks on any single participant’s failure, and works against the whole point of microservice autonomy — Sagas are almost always preferred instead.
Best Practices & Common Mistakes
The lessons that separate teams who have run this architecture for years from teams who are about to learn them the hard way.
23.1 Best practices
Default to async
Default to asynchronous events for anything that doesn’t need an instant answer.
Design consumers to be idempotent
Always design consumers to be idempotent — assume every event might be delivered more than once.
Always use the Outbox Pattern for save+publish
Use the Outbox Pattern whenever a service both writes to its database and publishes an event in the same operation.
Version event schemas from day one
Version your event schemas from day one, and keep changes backward-compatible.
Correlation IDs on everything
Include a correlation ID on every request and event so you can trace a full journey end-to-end.
Prefer eventual consistency
Prefer eventual consistency by default; only reach for synchronous strong consistency when the business truly cannot tolerate any staleness (e.g. checking a hard inventory limit at the exact moment of purchase).
23.2 Common mistakes
| Mistake | What actually happens |
|---|---|
| Sharing a database “just for now” | It almost never gets fixed later — the two services quietly grow more entangled over time. |
| Doing dual writes (DB + broker) directly | Silent event loss when the service crashes between the two writes. |
| Deep synchronous call chains | Every hop multiplies latency; any single slow service takes down the whole request. |
| Assuming in-order, exactly-once delivery | Consumers duplicate work, double-charge, or corrupt state when messages retry. |
| Event-sourcing every entity | Enormous accidental complexity for entities that would be fine with a simple current-state row. |
Real-World & Industry Examples
Concrete places where these patterns are running at massive scale right now — and a walkthrough that combines all of them.
Netflix
Uses hundreds of independently deployed microservices with their own datastores, communicating heavily through asynchronous messaging and well-defined APIs, alongside patterns like circuit breakers (Netflix originally built the widely-used Hystrix library) to prevent cascading failures.
Amazon
Pioneered the “two-pizza team” and service-oriented model where each team owns its service and its data exclusively, communicating with other teams only through published APIs — a philosophy that directly shaped the modern microservices movement.
Uber
Operates thousands of microservices for trip matching, pricing, payments, and mapping, relying heavily on event-driven pipelines to propagate state like driver location and trip status across many independent consuming services in near real time.
Banking & Fintech
Many modern banking platforms use Event Sourcing for ledgers (full auditability is a regulatory requirement) combined with Sagas for multi-step processes like fund transfers between accounts held in different services.
24.1 Walking through a familiar example: online food delivery
Let’s tie everything together with one end-to-end story, similar to what a food delivery platform might do behind the scenes.
User taps “Place Order”
You tap “Place Order” in the app. The Order Service synchronously calls the Restaurant Service to confirm the restaurant is currently open and accepting orders — this needs a fresh, real-time answer, so it’s a synchronous API call.
Order saves & publishes via Outbox
Once confirmed, Order Service saves the order and, using the Outbox Pattern, reliably publishes an
OrderPlacedevent.Payment consumes and charges
The Payment Service consumes this event and charges the customer’s saved card, then publishes
PaymentCompletedorPaymentFailed.Saga fans out the next steps
A Saga orchestrator (or a choreography chain) reacts: on success, it notifies the Restaurant Dashboard Service to start preparing food and the Driver Matching Service to find a nearby delivery partner; on failure, it cancels the order automatically.
Live map powered by CQRS
As the driver’s location updates every few seconds, a stream of
DriverLocationUpdatedevents flows through Kafka into a CQRS read model that powers the live map you see in the app — built purely from a fast-moving stream of events, not from synchronous polling of the Driver service’s database.Analytics powered by CDC
After delivery, an Analytics Service uses Change Data Capture on several services’ databases to build company-wide dashboards, without any of those services needing to change a single line of code to support analytics.
Notice how a single user action — placing one order — quietly touches almost every pattern covered in this tutorial: a synchronous check, a reliably published event, a saga with compensation, a live CQRS read model, and CDC-powered analytics. This is exactly what “production-grade” data sharing looks like in practice.
Frequently Asked Questions
Short, honest answers to the questions engineers ask most when they first meet these patterns.
Q1: Can two microservices ever share a database, even temporarily?
Technically yes, and some teams do it during a migration from a monolith. But it should be treated as a temporary, tracked exception with a clear plan to split it — not a long-term design choice.
Q2: Which pattern should I start with as a beginner?
Start with simple synchronous REST calls for straightforward lookups, and introduce asynchronous events once you have a real need to decouple services or fan out a change to multiple listeners.
Q3: Is eventual consistency “unsafe” for something like money?
Not inherently — as long as the business process accounts for the delay (e.g. showing a payment as “processing” rather than instantly “completed”) and compensating actions exist for failure cases, as in the Saga pattern.
Q4: How is CQRS different from just caching?
A cache typically stores a copy of data from one source in the same shape. A CQRS read model often combines and reshapes data from multiple services into a new structure specifically optimized for a particular query, and is usually kept in sync via events rather than simple expiration.
Q5: Do I need Kafka specifically, or can I use something simpler?
Kafka is popular for high-throughput, ordered event streams, but simpler tools like RabbitMQ, AWS SQS/SNS, or Google Pub/Sub are perfectly valid choices for smaller-scale or simpler messaging needs. Pick based on your throughput, ordering, and operational requirements — not hype.
Q6: What happens if two services disagree about the state of the same data?
This is why bounded contexts matter so much — each service should be the single, unambiguous source of truth for its own slice of data. If Inventory says “5 in stock” and a stale local cache in Order Service says “8 in stock,” Order Service’s cached value is simply out of date, not a competing truth; refreshing it (or re-validating right before checkout) resolves the disagreement.
Q7: How do you test a system built from so many independent, asynchronous pieces?
Through a combination of layers: fast unit tests per service, contract tests that verify a producer’s event shape matches what consumers expect (tools like Pact are popular for this), and a smaller number of end-to-end tests that exercise a full saga across a staging environment. Contract tests in particular catch breaking event-schema changes before they ever reach production.
Summary & Key Takeaways
The whole tutorial, compressed into what actually matters.
- Microservices avoid a shared database to gain independent deployment, scaling, and technology choice — but this means data must be shared deliberately through APIs and events instead of SQL joins.
- Synchronous APIs answer “what is true right now,” at the cost of tight coupling and cascading failure risk.
- Asynchronous events decouple services and let many consumers react to one fact independently, at the cost of eventual (not instant) consistency.
- Sagas replace cross-database transactions with a sequence of local transactions plus compensating actions.
- CQRS pre-builds fast, combined read views from events, so queries never have to fan out live across services.
- Event Sourcing stores the full history of changes as the source of truth, giving perfect auditability.
- Change Data Capture turns a database’s own transaction log into a reliable event stream, even for legacy services.
- The Outbox Pattern is essential whenever a service both saves data and publishes an event, to avoid silently losing events.
- The CAP theorem explains why most of these patterns lean toward availability and eventual consistency rather than strict, instant consistency.
- Production-grade systems combine several of these patterns together, chosen per use case — there is no single universal answer.
The one idea to remember
Data sharing without a shared database is not a workaround — it’s the deliberate architectural foundation that makes microservices independently deployable, scalable, and resilient in the first place. Master these patterns, and you’ll be equipped to design, debug, and explain real production distributed systems with confidence.