Shared Database Anti-Pattern in Microservices: Why It Hurts and How to Fix It
The shared database anti-pattern is when two or more microservices read and write the same database tables directly. It looks convenient, but it ties the services together through the table layout, so they can no longer be changed, deployed or scaled independently. The usual fix is to give each service its own data and share it only through APIs or events.
Teams often fall into this trap while splitting a monolith, because the database is the hardest part to divide. This tutorial shows the hidden costs with Java 21 examples and how to move away safely.
1. What Makes It an Anti-Pattern
An anti-pattern is a solution that looks reasonable at first but causes more problems than it solves.
Picture a grocery delivery company. It has an Order service, a Customer service and an Inventory service, each owned by a different team. All three connect to one PostgreSQL database. The Order service reads the customers table to find a phone number for delivery messages, and it updates stock rows when an order is placed.
The problem is ownership. The customers table now has three users, so its column names, types and meanings have become a public contract. But nobody wrote that contract down, and the Customer team may not even know who depends on it. This is called hidden coupling: services are connected in ways that their code and APIs don't show.
So the main promise of microservices, that each team can change and deploy on its own, is broken at the data layer.
2. How the Shared Database Anti-Pattern Hurts
2.1 Schema Changes Break Other Teams
A schema is the structure of the database: tables, columns and their types. If the Customer team renames a column, splits a table or changes a type, every service that reads it can break. And the Customer team's own tests will still pass, because the broken code lives in another repository.
2.2 Deployments Must Be Coordinated
To change a shared table safely, several teams must update their code in the right order and release together. Independent deployment, the main reason for microservices, is gone.
2.3 One Database, One Set of Limits
All services share the same CPU, memory, disk and connection limit. A connection is an open session between an application and the database. Each database server accepts only a limited number of them. A heavy report from one service can slow down checkout for everyone. This is called the noisy neighbour problem.
2.4 Locks Cross Service Boundaries
Databases use locks to stop two transactions from changing the same row at the same time. If a reporting job holds locks on stock rows during a long transaction, the Order service waits, even though the two services have nothing else to do with each other.
2.5 No Freedom of Technology
Inventory might suit Redis and Search might suit Elasticsearch, but with a shared database everyone is stuck with one technology.
3. The Alternative: Data Ownership
The standard fix is the Database per Service pattern. Each service owns its data, and only that service may read or write it. Other services get the data through:
- APIs, for data needed at the moment of the request ("What is the price of this item right now?").
- Events, for data that other services want to keep a copy of ("Customer C-7 changed their phone number"). The owner publishes a message to a broker such as Kafka, and interested services update their own local copy.
"Separate database" does not have to mean a separate server. Separate schemas (named groups of tables) on one server, each with its own database user and permissions, are often enough, as long as no service can touch another service's tables. What matters is ownership, not the hardware.
4. Code Examples
These programs use only the JDK (Java 21) and simulate the database, so you can run them anywhere.
4.1 Example 1: A Column Rename Breaks Another Team
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class SharedSchemaCouplingDemo {
// A tiny stand-in for one database table that two services both use
static class CustomersTable {
private final List<String> columns = new ArrayList<>(List.of("id", "name", "phone"));
private final List<Map<String, String>> rows = new ArrayList<>();
void insert(Map<String, String> row) {
rows.add(new LinkedHashMap<>(row));
}
void renameColumn(String from, String to) { // like ALTER TABLE customers RENAME COLUMN ...
columns.set(columns.indexOf(from), to);
rows.forEach(row -> row.put(to, row.remove(from)));
}
String select(String column, String id) {
if (!columns.contains(column)) {
throw new IllegalStateException("ERROR: column \"" + column + "\" does not exist");
}
return rows.stream().filter(r -> r.get("id").equals(id)).findFirst().orElseThrow().get(column);
}
}
// Written by the Order team, but it reads the Customer team's table directly
static void sendDeliverySms(CustomersTable customers, String customerId, String orderId) {
try {
String phone = customers.select("phone", customerId);
System.out.println("[order-service] SMS to " + phone + ": order " + orderId + " is out for delivery");
} catch (IllegalStateException e) {
System.out.println("[order-service] SMS for order " + orderId + " FAILED: " + e.getMessage());
}
}
public static void main(String[] args) {
var customers = new CustomersTable();
customers.insert(Map.of("id", "C-7", "name", "Meera", "phone", "+91-98450-11223"));
sendDeliverySms(customers, "C-7", "ORD-501");
System.out.println("[customer-service] v2 deployed: renamed phone -> phone_e164 (all customer tests pass)");
customers.renameColumn("phone", "phone_e164");
sendDeliverySms(customers, "C-7", "ORD-502"); // a different team's service breaks
}
}
[order-service] SMS to +91-98450-11223: order ORD-501 is out for delivery
[customer-service] v2 deployed: renamed phone -> phone_e164 (all customer tests pass)
[order-service] SMS for order ORD-502 FAILED: ERROR: column "phone" does not exist
The Customer team changed their own table and their tests passed, but the Order service broke. In production, this looks like an incident after an unrelated deployment, which is very hard to trace.
4.2 Example 2: The Connection Budget Nobody Planned
This is the pitfall most tutorials skip. Each Spring Boot pod uses HikariCP, whose default pool size is 10. Because minimumIdle defaults to the same value, every pod opens all 10 connections as soon as it starts.
import java.util.List;
import java.util.concurrent.Semaphore;
public class ConnectionBudgetDemo {
record Service(String name, int pods) { }
static final int MAX_CONNECTIONS = 100; // PostgreSQL default max_connections
static final int RESERVED = 3; // default superuser_reserved_connections
static final int POOL_SIZE = 10; // HikariCP default maximumPoolSize (minimumIdle defaults to the same)
public static void main(String[] args) {
var sharedDb = new Semaphore(MAX_CONNECTIONS - RESERVED);
List<Service> services = List.of(
new Service("order-service", 3), new Service("payment-service", 2),
new Service("inventory-service", 3), new Service("customer-service", 2),
new Service("reporting-service", 1));
int needed = 0;
for (Service s : services) {
for (int pod = 1; pod <= s.pods(); pod++) {
needed += POOL_SIZE;
int opened = 0;
while (opened < POOL_SIZE && sharedDb.tryAcquire()) { // the pool fills up at startup
opened++;
}
String result = opened == POOL_SIZE ? "ok"
: "FATAL: sorry, too many clients already";
System.out.printf("%-17s pod-%d opened %2d/%d %s%n", s.name(), pod, opened, POOL_SIZE, result);
}
}
System.out.println("Connections wanted: " + needed + ", available to apps: " + (MAX_CONNECTIONS - RESERVED));
}
}
order-service pod-1 opened 10/10 ok
order-service pod-2 opened 10/10 ok
order-service pod-3 opened 10/10 ok
payment-service pod-1 opened 10/10 ok
payment-service pod-2 opened 10/10 ok
inventory-service pod-1 opened 10/10 ok
inventory-service pod-2 opened 10/10 ok
inventory-service pod-3 opened 10/10 ok
customer-service pod-1 opened 10/10 ok
customer-service pod-2 opened 7/10 FATAL: sorry, too many clients already
reporting-service pod-1 opened 0/10 FATAL: sorry, too many clients already
Connections wanted: 110, available to apps: 97
Eleven pods want 110 connections, but PostgreSQL allows only 97 for applications by default. The last pods to start, including the reporting service, cannot connect at all, with PostgreSQL's real error message.
It gets worse during a rolling deployment, where new pods start before old ones stop. For a short time, a service needs up to twice its usual connections. Scaling one service up can therefore take down an unrelated one.
4.3 Example 3: Owning Data and Sharing It Through Events
Here the Customer service owns phone numbers and publishes an event when one changes. The Order service keeps its own read-only copy.
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.Queue;
public class EventReplicatedCopyDemo {
// The public contract: an event, not a table layout
record CustomerContactChanged(String customerId, String phone, long version) { }
static class CustomerService {
private final Map<String, String> phoneE164ById = new HashMap<>(); // private storage, free to change
private final Map<String, Long> versions = new HashMap<>();
private final Queue<CustomerContactChanged> outbox;
CustomerService(Queue<CustomerContactChanged> outbox) {
this.outbox = outbox;
}
void changePhone(String id, String phone) {
phoneE164ById.put(id, phone);
long version = versions.merge(id, 1L, Long::sum);
outbox.add(new CustomerContactChanged(id, phone, version)); // publish the change
}
}
static class OrderService {
private final Map<String, CustomerContactChanged> contactCopy = new HashMap<>(); // local read-only copy
void on(CustomerContactChanged event) {
var current = contactCopy.get(event.customerId());
if (current != null && current.version() >= event.version()) { // late or duplicate delivery
System.out.println("[order-service] ignored stale event v" + event.version()
+ " (already have v" + current.version() + ")");
return;
}
contactCopy.put(event.customerId(), event);
}
void sendDeliverySms(String customerId, String orderId) {
String phone = contactCopy.get(customerId).phone(); // no call to another service's database
System.out.println("[order-service] SMS to " + phone + ": order " + orderId + " is out for delivery");
}
}
public static void main(String[] args) {
Queue<CustomerContactChanged> broker = new ArrayDeque<>();
var customers = new CustomerService(broker);
var orders = new OrderService();
customers.changePhone("C-7", "+919845011223");
broker.forEach(orders::on);
var firstEvent = broker.poll();
orders.sendDeliverySms("C-7", "ORD-601");
customers.changePhone("C-7", "+917760099881"); // event published but not yet consumed
orders.sendDeliverySms("C-7", "ORD-602"); // eventual consistency: still the old number
broker.forEach(orders::on);
broker.clear();
orders.sendDeliverySms("C-7", "ORD-603");
orders.on(firstEvent); // the broker re-delivers an old event
orders.sendDeliverySms("C-7", "ORD-604");
}
}
[order-service] SMS to +919845011223: order ORD-601 is out for delivery
[order-service] SMS to +919845011223: order ORD-602 is out for delivery
[order-service] SMS to +917760099881: order ORD-603 is out for delivery
[order-service] ignored stale event v1 (already have v2)
[order-service] SMS to +917760099881: order ORD-604 is out for delivery
Three things to notice:
- Freedom to change: the Customer service can rename its internal storage at any time. Only the event format is a contract, and it can be versioned on purpose.
- Eventual consistency: order ORD-602 used the old number, because the event had not been processed yet. The copy becomes correct a little later. Your design must accept this short delay, or call an API when the data must be exact.
- Stale and duplicate events: message brokers can deliver an event twice or late. The
versioncheck stops the old event from overwriting newer data, which is a bug many first implementations have.
5. Comparison Table
| Shared database | Schema per service (shared server) | Database per service | |
|---|---|---|---|
| Who can touch a table | Any service | Only the owner (enforced by permissions) | Only the owner |
| Independent schema changes | No | Yes | Yes |
| Resource isolation | None | Partial (same CPU, memory, connections) | Full |
| Cross-service joins | Easy SQL joins | Not allowed | Not allowed |
| Consistency | ACID transactions across services | Per service, sagas across services | Per service, sagas across services |
| Cost and operations | Lowest | Low | Highest |
6. Common Mistakes and Pitfalls
1. A "read-only" user for another service's tables. Giving the Order service read-only access to customers still makes the table layout a contract. Read access couples you just as much as write access.
2. Two migration tools in one schema. If two services run Flyway against the same schema, each sees migrations in the shared flyway_schema_history table that it doesn't have locally, and validation fails at startup. Each service needs its own schema and its own migration history.
3. Integration through triggers or stored procedures. A trigger in one service's table that writes into another service's table is a shared database in disguise, and it is invisible in the application code.
4. Splitting the code but not the data. Teams often split a monolith into ten services that still use the old database. They get the costs of distributed systems (network calls, more deployments) without the benefits.
5. Writing to the database and the broker separately. If the service saves a change and then crashes before publishing the event, other services never hear about it. This is the dual-write problem, usually solved with the Transactional Outbox pattern.
7. Best Practices
- Give every table exactly one owning service, and write that ownership down.
- Enforce it with permissions: one database user per service, with grants only on its own schema.
- Share data through APIs or events, never through tables.
- Version your event and API contracts, and make consumers ignore unknown fields.
- Design for eventual consistency: show "updating…" states, and use the Saga pattern for business processes that span services.
- Split gradually: move one table at a time, starting with tables used by only one service.
- Size connection pools on purpose while the database is still shared, using the total across all pods, including the extra pods during deployments.
8. Summary
- The shared database anti-pattern happens when several services use the same tables, turning the schema into an unwritten contract.
- It breaks independent deployment, causes noisy-neighbour slowdowns, and lets locks and connection limits cross service boundaries.
- HikariCP opens its full pool at startup by default, so a shared database can run out of connections as pods are added.
- The fix is data ownership: one owner per table, and data shared only through APIs and events.
- Separate schemas on one server are fine if permissions stop cross-service access.
- Event-based copies are eventually consistent and must handle late or duplicate events.
Avoiding the shared database anti-pattern is what lets microservices stay truly independent. Treat each service's data as private, and share it only through contracts you design on purpose.
Shared Database Anti-Pattern: Interview Questions
Common interview questions on Shared Database Anti-Pattern, from basic to scenario-based, with short answers.
Basic questions
Q. Is a shared database always wrong?
Answer: No. It is a trade-off, and some catalogues even list "shared database" as a pattern with clear drawbacks. It can be acceptable as a temporary step while splitting a monolith, or for a small system with one team. It becomes a problem when separate teams need to change and deploy their services independently.
Q. What is Change Data Capture (CDC), and how does it help?
Answer: CDC reads changes from the database's own transaction log (such as PostgreSQL's WAL or MySQL's binlog) and turns them into events. Tools like Debezium do this without changing application code. It is a common way to implement the outbox pattern or to stream data out of a legacy database during a migration.
Q. Does database per service mean every service uses a different database technology?
Answer: No. Each service may pick the best store for its needs, which is called polyglot persistence, but many teams use PostgreSQL for almost everything. The rule is about ownership and isolation, not variety.
Intermediate questions
Q. Without a shared database, how do you keep a business process consistent across services?
Answer: Use a saga: a sequence of local transactions, one per service, where each step publishes an event that triggers the next. If a step fails, earlier steps are undone with compensating actions, such as refunding a payment. Sagas give eventual consistency, not one big ACID transaction.
Q. How do you enforce schema-per-service on a shared PostgreSQL server?
Answer: Create one schema and one login role per service, and grant each role rights only on its own schema.
CREATE SCHEMA orders AUTHORIZATION order_svc;
REVOKE ALL ON SCHEMA customers FROM order_svc;
In Spring Boot, each service then points its own spring.datasource.username and Flyway configuration at its own schema.
Q. How do you split a monolith's database step by step?
Answer: First, make sure each table is accessed only through one module's code inside the monolith. Then extract one service and its tables at a time, keeping a temporary sync (often CDC) while other code still needs the old copy. Remove the old access paths once every consumer uses the new API or events. Starting with tables that have the fewest readers keeps the risk low.
Q. What is the expand-and-contract technique for schema changes?
Answer: It changes a schema in safe steps instead of one breaking change. First expand: add the new column and write to both old and new. Then move all readers to the new column. Finally contract: remove the old column. Each step can be deployed on its own without downtime.
Q. How do you handle foreign keys between services?
Answer: You can't use database foreign keys across service databases. Store the other service's ID as a plain value, and check it through an API call or an event-fed copy when needed. Deletions are handled with events, for example marking orders as "customer closed" when a CustomerDeleted event arrives.
Q. What about shared reference data like country codes or currencies?
Answer: Small, rarely changing data can simply be copied into each service, through a shared library, configuration or a startup load. A dedicated reference-data service with caching is another option. Duplicating static data is cheap and far safer than sharing tables.
Tricky and scenario questions
Q. How do you show data from several services on one screen without SQL joins?
Answer: Use API composition: a gateway or aggregator calls each service and combines the results in memory. If that is too slow or too frequent, build a CQRS read model: a separate, query-optimised store that is updated from events published by each service.
Q. Scenario: The analytics team wants to query all six service databases directly for dashboards. What do you suggest?
Answer: Don't let reporting queries run against production service databases, because that recreates the shared database problem and adds heavy load. Instead, stream events or CDC changes into a data warehouse or data lake, where analysts can join everything freely. The services stay independent, and reports cannot slow down checkout.
Q. Scenario: After splitting databases, the Order service calls the Customer service on every request, and latency doubled. What can you do?
Answer: Keep a local copy of the customer fields the Order service needs, updated from Customer events, so most requests need no remote call. Cache API responses for data that changes rarely. Only call the owner synchronously when you need guaranteed-fresh data, such as checking a credit limit.
Q. Why is the shared database also a scaling problem?
Answer: All services compete for one database's CPU, memory, I/O and connections, and you cannot scale or tune it for one service's needs. Fast checkout queries and heavy reporting queries need different indexes and settings. With separate databases, each one can be sized, tuned and replicated for its own workload.