The Saga pattern (choreography or orchestration) replaces distributed transactions
The Outbox pattern guarantees at-least-once event publishing without two-phase commit
CQRS separates the write model from optimised read models for query performance
✦ Definition~90s read
What is Database-per-Service Pattern in Microservices?
The database-per-service pattern mandates that each microservice has exclusive ownership of its data store. The database technology (PostgreSQL, MongoDB, Redis, Cassandra) is chosen to fit the service's access patterns, not forced to match a corporate standard.
★
Think of microservices like separate restaurant kitchens.
The schema evolves independently, versioned alongside the service, deployed with the service. Other services that need data from this service must ask for it through the service's API or subscribe to events the service publishes.
This is not merely a physical separation of schemas within one database cluster (though that can be a useful interim step). True database-per-service means separate connection strings, separate credentials, separate lifecycle management. The goal is that any engineer on the orders team can drop and recreate the orders schema, run a major PostgreSQL version upgrade, or migrate to a different database engine entirely — without filing a change request with the inventory team or coordinating a multi-team release window.
The pattern is a direct enabler of the microservices autonomy guarantee. Without it, services are coupled at the data layer regardless of how clean their API contracts look. With it, teams can own their domain end to end: schema, business logic, API contract, and operational runbook.
Plain-English First
Think of microservices like separate restaurant kitchens. A shared database is like all kitchens sharing one pantry — one chef's reorganisation blocks everyone else. Database-per-service gives each kitchen its own locked pantry. Coordinating a multi-course meal across kitchens then requires a maitre d' (saga orchestrator) or pre-agreed signals (choreography events) instead of one chef yelling across a shared space.
The promise of microservices — independent deployability, technology heterogeneity, and fault isolation — evaporates the moment two services share a database. A schema change in the orders table forces a coordinated release with the inventory service. A long-running report query from the analytics service holds locks that stall order writes. A poorly indexed join in billing brings down the entire cluster. These are not theoretical concerns; they are Tuesday morning incidents at companies that adopted microservices topologically (splitting the code) but not architecturally (splitting the data).
The database-per-service pattern is the foundational data rule of microservices. It states that each service is the sole owner and gatekeeper of its persistent state. No other service may read from or write to that database directly. All data exchange happens through well-defined APIs or asynchronous events. This sounds simple, and it is — but the consequences are profound and the implementation challenges are real.
The first challenge engineers hit is cross-service queries. In a monolith, joining orders and customers is a single SQL statement. In a database-per-service world, that join must become a service call, an event-driven denormalisation, or a dedicated read model assembled from both services. Each approach has trade-offs in latency, consistency, and operational complexity.
The second challenge is distributed transactions. Moving money between accounts in a monolith is a single ACID transaction that either commits or rolls back. In a microservices world, the accounts service and the ledger service each own their data. The Saga pattern — implemented via choreography (events) or orchestration (a saga orchestrator) — replaces 2PC with a sequence of local transactions and compensating actions. Understanding when to use each flavour is one of the most important architectural decisions you will make.
The Outbox pattern solves the dual-write problem: how do you atomically update your database and publish an event without a distributed transaction? The answer is to write the event into an outbox table in the same local transaction, then have a relay process publish it asynchronously. This guarantees at-least-once delivery even if the broker is down at commit time.
Finally, CQRS (Command Query Responsibility Segregation) acknowledges that the write model optimised for consistency is rarely the same shape as the read model optimised for query performance. By maintaining separate models — often a normalised write store and a denormalised read store — teams can serve complex dashboards without burdening transactional databases.
Why Shared Databases Break Microservices
The entire value proposition of microservices rests on independent deployability. A team should be able to release their service on a Friday afternoon without coordinating with five other teams. Shared databases make this impossible in practice, even when service APIs are well-defined.
The mechanisms of coupling are insidious. Schema coupling: Service B reads columns from Service A's table. When A wants to rename or remove a column, it must first verify B has been updated — a cross-team coordination dance. Temporal coupling: A's migration holds a table lock. B's queries time out. The order of deployment matters and teams must coordinate release windows. Load coupling: B's report query does a full-table scan on A's orders table at 09:00 every morning, causing write latency spikes for A's customers.
None of these problems are visible in architecture diagrams that show only service-to-service API calls. They only surface in production, usually at the worst possible moment. Database-per-service eliminates all three forms of coupling by making the data boundary as explicit as the API boundary.
The transition from a shared database to database-per-service is rarely a big-bang migration. The strangler fig pattern applies here: identify seams in the existing schema where data belongs clearly to one domain, extract those tables into a service-owned schema, and replace direct DB queries in other services with API calls. The process is iterative and can take months for a large monolith, but each seam extracted reduces the blast radius of future incidents.
The 'Same Cluster, Different Schema' Trap
Separating schemas within a single PostgreSQL cluster is better than a single schema, but it is not true database-per-service. A long-running transaction in one schema can still hold locks that affect others. A cluster upgrade still requires coordinated downtime. Use separate clusters (or at minimum separate RDS instances) for true isolation — especially for services with different SLAs.
Production Insight
Every direct cross-service DB query you find in production is a ticking clock — the only question is which schema change will detonate it.
Key Takeaway
Data ownership is the real boundary in microservices — API boundaries without data boundaries are just a monolith with extra network hops.
thecodeforge.io
Microservices Database Per Service
Saga Pattern: Choreography vs Orchestration
A saga is a sequence of local transactions, each updating its own service's database and publishing an event or message to trigger the next step. If any step fails, compensating transactions undo the work of preceding steps. This replaces the two-phase commit (2PC) protocol, which requires a distributed transaction coordinator and is incompatible with independently deployed services.
Choreography-based sagas have no central coordinator. Each service listens for events and decides independently what to do next. The order service publishes OrderPlaced. The inventory service listens, reserves stock, and publishes StockReserved. The payment service listens to StockReserved, charges the card, and publishes PaymentProcessed. Each service knows its role in the dance without anyone calling the shots. This approach is simple to implement, scales well, and has no single point of failure. The downside is that the overall flow is implicit — it exists only in the aggregate behaviour of all participants. Debugging a stuck saga means correlating events across multiple service logs, which requires good distributed tracing and a shared correlation ID.
Orchestration-based sagas have a central saga orchestrator (often a separate service or a durable workflow engine like Temporal or Axon). The orchestrator drives the saga: it calls inventory to reserve stock, waits for confirmation, then calls payment, waits, then calls fulfilment. If payment fails, the orchestrator explicitly calls inventory to release the reservation. The flow is centralised and visible — you can query the orchestrator for the current state of any saga instance. The trade-off is a new service to maintain and a potential bottleneck if the orchestrator becomes overloaded.
Choose choreography when: the saga has 2-3 steps, participants are well-defined, and the team values simplicity. Choose orchestration when: the saga has 5+ steps, conditional branching is needed (retry payment 3 times before cancelling), long-running waits are involved (wait for human approval), or business teams need visibility into saga state via a dashboard.
Always Include a Correlation ID
Every event and API call in a saga chain must carry a correlation ID (a UUID generated at saga start). This is the only way to trace a saga across service logs, Kafka consumer groups, and distributed traces. Inject it into MDC at saga start: MDC.put("correlationId", saga.getCorrelationId()).
Production Insight
Choreography sagas look elegant on whiteboards but become debugging nightmares in production without centralised event sourcing or at least a saga tracker table.
Key Takeaway
Choose choreography for simplicity in small sagas; choose orchestration when you need visibility, branching logic, or long-running waits.
Outbox Pattern: Reliable Event Publishing
The dual-write problem is simple to state: your business logic needs to update its database AND publish an event to a message broker. If you do both in sequence, you have a race condition. Update DB, then crash before publishing? Event is lost. Publish event, then crash before committing? Event fires but DB has no record. Two-phase commit between a relational DB and Kafka is theoretically possible but operationally complex and almost never used in practice.
The Outbox pattern solves this with a local transaction guarantee. Instead of publishing directly to Kafka, you insert the event into an outbox table in the SAME database transaction as your business update. If the transaction commits, both the business data and the outbox record are durably saved. If the transaction rolls back, neither survives. The dual-write problem disappears because there is only one write target: your own database.
A separate relay process then reads unpublished outbox rows and publishes them to Kafka, marking them published after acknowledgement. The relay can be implemented as a polling publisher (a scheduled Spring job that queries for unprocessed outbox rows every 100ms) or as CDC (Change Data Capture) using Debezium, which tails the database's write-ahead log and publishes changes to Kafka with near-zero latency and no polling overhead.
Debezium is the production choice for high-throughput systems. It reads the Postgres WAL or MySQL binlog, requires no polling, and adds no write amplification to the application. The outbox rows themselves can be used as the event payload, or the Debezium Outbox Event Router SMT (Single Message Transform) can route events to per-aggregate topics automatically.
Critical detail: the relay guarantees at-least-once delivery, not exactly-once. Consumers must be idempotent. The standard approach is to include the outbox row's UUID as the event ID, and consumers check IF NOT EXISTS (SELECT 1 FROM processed_events WHERE event_id = ?) before applying the event.
Never Delete Outbox Rows Immediately
Keep published outbox rows for at least 24 hours. They are invaluable for debugging event delivery issues — you can check exactly what payload was sent and when. Archive to cold storage after 7 days. Immediate deletion loses your audit trail and makes replay impossible.
Production Insight
Debezium CDC with the Outbox Event Router SMT is the gold standard — zero polling overhead, sub-100ms latency, and it works even if your application is completely down.
Key Takeaway
The Outbox pattern converts a distributed commit into a local commit — one of the most powerful reliability patterns in event-driven microservices.
thecodeforge.io
Microservices Database Per Service
CQRS: Separating Write and Read Models
CQRS (Command Query Responsibility Segregation) is the recognition that the data model optimised for writing (normalised, consistent, transactional) is almost never the same shape as the data model optimised for reading (denormalised, pre-joined, query-friendly). Most systems use a single model for both, and both suffer as a result: writes deal with complex joins to maintain normalisation, reads deal with expensive joins to reconstruct domain objects.
In a microservices context, CQRS pairs naturally with the database-per-service pattern. The write-side service owns its transactional store and publishes domain events on every state change. One or more read-side projectors subscribe to these events and maintain denormalised read models optimised for specific query patterns. The customer dashboard projector might maintain a customer_order_summary table with pre-computed totals. The inventory report projector might maintain a daily_stock_snapshot table. Each read model is a materialised view, but built from events rather than from a database view.
The key operational insight is that read models are disposable and rebuildable. Because they are derived from an immutable event stream, you can drop a read model, replay the event stream from the beginning, and rebuild it with a new schema. This makes schema evolution on the read side trivial — something that is very expensive on a shared transactional database.
CQRS does introduce eventual consistency between write and read models. A customer who just placed an order may not see it immediately in the orders list if the projector has not yet processed the event. This is usually acceptable with a clear UX pattern: after the write succeeds, show the new item optimistically in the UI while the event propagates. If strict consistency is required for a specific query, serve it from the write-side service directly, accepting the performance cost.
Include Enough Data in Events to Avoid Lookups
When publishing domain events, include denormalised data that projectors will need — customer name, product title, price at time of purchase. This avoids the projector having to make synchronous API calls to enrich the event, which reintroduces coupling and creates a potential point of failure during event replay.
Production Insight
Design events for the readers, not for the writer — include the data consumers will project, not just the minimal change delta.
Key Takeaway
CQRS read models are disposable materialised views built from events — optimise them freely for query patterns without any risk to the write side.
Eventual Consistency: Designing for It, Not Against It
Eventual consistency means that, given no new updates, all replicas of a data item will eventually converge to the same value. In a database-per-service architecture, it is not an optional trade-off — it is the fundamental consistency model for cross-service data. The question is not whether to accept eventual consistency but how to design systems that are correct in its presence.
The first design principle is to make writes return as fast as possible and let the system catch up asynchronously. An order placement writes to the orders database and publishes an event. The inventory update happens asynchronously. The payment charge happens asynchronously. The confirmation email is sent asynchronously. The customer sees immediate feedback ('Your order has been placed') while the downstream effects propagate over the next few hundred milliseconds.
The second principle is to design UIs for optimistic updates. When a customer places an order, show it immediately in their order history with a 'Processing' status. Do not wait for the CQRS read model to catch up. Use a local state update (Redux, Zustand, or React Query optimistic update) that will be reconciled when the next poll or WebSocket push delivers the confirmed state.
The third principle is to design for idempotency everywhere. Events will be delivered at least once. Consumers may process the same event multiple times due to redelivery after a crash. Every event handler must be safe to call multiple times with the same event. The standard pattern is to maintain a processed_events table with the event ID and check it before applying — if already processed, skip and ack the message.
Business processes that require strong consistency (financial ledgers, inventory allocation at checkout) should be modelled as sagas with explicit compensating transactions, or should remain within a single service's database boundary where ACID transactions apply.
Use 202 Accepted for Commands That Trigger Async Sagas
Return HTTP 202 Accepted (not 200 OK) when a command triggers an asynchronous saga. Include an order/saga ID and a polling URL (/api/orders/{id}/status) so clients can track progress. This sets accurate expectations and prevents clients from assuming the operation is complete.
Production Insight
The time window of inconsistency in a well-tuned Kafka-based system is typically 50-200ms — design UX for this, not for hours.
Key Takeaway
Eventual consistency is a feature, not a bug — it enables independent scaling and autonomous deployments, but demands idempotent consumers and optimistic UI patterns.
Migration Strategy: Strangler Fig to Database-per-Service
Migrating a monolith with a shared database to a database-per-service architecture is a multi-month journey. The strangler fig pattern provides the framework: build new service-owned data stores alongside the monolith, gradually route traffic to them, and strangle the shared database one domain at a time.
Phase 1 — Identify seams: Map which tables belong exclusively to which domain. Tables that are truly shared (accessed by many domains) are the hardest — start with the easy wins: tables accessed by only one domain team. Use a database dependency graph tool or simply grep the codebase for table name references.
Phase 2 — Create the shadow service: Stand up the new service with its own database. Implement a dual-write adapter in the monolith: on every write to the shared table, also write to the new service's API (or outbox). Run both databases in sync for 2-4 weeks, comparing output.
Phase 3 — Migrate reads: Switch the consumers of this data (other services, the monolith itself) to call the new service's API instead of querying the table directly. Do this one consumer at a time, running A/B comparisons.
Phase 4 — Cut over writes: Remove the dual-write adapter. The shared table becomes read-only. After a confidence period, drop the table from the shared database.
Pitfalls: Cross-domain transactions that span both old and new data (must be converted to sagas before cutover). Reporting queries that join multiple domains (must become read models before cutover). Long-lived transactions that pin the shared DB (identify and shorten before migration).
Use Feature Flags to Control Migration Traffic
Every phase of the migration should be behind a feature flag so you can roll back instantly without a deployment. Keep the flag granular: per-tenant, per-region, or percentage-based rollout. This lets you validate the new service at 1% traffic before committing to 100%.
Production Insight
The strangler fig migration almost always takes 2x longer than estimated — the last 20% of cross-cutting references are the hardest.
Key Takeaway
Migrate data ownership incrementally, one domain seam at a time, with dual-write and feature flags providing a safe rollback at every step.
Why Your EntityManagerFactory Setup Will Fail in Production
You configured two databases in Spring Boot. Great. Now watch your app fail when it tries to create the second persistence unit. Default Spring Boot auto-configuration assumes one DataSource, one EntityManagerFactory. When you define a second, Spring doesn't know which is which. You get duplicate bean definitions, transaction managers fighting, and repositories pointing at wrong databases. The fix is explicit: mark one Primary, name the others. Every bean needs a distinct qualifier. Every repository package needs its own @EnableJpaRepositories with explicit entityManagerFactoryRef and transactionManagerRef. If you skip this, your user service will write products into the user table, silent data corruption until a report breaks. Spring Boot's magic works for one database. For multiple, you're the magician now.
Without @Primary on your primary DataSource, Spring will throw: NoUniqueBeanDefinitionException — expected single matching bean but found 2.
Production Trap:
Never use @Primary on the real-time operational database. Use it on your logging or analytics database instead. If you promote the wrong one, rollback data in your transaction-logic database becomes impossible without killing other services.
Key Takeaway
Every secondary persistence unit needs its own @EnableJpaRepositories with explicit refs. @Primary on exactly one DataSource prevents bean ambiguity.
Testing Cross-Database Transactions: The Hidden Race Condition
You have two databases. One JTA transaction manager. One write fails. The other commits anyway. This is the classic distributed transaction lie. Spring's @Transactional across multiple DataSources only works with XA transactions, which break under network partitions and slow your writes to molasses. Real production services don't use XA. They use the Saga pattern, but you need to prove your compensation logic actually fires. Write a test that simulates a partial failure: insert into Order DB, then throw a RuntimeException before the Payment DB write. Assert the Order DB rollback. If it doesn't roll back, your Saga compensation code is dead code. Use Testcontainers with two Postgres containers. Annotate your test with @Transactional per data source, not across both. Verify the compensation logic by checking the state of the first DB after the failure. You'll catch the race condition that only shows up at 2 AM under load.
SagaRollbackTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial
@TestvoidsagaCompensatesOnPaymentFailure() {
// simulate partial Saga — order created, payment failsvar orderId = orderService.createOrder(newCreateOrderRequest("SKU-1", 1));
// force failure — payment service throwsMockito.when(paymentClient.processPayment(any()))
.thenThrow(newPaymentFailedException("insufficient funds"));
assertThrows(PaymentFailedException.class,
() -> orderService.completeOrder(orderId));
// verify compensation — order is rolled backvar order = orderRepository.findById(orderId).orElse(null);
assertNull(order, "Saga should have compensated: order not null");
}
Output
Without Saga compensation, this test fails: order remains in DB with status 'pending'. Fix: implement Outbox-based compensation events that trigger on rollback.
Testing Reality Check:
Your test must fail at least once before it passes. If it passes on the first run, your compensation logic is not being exercised. Force the payment failure with a real network disconnect, not a mocked exception. Mocks hide race conditions.
Key Takeaway
JTA transactions across databases are a trap. Test Saga compensation with real containers and forced failures. If compensation doesn't fire, you have data leaks.
● Production incidentPOST-MORTEMseverity: high
The Shared Schema Migration That Took Down Order Processing for 4 Hours
Symptom
Alert fired at 14:02: order creation latency p99 spiked from 80ms to 30s. API gateway started returning 503s. Customer support reported users unable to checkout. Inventory and fulfilment dashboards also went blank.
Assumption
The on-call engineer assumed a network partition between the app tier and the database. They checked RDS CloudWatch — CPU was fine, IOPS normal. They restarted the order service pods. Nothing changed.
Root cause
A database migration job run by the billing team executed ALTER TABLE orders ADD COLUMN tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0. On MySQL 5.7 (the team had not yet migrated to 8.0), this acquired an exclusive metadata lock for the duration of the table rebuild — 23 minutes on a 400M row table. Because the orders table was shared by order, inventory, and fulfilment services, all three were blocked. The billing team had no visibility into which other services depended on the table.
Fix
The DBA killed the ALTER session. Services recovered immediately. The migration was re-run the following week using pt-online-schema-change. The architectural fix took three months: the orders table was split into service-owned schemas with a dedicated read model for billing.
Key lesson
Schema coupling is invisible until it is catastrophic.
The moment two teams share a table, one team's routine migration becomes the other team's production incident.
Database-per-service is not optional for truly independent deployability.
Production debug guideSymptom → root cause → fix5 entries
Symptom · 01
Service B fails when Service A's database is down
→
Fix
This is data coupling masquerading as a network issue. Trace the call graph — if B is querying A's DB directly (shared connection pool, shared schema, read replica shared), that is the root cause. Introduce an API boundary: B calls A's REST/gRPC endpoint, which can apply circuit breaker logic and return cached or degraded responses. Short term: add a read replica dedicated to B. Long term: migrate B's queries to subscribe to A's events and maintain its own read model.
Symptom · 02
Saga transaction leaves data inconsistent after partial failure
→
Fix
First, identify the saga step that failed by querying the saga state table (if orchestrated) or correlating event IDs across service logs. Check whether compensating transactions ran. If they did not, the compensating handler likely threw an exception — look for uncaught errors in the dead-letter queue. Replay the compensating event manually after fixing the handler. Add idempotency keys to all compensating actions so replay is safe. Then harden the failure path with retry + DLQ + alerting.
Symptom · 03
Outbox events are not being published after service restart
→
Fix
The outbox relay (Debezium CDC or polling publisher) likely lost its offset or failed to start. Check the relay process logs first. For Debezium, verify the connector status via the Kafka Connect REST API (GET /connectors/<name>/status). If the connector is in FAILED state, check the offset stored in the connect-offsets Kafka topic. For a polling publisher, verify the processed flag on outbox rows — unprocessed rows should be queued immediately on startup. Add a startup health check that verifies the relay is running before the service accepts traffic.
Symptom · 04
CQRS read model is stale — showing data that was updated 10 minutes ago
→
Fix
Check the event consumer lag for the read-model updater using kafka-consumer-groups.sh --describe. If lag is growing, the consumer is too slow — profile the read-model update logic (likely missing an index on the projection table). If lag is zero but data is still stale, suspect a bug in the event handler (wrong event type subscribed, filtering too aggressively, or a field mapping error). Enable structured logging in the event handler with the event offset and processed-at timestamp to correlate lag.
Symptom · 05
Cross-service join is too slow — 5s to assemble a customer order summary
→
Fix
This is a missing read model. The summary should be pre-computed by subscribing to OrderCreated, OrderUpdated, and CustomerProfileUpdated events and writing a denormalised summary row per customer. If you cannot introduce a read model immediately, implement a parallel fetch: call order service and customer service concurrently with CompletableFuture.allOf(), then join in application memory. Measure — if the join data is small (<10k rows), in-memory join is fast enough as a bridge solution.
★ Debug Cheat SheetImmediate actions for database-per-service incidents
Outbox table growing — events not consumed−
Immediate action
Check relay process health and Kafka broker connectivity
If DB pod is CrashLoopBackOff, check PVC mount; if connection refused, verify Service/Endpoint object and NetworkPolicy
Saga: Choreography vs Orchestration
Dimension
Choreography
Orchestration
Flow visibility
Implicit — exists only in events
Explicit — saga state queryable
Coupling
Services know only their events
Services coupled to orchestrator API
Failure handling
Each service handles its compensation
Orchestrator drives compensation in reverse
Debugging
Correlate events across service logs
Query saga state table directly
Scalability
No central bottleneck
Orchestrator can become bottleneck
Best for
2-3 step linear flows
5+ steps, branching, long waits
Tooling
Kafka events, Spring @KafkaListener
Temporal, Axon, Spring State Machine
Operational complexity
Low (no extra service)
High (orchestrator service to operate)
⚙ Quick Reference
2 commands from this guide
File
Command / Code
Purpose
PersistenceUserConfig.java
@Configuration
Why Your EntityManagerFactory Setup Will Fail in Production
SagaRollbackTest.java
@Test
Testing Cross-Database Transactions
Key takeaways
1
Database-per-service is the foundational data rule of microservices
without it, independent deployability is impossible regardless of API boundary quality
2
The Outbox pattern converts a dual-write problem into a single local transaction, guaranteeing at-least-once event delivery without distributed transactions
3
Choose saga choreography for simple linear flows; choose orchestration when you need visibility, branching, or long-running waits
4
Every event consumer must be idempotent
at-least-once delivery is the guarantee of all practical message brokers
5
CQRS read models are disposable and rebuildable from the event stream
optimise them aggressively for query patterns without affecting the write side
6
Migrate from shared DB using the strangler fig pattern
dual-write → migrate reads → cut over writes → drop old table, with feature flags at every step
Common mistakes to avoid
6 patterns
×
Sharing a database but calling it 'microservices'
Symptom
Teams must coordinate deployments whenever schema changes; one service's slow query causes latency spikes in unrelated services
Fix
Apply the strangler fig pattern to extract service-owned schemas; start with the easiest seams (tables only one team touches)
×
Publishing events directly to Kafka inside a @Transactional method without the Outbox pattern
Symptom
Events occasionally not published (transaction committed but Kafka send failed) or published twice (message sent but DB rolled back on retry)
Fix
Write to an outbox table in the same transaction; use Debezium CDC or a polling relay to publish to Kafka
×
Not including compensating transactions in saga design
Symptom
Saga fails halfway — inventory reserved but payment never charged; order stuck in PENDING state indefinitely
Fix
For every forward step in a saga, define and implement its compensating transaction before the saga goes to production
×
Non-idempotent event consumers
Symptom
Duplicate processing causes double charges, duplicate emails, or incorrect inventory counts after a Kafka partition rebalance
Fix
Add a processed_events table; check event ID before processing; store the check and the side effect in the same local transaction
×
Read models that make synchronous API calls during event handling
Symptom
Read model projector falls behind during dependent service outages; projection lag grows indefinitely
Fix
Enrich events at the source — include denormalised data in the event payload so projectors never need to call other services
×
Using 2PC (XA transactions) across services instead of sagas
Symptom
Distributed transaction coordinator becomes a single point of failure; locks held across services cause cascading timeouts under load
Fix
Replace 2PC with sagas; accept eventual consistency and design compensating transactions for every step
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
Why does a shared database violate the principles of microservices?
Q02SENIOR
Explain the Outbox pattern and why it's needed.
Q03SENIOR
When would you choose choreography over orchestration for a saga?
Q04SENIOR
How do you handle a saga that fails halfway through? Walk me through com...
Q05SENIOR
What is CQRS and how does it complement the database-per-service pattern...
Q06SENIOR
How do you ensure eventual consistency does not cause incorrect business...
Q07SENIOR
How would you migrate a monolith with a shared database to database-per-...
Q08SENIOR
What is CDC (Change Data Capture) and why is it preferred over polling f...
Q01 of 08JUNIOR
Why does a shared database violate the principles of microservices?
ANSWER
A shared database creates schema coupling (one team's column rename forces coordination with all teams that query that column), deployment coupling (schema migrations require coordinated release windows), and load coupling (one service's expensive query degrades all others). Independent deployability — the core microservices promise — is impossible when the data layer is shared. True microservices require data ownership: each service controls its schema, connection strings, and database lifecycle.
Q02 of 08SENIOR
Explain the Outbox pattern and why it's needed.
ANSWER
The Outbox pattern solves the dual-write problem: you cannot atomically update a database and publish to a message broker without a distributed transaction. The solution is to write the event to an 'outbox' table in the same local database transaction as the business update. A separate relay process (polling publisher or CDC with Debezium) reads unpublished outbox rows and publishes them to Kafka, guaranteeing at-least-once delivery. Consumers must be idempotent to handle potential duplicates.
Q03 of 08SENIOR
When would you choose choreography over orchestration for a saga?
ANSWER
Choose choreography for simple, linear, 2-4 step sagas where the flow is unlikely to change and teams value simplicity — no extra service to operate, no central bottleneck. Choose orchestration when the saga has 5+ steps, requires conditional branching (retry payment N times before cancelling), involves long-running waits (human approval), or business stakeholders need visibility into saga state. Orchestration tools like Temporal provide durable workflows with built-in retry, timeout, and state querying.
Q04 of 08SENIOR
How do you handle a saga that fails halfway through? Walk me through compensating transactions.
ANSWER
When step N of a saga fails, you must undo steps 1 through N-1 in reverse order using compensating transactions. Each compensation must be idempotent (safe to run multiple times) and must not depend on the state of the failing step. For example: if an order saga fails at payment, the compensating transactions are (1) release inventory reservation, (2) update order status to CANCELLED. In a choreographed saga, the payment service publishes PaymentFailed; the inventory service listens and releases. In an orchestrated saga, the orchestrator explicitly calls each compensation endpoint in reverse order.
Q05 of 08SENIOR
What is CQRS and how does it complement the database-per-service pattern?
ANSWER
CQRS separates the write model (optimised for consistency and transactional integrity) from the read model (optimised for query performance and denormalisation). In a microservices context, services publish domain events on every state change. Read-side projectors subscribe to these events and maintain denormalised read models for specific query patterns. Read models are disposable — they can be dropped and rebuilt by replaying the event stream. This means read model schema changes are trivial and do not affect the write side.
Q06 of 08SENIOR
How do you ensure eventual consistency does not cause incorrect business outcomes in a financial system?
ANSWER
Financial operations that require strong consistency should remain within a single service's ACID transaction boundary where possible. When cross-service coordination is unavoidable, use sagas with idempotency keys — each saga step includes a unique key so that retries do not double-charge. For ledger accuracy, use append-only ledger entries (never update, only insert DEBIT/CREDIT rows) and compute balances at read time from the immutable log. Reconciliation jobs run hourly/daily to detect and alert on inconsistencies. The key insight is that 'eventual' should mean milliseconds to seconds, not minutes.
Q07 of 08SENIOR
How would you migrate a monolith with a shared database to database-per-service without downtime?
ANSWER
Use the strangler fig pattern. Phase 1: Identify domain seams — tables accessed by only one business domain. Phase 2: Stand up the new service with its own database. Implement dual-write in the monolith (write to both the shared table and the new service's outbox). Run for 2-4 weeks and compare outputs. Phase 3: Migrate reads — switch consumers to call the new service's API. Phase 4: Cut over writes — the shared table becomes read-only, then is dropped. Each phase is behind a feature flag for instant rollback. Cross-domain joins must be converted to read models or API calls before the table is dropped.
Q08 of 08SENIOR
What is CDC (Change Data Capture) and why is it preferred over polling for the Outbox relay?
ANSWER
CDC reads the database's write-ahead log (WAL for Postgres, binlog for MySQL) to capture every committed change without querying the database. Debezium is the standard Spring/Kafka tool. It is preferred over polling because: (1) zero polling overhead — no SELECT queries every 100ms, (2) near-zero latency — events captured within milliseconds of commit, (3) works even if the application is down, (4) scales to millions of events per second without impacting the application database. The trade-off is operational complexity: Debezium requires Kafka Connect infrastructure and careful offset management.
01
Why does a shared database violate the principles of microservices?
JUNIOR
02
Explain the Outbox pattern and why it's needed.
SENIOR
03
When would you choose choreography over orchestration for a saga?
SENIOR
04
How do you handle a saga that fails halfway through? Walk me through compensating transactions.
SENIOR
05
What is CQRS and how does it complement the database-per-service pattern?
SENIOR
06
How do you ensure eventual consistency does not cause incorrect business outcomes in a financial system?
SENIOR
07
How would you migrate a monolith with a shared database to database-per-service without downtime?
SENIOR
08
What is CDC (Change Data Capture) and why is it preferred over polling for the Outbox relay?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
Can two microservices share the same PostgreSQL cluster but use different schemas?
This is a valid interim step that eliminates schema coupling while keeping operational overhead low. However, it does not provide full isolation: a long-running transaction in one schema can hold locks affecting others, a cluster upgrade requires coordinated downtime, and a runaway query can exhaust shared connection pools. For teams early in the microservices journey it is acceptable; for mature services with different SLAs, separate clusters are the goal.
Was this helpful?
02
How do I query data across multiple services without a shared database?
Three approaches: (1) API composition — call each service, join results in application memory (good for small datasets, adds latency); (2) CQRS read model — a projector subscribes to events from both services and maintains a denormalised read model (best for repeated queries); (3) API gateway aggregation — the gateway fans out requests and assembles the response (suitable for BFF patterns). Avoid cross-service database joins entirely.
Was this helpful?
03
What happens if a saga compensating transaction also fails?
This is the 'double failure' problem. The standard approach is: retry the compensating transaction with exponential backoff and jitter. If retries are exhausted, send the saga instance to a dead-letter state and alert an on-call engineer. The engineer investigates and manually triggers the compensation or uses an admin endpoint to force-complete the saga. This is why saga state visibility (orchestration) is valuable for complex, high-stakes sagas — you can query the exact step and error.
Was this helpful?
04
How do I handle schema changes in event payloads?
Use Avro or Protobuf with a Schema Registry (Confluent Schema Registry) for forward and backward compatibility enforcement. For JSON events, follow the rule: add fields (backward compatible, consumers ignore unknowns), never remove or rename fields without a versioning strategy. Use event versioning: OrderPlacedV1, OrderPlacedV2. Consumers subscribe to both during transition. After all consumers are on V2, deprecate V1.
Was this helpful?
05
Is the Outbox pattern necessary if I use Kafka transactions?
Kafka transactions (producer transactions with transactional.id) guarantee exactly-once delivery between Kafka topics, but they do not coordinate with your relational database transaction. If you write to the DB, then publish to Kafka in a Kafka transaction, a crash between the DB commit and the Kafka commit still loses the event. The Outbox pattern is the only reliable way to guarantee that a DB commit and a Kafka publish are atomic from the application's perspective.
Was this helpful?
06
How long should eventual consistency take in a well-designed system?
In a Kafka-based system with Debezium CDC, the typical propagation time from DB commit to read model update is 50-200ms under normal load. This is imperceptible to users. Under high load or consumer lag, it may grow to seconds. Design your monitoring to alert if consumer lag exceeds 1000 messages or if the read model is more than 30 seconds behind — these indicate the projector needs to scale up.