Microservices Architecture & Design

Database Per Service

18 min Lesson 4 of 12

Database Per Service

One of the most consequential decisions in a microservices system is the rule that each service owns its data store exclusively. No other service may connect to that database directly. This principle — Database Per Service — is not a nice-to-have; it is the structural guarantee that allows services to evolve independently. Without it, the decoupling that microservices promise is largely fictional.

Why Shared Databases Are Dangerous

In a monolith, every module reads and writes the same schema. It feels convenient. The moment you extract services but leave a shared database, you have distributed the runtime without distributing the coupling. Now you have the worst of both worlds: the operational overhead of distributed processes plus the tight coupling of a monolith.

Concrete problems that arise immediately:

  • Schema change paralysis. Renaming a column in the orders table requires coordinating every service that touches it. Deployments become a system-wide event instead of a per-team decision.
  • Unexpected load and contention. A heavy query in the reporting service acquires locks or chews CPU, degrading the order service that shares the same host.
  • Uncontrolled data coupling. A service can read or mutate data it logically should never touch. Data contracts vanish; bugs become non-local.
  • Security blast radius. A compromised service credential gives an attacker direct SQL access to every table in the shared instance — orders, payments, users, everything.
The "shared database" shortcut always comes due. Teams that skip Database Per Service during a decomposition almost universally report that the shared database becomes the bottleneck, the most-feared migration target, and the largest source of production incidents within six to eighteen months.

What the Pattern Actually Means

Database Per Service does not mandate a separate physical server for every service (though that is allowed). It mandates a separate logical isolation boundary: the data for a service is reachable only through that service's API. The isolation can be implemented at several levels:

  1. Separate schema / separate database on a shared database server — the minimum viable approach and common in early decompositions.
  2. Separate database server — full physical isolation; required when services have divergent SLA, scaling, or compliance requirements.
  3. Polyglot persistence — each service picks the storage technology best suited to its workload: a relational database for the order service, a document store for the catalog, Redis for the session service, a time-series database for the metrics service.
The rule is about access, not topology. Two services may happen to run queries against the same physical PostgreSQL server, as long as they connect to different schemas with different credentials and no cross-schema queries exist. The moment Service B runs SELECT * FROM service_a_schema.orders, the pattern is broken.

Implementing the Boundary in Spring Boot

In Spring Boot 3 the cleanest way to enforce this is to give each service its own DataSource configured in its own application.yml. There is no shared bean, no shared connection pool, no shared credentials.

Order service configuration (application.yml):

spring: datasource: url: jdbc:postgresql://order-db:5432/orderdb username: ${ORDER_DB_USER} password: ${ORDER_DB_PASSWORD} jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: orders

Inventory service configuration (application.yml):

spring: datasource: url: jdbc:postgresql://inventory-db:5432/inventorydb username: ${INVENTORY_DB_USER} password: ${INVENTORY_DB_PASSWORD} jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: inventory

Notice that the order service has no InventoryItem entity and the inventory service has no Order entity. They are completely separate JPA contexts. When the order service needs to check stock, it calls the inventory service's REST API — not the database.

// OrderService.java — in the order microservice @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final InventoryClient inventoryClient; // Feign / RestClient call @Transactional public Order placeOrder(CreateOrderRequest req) { // Ask the inventory service — never touch its database StockResponse stock = inventoryClient.checkStock(req.getProductId(), req.getQuantity()); if (!stock.isAvailable()) { throw new InsufficientStockException(req.getProductId()); } Order order = new Order(req.getProductId(), req.getQuantity(), OrderStatus.CONFIRMED); return orderRepository.save(order); } }
// InventoryClient.java — Feign declarative HTTP client @FeignClient(name = "inventory-service", url = "${services.inventory.url}") public interface InventoryClient { @GetMapping("/api/v1/stock/{productId}") StockResponse checkStock( @PathVariable String productId, @RequestParam int quantity ); }
Credentials from environment variables only. The placeholders ${ORDER_DB_USER} and ${ORDER_DB_PASSWORD} are never committed to source control. In Kubernetes they come from Secrets; in Docker Compose from an .env file. This directly limits the blast radius of a compromised service: an attacker who reads the order service's environment can only reach the order database.

Handling Data That Used to Be a JOIN

The most common objection to Database Per Service is: "But I need data from both services in one query." In a monolith you would write a SQL join. Distributed, you have three main options:

  • API Composition. The calling service (or a dedicated BFF/API Gateway) fetches from both services and merges the results in code. Correct for low-volume reads.
  • CQRS + Event-Driven Read Model. Services publish domain events; a separate read-service (or the same service's read side) maintains a denormalized projection optimised for query. The order service publishes OrderPlaced; the reporting service consumes it and stores a flat row that already contains the product name it received from the inventory service via a separate event. No join needed at query time.
  • Shared reference data. Truly static data (country codes, currency codes) can be published to a shared read-only store or replicated via events. It is owned by one service; all others cache a read-only copy.

Data Consistency Without Shared Transactions

A single ACID transaction spanning two databases is impossible without a distributed transaction protocol (2PC), which is slow and couples services at the protocol level. Instead, microservices embrace eventual consistency via the Saga pattern:

  1. Each step of a multi-service operation is a local transaction in one service's database.
  2. If a later step fails, compensating transactions undo the earlier steps.
  3. The system converges to a consistent state, but not atomically.

This is a deliberate trade-off: you gain deployment independence and fault isolation; you pay with more complex failure handling. Lesson 7 of this tutorial covers distributed data and consistency in full detail.

Security Implications

Database Per Service is also a security boundary. With separate credentials and network policies (each database only reachable by its own service's pod/container), a successful exploit of one service cannot directly exfiltrate another service's data. In regulated environments (PCI-DSS, HIPAA) this isolation is often a compliance requirement, not just an architectural preference.

Apply least privilege at the database level too. The order service's database user should have only SELECT, INSERT, UPDATE, DELETE on the orders schema — not DROP TABLE, not superuser access. Use REVOKE and role-based grants when provisioning the schema.

Summary

Database Per Service enforces a hard access boundary: service data is reachable only through that service's published API. This enables independent deployability, technology choice per service, and a meaningful security blast-radius limit. The cost is that cross-service queries become API compositions or event-driven read models, and cross-service mutations become Sagas rather than ACID transactions. The trade-off is intentional and fundamental — embrace it early rather than retrofitting it under production pressure.