Skip to content

Database per Service

You have decomposed your system into services aligned to business capabilities — orders, inventory, payments, shipping. Each service has its own codebase and its own team. Now you have to decide where each service’s data lives. The path of least resistance is to point them all at the same database you already had. It works on day one, and it quietly destroys everything decomposition was supposed to buy you.

When services share a database, they share a schema. The shipping team adds a column to the orders table; the orders team’s queries break. The inventory team wants to switch a table from PostgreSQL to a document store; they cannot, because four other services read it directly. A slow report run by one service exhausts the connection pool that every other service depends on.

Sharing a database recreates the monolith at the data layer. So the force at work is this: how do you let each service evolve its data model, choose its own storage technology, and scale on its own schedule, without other services silently depending on its internal tables?

Give each service exclusive ownership of its data. The data lives in a private database — a separate schema, a separate database instance, or a separate database server, depending on how strong an isolation you need. No other service is allowed to connect to it. The only way to read or change a service’s data is to go through that service’s published API or to consume the events it emits.

The database becomes an implementation detail of the service, just like its in-memory data structures. The team is free to change tables, indexes, and even the database engine, as long as the API contract stays stable.

flowchart TB
  subgraph Order[Order Service]
    OAPI[Order API]
    ODB[(PostgreSQL)]
    OAPI --> ODB
  end
  subgraph Shipping[Shipping Service]
    SAPI[Shipping API]
    SDB[(MongoDB)]
    SAPI --> SDB
  end
  SAPI -- HTTP / gRPC call --> OAPI
  Forbidden[Shipping reaching into Orders DB] -. NOT ALLOWED .-x ODB
  style Forbidden fill:#fee,stroke:#c00,stroke-dasharray: 5 5
A service may call another service's API, but never connect to its database

The pattern is structural rather than algorithmic, so the clearest illustration is configuration. Below, two services are wired so that each holds credentials for its own database only. Notice that the Shipping service has no connection string for the Orders database — it has only the Orders service’s URL.

# docker-compose excerpt — each service gets its own DB and its own credentials
services:
order-service:
image: shop/order-service:1.0
environment:
DATABASE_URL: postgres://order_user:secret@order-db:5432/orders
# No reference to any other service's database.
order-db:
image: postgres:16
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: secret
shipping-service:
image: shop/shipping-service:1.0
environment:
DATABASE_URL: mongodb://ship_user:secret@shipping-db:27017/shipping
# To learn about an order, Shipping calls the Order API, not its DB.
ORDER_SERVICE_URL: http://order-service:8080
shipping-db:
image: mongo:7
environment:
MONGO_INITDB_DATABASE: shipping

The boundary is also a permissions boundary. Even if someone knows the orders database hostname, the credentials simply are not present anywhere outside the Order service:

order-service --owns--> orders DB (order_user can connect)
shipping-service --owns--> shipping DB (ship_user can connect)
shipping-service --calls--> Order API --> orders DB (allowed, indirect)
shipping-service --X--> orders DB (blocked: no route, no credentials)

What you gain:

  • Loose coupling. Each team owns its schema and can refactor it freely. A change inside one database cannot break another service.
  • Polyglot persistence. A service can pick the right store for its job — relational for orders, document for shipping, a search index for the catalog.
  • Independent scaling and failure isolation. One service’s heavy query load or database outage no longer drags down every other service.

What it costs you:

  • No cross-service transactions. You cannot wrap an order insert and an inventory decrement in one atomic commit, because they live in different databases. Restoring consistency across services is exactly what the Saga pattern addresses.
  • No cross-service joins. A query that needs order, customer, and shipping data can no longer be one SQL statement. You assemble it instead with API Composition or CQRS.
  • Some duplicated data. Services often cache small slices of data they consume from others, which you must keep in sync.

The key insight: Database per Service does not solve consistency and querying — it deliberately removes the shortcuts so that you solve them explicitly and per-service, which is why the rest of this module exists.

  • Saga — consistency across the private databases without distributed transactions.
  • API Composition — answering queries that used to be a join.
  • CQRS — a read model that spans services efficiently.
What is the only legitimate way for one service to read another service's data?
Which benefit does Database per Service directly enable?
Database per Service removes cross-service joins. Which patterns restore querying across services?