Database Per Service: Achieving True Data Isolation in Microservices

When organizations begin their journey from a monolith to microservices, the database is often the last component to be decoupled. Teams split application code into independently deployable services, but those services continue sharing a single database, creating a hidden monolith that undermines every benefit microservices promise. The database-per-service pattern solves this problem by giving each service exclusive ownership of its data store, but the pattern introduces challenges that require deliberate architectural decisions around consistency, querying, and operational management.

This guide walks through the database-per-service pattern in depth, covering why shared databases fail, how to manage cross-service data access, strategies for maintaining consistency without distributed transactions, and practical approaches to migrating from a shared database to isolated stores.

Why Shared Databases Fail in Microservices

A shared database creates tight coupling between services in several ways. First, schema changes in one service can break another. If the Orders service modifies a column in the orders table, the Shipping service that reads from that table might fail. Second, services cannot be deployed independently because database migrations must be coordinated across teams. Third, a single slow query from one service can degrade performance for every other service sharing that database, as explained in our PostgreSQL performance tuning guide.

Beyond coupling, shared databases prevent teams from choosing the storage technology best suited to their workload. A search service might benefit from Elasticsearch, a recommendations service from a graph database, and a session service from Redis. When every service shares PostgreSQL, each makes compromises that add complexity and reduce performance.

"A shared database is the anti-pattern that turns microservices into a distributed monolith. You get all the operational complexity of a distributed system with none of the autonomy benefits."

The following diagram illustrates the coupling problem with a shared database versus the isolation achieved with database-per-service.

Shared Database (Anti-pattern) Orders Svc Users Svc Shipping Svc Inventory Svc Shared PostgreSQL Tight coupling Database Per Service Orders Svc PostgreSQL Users Svc PostgreSQL Shipping Svc MongoDB Inventory Svc Redis Loose coupling & polyglot persistence

The Database-Per-Service Pattern Explained

The database-per-service pattern mandates that each microservice owns its data exclusively. No other service can access that data directly through the database. Instead, services expose their data through well-defined API contracts. This ownership boundary ensures that internal schema changes remain local to the owning service, that services can be deployed independently, and that each service can choose the storage technology best suited to its workload.

Levels of Data Isolation

The pattern can be implemented at different levels of isolation, each offering different tradeoffs between operational simplicity and true independence:

  • Separate schemas within a shared instance: Each service uses its own schema (namespace) within one database server. This is the easiest to operate but offers weaker isolation since services share CPU, memory, and I/O.
  • Separate database instances on shared infrastructure: Each service has its own database on a shared cluster. Better isolation, with independent connection pools and query planners, but shared hardware resources.
  • Fully independent database servers: Each service runs its own database on dedicated infrastructure. Maximum isolation and independent scaling, but highest operational overhead.

For most teams, starting with separate schemas and graduating to separate instances as the system grows is a pragmatic path. The critical rule is that regardless of the isolation level, only the owning service accesses its data. Cross-service data access happens exclusively through APIs and events.

Data Ownership Boundaries

Drawing the right boundaries is the hardest part of the pattern. A useful heuristic is to align data ownership with business capabilities. The Orders service owns everything about order lifecycle. The Users service owns user profiles and authentication data. When you find that two services need to modify the same data, either the boundary is wrong or one service should own the data while the other consumes events about changes.

Here is an example of how the Orders service defines its schema, keeping only the data it truly owns:

-- orders-service/migrations/001_initial.sql
CREATE TABLE orders (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id UUID NOT NULL,        -- references Users svc, but no FK
    status      VARCHAR(20) NOT NULL DEFAULT 'pending',
    total_cents BIGINT NOT NULL,
    currency    VARCHAR(3) NOT NULL DEFAULT 'USD',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id    UUID NOT NULL REFERENCES orders(id),
    product_id  UUID NOT NULL,        -- references Catalog svc, no FK
    quantity    INT NOT NULL,
    unit_price  BIGINT NOT NULL
);

CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_items_order ON order_items(order_id);

Notice that customer_id and product_id reference entities in other services but have no foreign key constraints. This is intentional: cross-service foreign keys would create the tight coupling we are trying to eliminate. The Orders service stores just enough information (the IDs) to identify external entities and resolves details at query time through API calls or maintains local read-only copies through event consumption.

Cross-Service Queries: API Composition and CQRS

Without a shared database and JOINs, how do you retrieve data that spans multiple services? Two primary patterns address this need: API Composition and CQRS.

API Composition

