Database per Service
Context
Section titled “Context”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.
Problem
Section titled “Problem”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?
Solution
Section titled “Solution”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 Example
Section titled “Example”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 credentialsservices: 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: shippingThe 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)Resulting context
Section titled “Resulting context”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.
Related patterns
Section titled “Related patterns”- 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.