00:00

Shared Database Anti-Pattern in Microservices: Why It Hurts and How to Fix It

Last updated:

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.
Shared database compared with database per serviceLeft: the Order, Customer and Inventory services all read and write one shared database. Right: each service has its own database. The Customer service writes to the Customer DB and publishes a CustomerContactChanged event to a message broker. The Order service consumes the event and updates the customer contact copy kept in the Order DB. When it needs fresh data, the Order service calls the Inventory service API, and the Inventory service uses its own Inventory DB. Shared database (anti-pattern) Order service Customer service Inventory service One shared database every service reads and writes the same tables Database per service Customer service Customer DB Message broker Order service Order DB + customer contact copy Inventory service Inventory DB CustomerContactChanged event consumed, local copy updated API call when fresh data is needed
On the left, all services use the same tables; on the right, each service owns its database and shares data only through events and API calls.

"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

SharedSchemaCouplingDemo.java
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
    }
}
Output
[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.

ConnectionBudgetDemo.java
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));
    }
}
Output
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.

EventReplicatedCopyDemo.java
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");
    }
}
Output
[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 version check stops the old event from overwriting newer data, which is a bug many first implementations have.

5. Comparison Table

Shared databaseSchema per service (shared server)Database per service
Who can touch a tableAny serviceOnly the owner (enforced by permissions)Only the owner
Independent schema changesNoYesYes
Resource isolationNonePartial (same CPU, memory, connections)Full
Cross-service joinsEasy SQL joinsNot allowedNot allowed
ConsistencyACID transactions across servicesPer service, sagas across servicesPer service, sagas across services
Cost and operationsLowestLowHighest

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.