An API Composition layer (often the API Gateway or a dedicated aggregator service) calls multiple downstream services and merges the results. This works well for simple read operations but struggles with complex filtering and pagination across services.

# api_gateway/composites/order_details.py
import asyncio
import httpx

async def get_order_details(order_id: str) -> dict:
    async with httpx.AsyncClient() as client:
        order_task = client.get(
            f"http://orders-svc/api/orders/{order_id}"
        )
        # Fire both requests concurrently
        order_resp, = await asyncio.gather(order_task)
        order = order_resp.json()

        # Now fetch customer and product details in parallel
        customer_task = client.get(
            f"http://users-svc/api/users/{order['customer_id']}"
        )
        product_tasks = [
            client.get(
                f"http://catalog-svc/api/products/{item['product_id']}"
            )
            for item in order["items"]
        ]

        results = await asyncio.gather(
            customer_task, *product_tasks
        )

        customer = results[0].json()
        products = {
            p.json()["id"]: p.json()
            for p in results[1:]
        }

        return {
            "order": order,
            "customer": {
                "name": customer["name"],
                "email": customer["email"],
            },
            "items": [
                {
                    **item,
                    "product_name": products[item["product_id"]]["name"],
                }
                for item in order["items"]
            ],
        }

CQRS (Command Query Responsibility Segregation)

For complex queries, CQRS separates the write model (commands) from the read model (queries). Each service publishes events when its data changes. A read-side projector listens to events from multiple services and builds denormalized read models optimized for specific query patterns. This approach is closely related to event-driven architecture and eliminates the need for synchronous API calls at read time.

# read-side/projectors/order_summary.py
from dataclasses import dataclass
from typing import Optional

@dataclass
class OrderSummaryProjection:
    order_id: str
    customer_name: str
    customer_email: str
    status: str
    total_cents: int
    item_count: int
    created_at: str

class OrderSummaryProjector:
    """Listens to events from Orders and Users services
    to maintain a denormalized read model."""

    def __init__(self, read_db):
        self.db = read_db

    async def handle_order_created(self, event: dict):
        self.db.upsert("order_summaries", {
            "order_id": event["order_id"],
            "customer_id": event["customer_id"],
            "status": "pending",
            "total_cents": event["total_cents"],
            "item_count": len(event["items"]),
            "created_at": event["timestamp"],
        })

    async def handle_customer_updated(self, event: dict):
        # Update all order summaries for this customer
        self.db.update_many(
            "order_summaries",
            filter={"customer_id": event["customer_id"]},
            update={
                "customer_name": event["name"],
                "customer_email": event["email"],
            },
        )

    async def handle_order_status_changed(self, event: dict):
        self.db.update(
            "order_summaries",
            filter={"order_id": event["order_id"]},
            update={"status": event["new_status"]},
        )

The tradeoff is eventual consistency: the read model lags behind the write model by the time it takes events to propagate and projections to update. For many use cases, this latency (typically milliseconds to a few seconds) is acceptable.

Data Consistency: The Saga Pattern

Distributed transactions (two-phase commit) do not scale well in microservices. They introduce tight coupling, reduce availability, and create lock contention. The Saga pattern provides an alternative: a sequence of local transactions coordinated through events or an orchestrator, with compensating transactions to handle failures.

Choreography-Based Sagas

In choreography, each service listens for events and decides locally whether to proceed or compensate. There is no central coordinator. This works well for simple flows with few steps. It aligns naturally with the patterns described in our guide to distributed systems patterns.

Choreography-Based Saga: Order Creation Orders Svc Payment Svc Inventory Svc Shipping Svc OrderCreated PaymentOK Reserved Compensation Flow (on failure) Refund Payment Release Stock Reject Order InventoryFailed PaymentRefunded Happy path event Compensating event

Orchestration-Based Sagas

In orchestration, a central Saga orchestrator controls the sequence of steps, telling each service what to do and handling compensation when a step fails. This is better suited for complex workflows with many steps, conditional logic, or parallel execution paths.

# sagas/create_order_saga.py
from enum import Enum
from typing import Optional

class SagaStep(Enum):
    CREATE_ORDER = "create_order"
    PROCESS_PAYMENT = "process_payment"
    RESERVE_INVENTORY = "reserve_inventory"
    ARRANGE_SHIPPING = "arrange_shipping"

class CreateOrderSaga:
    """Orchestrator for the order creation saga."""

    STEPS = [
        SagaStep.CREATE_ORDER,
        SagaStep.PROCESS_PAYMENT,
        SagaStep.RESERVE_INVENTORY,
        SagaStep.ARRANGE_SHIPPING,
    ]

    COMPENSATIONS = {
        SagaStep.ARRANGE_SHIPPING: "cancel_shipment",
        SagaStep.RESERVE_INVENTORY: "release_inventory",
        SagaStep.PROCESS_PAYMENT: "refund_payment",
        SagaStep.CREATE_ORDER: "reject_order",
    }

    def __init__(self, saga_id: str, order_data: dict):
        self.saga_id = saga_id
        self.order_data = order_data
        self.current_step = 0
        self.completed_steps: list[SagaStep] = []
        self.state = "running"

    async def execute(self, service_clients: dict):
        for step in self.STEPS:
            try:
                handler = getattr(self, f"_do_{step.value}")
                await handler(service_clients)
                self.completed_steps.append(step)
            except Exception as e:
                self.state = "compensating"
                await self._compensate(service_clients)
                self.state = "failed"
                raise SagaFailedError(
                    step=step, cause=e
                )
        self.state = "completed"

    async def _compensate(self, service_clients: dict):
        for step in reversed(self.completed_steps):
            comp_method = self.COMPENSATIONS.get(step)
            if comp_method:
                handler = getattr(self, f"_do_{comp_method}")
                await handler(service_clients)

    async def _do_create_order(self, clients):
        resp = await clients["orders"].post(
            "/api/orders", json=self.order_data
        )
        self.order_id = resp.json()["id"]

    async def _do_process_payment(self, clients):
        await clients["payments"].post(
            "/api/payments",
            json={
                "order_id": self.order_id,
                "amount": self.order_data["total_cents"],
            },
        )

    async def _do_reserve_inventory(self, clients):
        await clients["inventory"].post(
            "/api/reservations",
            json={
                "order_id": self.order_id,
                "items": self.order_data["items"],
            },
        )

    async def _do_arrange_shipping(self, clients):
        await clients["shipping"].post(
            "/api/shipments",
            json={
                "order_id": self.order_id,
                "address": self.order_data["shipping_address"],
            },
        )

The Transactional Outbox Pattern

Both saga variants rely on reliable event delivery. The transactional outbox pattern ensures that database writes and event publications are atomic. Instead of publishing events directly to a message broker, the service writes them to an outbox table within the same database transaction. A separate process (or change data capture tool like Debezium) reads the outbox and publishes events to the broker.

-- Inside the Orders service database
CREATE TABLE outbox (
    id          BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id   UUID NOT NULL,
    event_type     VARCHAR(100) NOT NULL,
    payload        JSONB NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at   TIMESTAMPTZ
);

-- Application code writes order + outbox event in one transaction
BEGIN;

INSERT INTO orders (id, customer_id, status, total_cents)
VALUES ($1, $2, 'pending', $3);

INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('Order', $1, 'OrderCreated', $4::jsonb);

COMMIT;

Polyglot Persistence: Choosing the Right Database Per Service

One of the biggest advantages of database-per-service is the freedom to choose the best storage technology for each workload. This is known as polyglot persistence. Here is a decision matrix for common service types:

Service Type Recommended Store Rationale
Transactional (Orders, Payments) PostgreSQL / MySQL ACID guarantees, mature tooling, complex queries
User Profiles, Content MongoDB / PostgreSQL Flexible schemas, document-oriented reads
Session / Cache Redis Sub-millisecond reads, TTL support, as covered in our Redis beyond caching guide
Search / Full-Text Elasticsearch / OpenSearch Inverted indices, relevance scoring
Recommendations / Graph Neo4j / Neptune Relationship traversal, pattern matching
Time-Series / Metrics TimescaleDB / InfluxDB Time-partitioned storage, downsampling
Event Store EventStoreDB / PostgreSQL Append-only, stream subscriptions

Polyglot persistence is not an excuse to use seven different databases from day one. Start with a sane default (PostgreSQL covers most workloads well) and introduce specialized stores only when a service has clear requirements that the default cannot meet efficiently.

Migrating from a Shared Database

Migrating from a shared database to database-per-service is a gradual process. Attempting a big-bang migration is risky and usually fails. Instead, follow the Strangler Fig pattern applied to data.

Step 1: Identify Data Boundaries

Map every table to the service that should own it. Tables accessed by multiple services need careful analysis: either one service owns the data and exposes it via API, or the table needs to be split along domain boundaries.

Step 2: Create a Data Access Layer

Wrap all direct database access in each service behind a repository or data access layer. This gives you a seam where you can later redirect reads and writes without changing business logic. This step mirrors the clean separation advocated in clean architecture.

Step 3: Introduce a Sync Mechanism

Before cutting the cord, set up change data capture (CDC) from the shared database to the service's new database. This keeps both databases in sync during the transition.

# Using Debezium connector configuration
{
  "name": "orders-outbox-connector",
  "config": {
    "connector.class":
      "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "shared-db.internal",
    "database.port": "5432",
    "database.user": "cdc_reader",
    "database.dbname": "monolith",
    "table.include.list": "public.orders,public.order_items",
    "topic.prefix": "orders-migration",
    "slot.name": "orders_cdc_slot",
    "publication.name": "orders_publication",
    "transforms": "route",
    "transforms.route.type":
      "io.debezium.transforms.ByLogicalTableRouter",
    "transforms.route.topic.regex": "(.*)orders(.*)",
    "transforms.route.topic.replacement": "orders-svc.$2"
  }
}

Step 4: Dual Writes and Shadow Reads

Have the service write to both the shared database and its new database. Read from the shared database but shadow-read from the new database and compare results. This validates that the new database contains correct data before switching over.

Step 5: Cut Over

Once shadow reads confirm data consistency for a sustained period, switch the service to read and write exclusively from its own database. Remove the sync mechanism and clean up the tables from the shared database.

Operational Concerns

Running many databases instead of one introduces operational complexity that must be addressed proactively.

Schema Migrations

Each service manages its own migrations independently. Use a migration tool that integrates with your CI/CD pipeline (Flyway, Alembic, Prisma Migrate) and run migrations as part of the service deployment. Never share migration state across services.

Backups and Recovery

Centralize backup orchestration even though databases are distributed. A Platform team should provide backup tooling that each service team configures. Point-in-time recovery becomes more complex because restoring a consistent state across services requires coordinating timestamps across independent stores.

Monitoring and Observability

Standardize database metrics collection across all service databases. Key metrics to track include connection pool utilization, query latency percentiles, replication lag, and storage growth. This is especially critical when tuning Node.js performance for services that make heavy database calls. Use a unified observability platform (Grafana, Datadog) with per-service dashboards and cross-service correlation.

# docker-compose.yml - standardized monitoring sidecar
services:
  orders-db:
    image: postgres:16
    environment:
      POSTGRES_DB: orders
    volumes:
      - orders-data:/var/lib/postgresql/data

  orders-db-exporter:
    image: prometheuscommunity/postgres-exporter
    environment:
      DATA_SOURCE_NAME: "postgresql://monitor:${MONITOR_PWD}@orders-db:5432/orders?sslmode=disable"
    ports:
      - "9187:9187"
    depends_on:
      - orders-db

  users-db:
    image: mongo:7
    volumes:
      - users-data:/data/db

  users-db-exporter:
    image: percona/mongodb_exporter:0.40
    command:
      - "--mongodb.uri=mongodb://monitor:${MONITOR_PWD}@users-db:27017"
    ports:
      - "9216:9216"
    depends_on:
      - users-db

Testing Strategies

Integration and contract testing become essential. Each service should have integration tests that verify its database interactions with a real database instance (using Testcontainers or similar). Contract tests ensure that the API each service exposes matches what consuming services expect. End-to-end saga tests should verify that multi-service workflows complete correctly under both success and failure scenarios.

When Not to Use Database Per Service

The pattern is not always the right choice. If your system is small enough that a single team manages all services and the rate of schema changes is low, the overhead of managing multiple databases may not be worth the isolation benefits. Similarly, if you have strong consistency requirements across entities that would span services, you may need to reconsider your service boundaries rather than fighting the CAP theorem with complex saga implementations.

Evaluate the pattern against your team structure, deployment cadence, and consistency requirements. Conway's Law applies: if one team owns multiple services, separate databases add friction without proportionate benefit. Use it when you genuinely need independent deployability, team autonomy, and the freedom to evolve data models independently.

Key Takeaways

  • The database-per-service pattern eliminates the hidden monolith that shared databases create, enabling true microservice independence.
  • Cross-service queries are solved through API Composition for simple cases and CQRS with event-driven projections for complex read models.
  • Sagas replace distributed transactions, with choreography for simple flows and orchestration for complex multi-step processes.
  • The transactional outbox pattern guarantees reliable event delivery without dual-write inconsistencies.
  • Polyglot persistence lets each service choose the optimal storage technology, but start simple and specialize only when needed.
  • Migrate gradually using the Strangler Fig approach: CDC, dual writes, shadow reads, then cutover.
  • Standardize operational tooling (monitoring, backups, migrations) across all service databases to manage the increased operational surface area.