Database Per Service Pattern in Microservices
Splitting an application into microservices is only half the job. If every service still reads and writes the same shared database, the services remain tied together and cannot change or scale on their own. The Database per Service pattern solves this by giving each microservice its own private data store. This tutorial explains the idea in simple terms, walks through an e-commerce example, shows how to build it with Spring Boot, and covers the challenges and the patterns that solve them.
The Core Idea
The Database per Service pattern says that each microservice owns and manages its own data. No other service may read or write that data directly. If another service needs the data, it must ask through the owner's public API (such as REST or gRPC) or listen to the events the owner publishes. Direct database connections between services are not allowed.
This is the opposite of a traditional monolith, where every module shares one large database.
Why Is This So Important?
With a shared database, services are tightly coupled through the tables. If one team needs to change a table, it must check with every other team that reads that table. A slow query from one service can slow down all the others, and nobody is sure who really owns which data. This blocks the independent, fast-moving teams that microservices are meant to enable.
The Database per Service pattern gives you:
- Loose Coupling: Services can be developed, deployed and scaled without affecting each other.
- Data Encapsulation: The data structure is a private detail of the service. It can change its schema, or even its database technology, without breaking anyone, as long as its API stays the same.
- Technology Freedom: Each service can pick the database that fits its job best: relational, document, key-value, graph or search.
- Clear Ownership: One team owns a service and its data from end to end, which speeds up development and decision-making.
- Independent Scaling and Fault Isolation: A busy service can get a bigger database, and a database problem in one service does not take the others down.
Shared Database vs Database per Service
| Aspect | Shared Database | Database per Service |
|---|---|---|
| Coupling | Services coupled through tables | Services coupled only through APIs and events |
| Schema changes | Need coordination across teams | Owner team changes freely |
| Transactions | Simple ACID transactions across data | Local transactions; Sagas across services |
| Queries | SQL joins across all data | API Composition or CQRS views |
| Scaling | Whole database scales together | Each database scales on its own |
| Failure impact | One database issue affects all services | Issues stay within one service |
| Operations | One database to run | Many databases to run and monitor |
Ways to Keep Data Private
"Database per service" does not always mean a separate database server for every service. What matters is that no other service can touch the data. There are three common levels of separation:
| Option | How It Works | Isolation | Cost |
|---|---|---|---|
| Private tables per service | Services share a database server but each owns its own set of tables; access is limited with database users and permissions | Low | Low |
| Schema per service | Each service has its own schema on a shared server, with its own database user | Medium | Low |
| Database server per service | Each service runs its own database instance or cluster | High | Higher |
Many teams start with schema per service to keep costs low, and move busy or critical services to their own servers when needed. Whichever option you choose, give each service its own database credentials so that it is technically impossible to read another service's tables.
Polyglot Persistence: The Right Database for Each Job
Because each service owns its data, each one can choose the best storage type. Using different database technologies in one system is called polyglot persistence.
| Service | Data Needs | Good Database Choice |
|---|---|---|
| Order Service | Transactions, strong consistency | PostgreSQL, MySQL |
| Product Catalog Service | Flexible attributes per product | MongoDB (document store) |
| Search Service | Full-text search and filters | Elasticsearch, OpenSearch |
| Cart / Session Service | Very fast reads and writes, short-lived data | Redis (key-value) |
| Recommendation Service | Relationships between users and products | Neo4j (graph) |
Use this freedom wisely: every extra technology needs skills, monitoring and backups. Choose a new database only when it brings a clear benefit.
A Concrete Example: E-Commerce Application
Imagine a simple online shop with three microservices:
- Order Service: Creates and manages orders.
- Customer Service: Manages customer details such as name, email and address.
- Product Catalog Service: Manages product details such as name, price and attributes.
The Wrong Way (Shared Database)
With a shared ecommerce_db, the Order Service might build an order summary with one SQL query that joins tables owned by other teams:
SELECT o.order_id, c.name, c.email, p.product_name, p.price, o.quantity
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE o.order_id = 123;
This looks convenient, but it creates a tightly coupled system. If the Customer team renames the name column to full_name, the Order Service breaks, even though the Order team changed nothing.
The Right Way (Database per Service)
Each service gets its own private database:
| Service | Private Database | What It Stores |
|---|---|---|
| Order Service | order_db (PostgreSQL) | orders table with order_id, customer_id, product_id, quantity, status |
| Customer Service | customer_db (PostgreSQL) | customers table with customer_id, name, email, address |
| Product Catalog Service | product_db (MongoDB) | Product documents with id, name, price, attributes |
Customer Service Order Service Product Catalog Service
| | |
v v v
[ customer_db ] [ order_db ] [ product_db ]
PostgreSQL PostgreSQL MongoDB
Services talk only through APIs and events, never through each other's database.
Scenario: Displaying an Order Summary Page
The front-end needs to show: Order #123 for John Doe (john@example.com): 1 x "Wireless Mouse" ($29.99).
Here is the flow:
- The front-end calls the Order Service API:
GET /orders/123/summary. - The Order Service reads order 123 from
order_db. It findscustomer_id = 456,product_id = 789,quantity = 1andstatus = SHIPPED. It does not have the customer's name or the product's name, only their IDs. - The Order Service calls the Customer Service:
GET /customers/456and receives{ "name": "John Doe", "email": "john@example.com" }. - At the same time, it calls the Product Catalog Service:
GET /products/789and receives{ "name": "Wireless Mouse", "price": 29.99 }. - The Order Service combines the three results and returns one response:
{
"orderId": 123,
"customerName": "John Doe",
"customerEmail": "john@example.com",
"productName": "Wireless Mouse",
"productPrice": 29.99,
"quantity": 1,
"status": "SHIPPED"
}
Combining data from several services in code like this is called the API Composition pattern. The next section shows how to build it.
Implementation with Spring Boot
Step 1: Give Each Service Its Own Database Connection
Each service has its own connection settings and its own credentials. The Order Service only knows about order_db:
spring:
application:
name: order-service
datasource:
url: jdbc:postgresql://localhost:5432/order_db
username: order_user # this user can access order_db only
password: ${ORDER_DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # schema is managed by Flyway migrations
flyway:
enabled: true
The Product Catalog Service uses a different technology and its own credentials:
spring:
application:
name: product-service
data:
mongodb:
uri: mongodb://product_user:${PRODUCT_DB_PASSWORD}@localhost:27017/product_db
Step 2: Store Only IDs for Data Owned by Others
The Order entity keeps customerId and productId as plain values. There is no @ManyToOne relationship and no foreign key to another service's table, because those tables live in other databases.
package com.javabytechie.order;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// References to data owned by other services: IDs only, no joins.
@Column(nullable = false)
private Long customerId;
@Column(nullable = false)
private String productId;
private int quantity;
@Enumerated(EnumType.STRING)
private OrderStatus status;
protected Order() {
}
public Order(Long customerId, String productId, int quantity) {
this.customerId = customerId;
this.productId = productId;
this.quantity = quantity;
this.status = OrderStatus.CREATED;
}
public Long getId() { return id; }
public Long getCustomerId() { return customerId; }
public String getProductId() { return productId; }
public int getQuantity() { return quantity; }
public OrderStatus getStatus() { return status; }
}
package com.javabytechie.product;
import java.math.BigDecimal;
import java.util.Map;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document(collection = "products")
public record Product(
@Id String id,
String name,
BigDecimal price,
Map<String, String> attributes) { // flexible fields, e.g. colour, size, DPI
}
Step 3: Combine Data with API Composition
The Order Service reads its own order, then fetches customer and product details from their owners in parallel to keep the response fast.
package com.javabytechie.order;
import java.util.concurrent.CompletableFuture;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class OrderSummaryService {
private final OrderRepository orderRepository;
private final RestClient customerClient;
private final RestClient productClient;
public OrderSummaryService(OrderRepository orderRepository, RestClient.Builder restClientBuilder) {
this.orderRepository = orderRepository;
this.customerClient = restClientBuilder.clone().baseUrl("http://customer-service").build();
this.productClient = restClientBuilder.clone().baseUrl("http://product-service").build();
}
public OrderSummary getSummary(Long orderId) {
// 1. Read our own data from order_db.
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// 2. Ask the owners for their data, both calls at the same time.
CompletableFuture<CustomerDto> customer = CompletableFuture.supplyAsync(() ->
customerClient.get()
.uri("/customers/{id}", order.getCustomerId())
.retrieve()
.body(CustomerDto.class));
CompletableFuture<ProductDto> product = CompletableFuture.supplyAsync(() ->
productClient.get()
.uri("/products/{id}", order.getProductId())
.retrieve()
.body(ProductDto.class));
// 3. Combine everything into one response.
return OrderSummary.of(order, customer.join(), product.join());
}
}
package com.javabytechie.order;
import java.math.BigDecimal;
public record OrderSummary(
Long orderId,
String customerName,
String customerEmail,
String productName,
BigDecimal productPrice,
int quantity,
OrderStatus status) {
public static OrderSummary of(Order order, CustomerDto customer, ProductDto product) {
return new OrderSummary(
order.getId(),
customer.name(),
customer.email(),
product.name(),
product.price(),
order.getQuantity(),
order.getStatus());
}
}
package com.javabytechie.order;
// Only the fields the Order Service needs, not the Customer Service's full model.
public record CustomerDto(Long id, String name, String email) {
}
package com.javabytechie.order;
import java.math.BigDecimal;
public record ProductDto(String id, String name, BigDecimal price) {
}
package com.javabytechie.order;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderSummaryService orderSummaryService;
public OrderController(OrderSummaryService orderSummaryService) {
this.orderSummaryService = orderSummaryService;
}
@GetMapping("/{orderId}/summary")
public OrderSummary getSummary(@PathVariable Long orderId) {
return orderSummaryService.getSummary(orderId);
}
}
Keeping a Local Copy of Other Services' Data
API Composition is simple, but it makes the Order Service depend on the Customer Service being up for every request. When data is read very often and changes rarely, a service can keep a local, read-only copy of just the fields it needs. The owner publishes an event whenever the data changes, and the copy is updated from that event.
package com.javabytechie.order.customer;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
// A read-only copy stored in order_db. The Customer Service is still the owner.
@Entity
@Table(name = "customer_snapshot")
public class CustomerSnapshot {
@Id
private Long customerId;
private String name;
private String email;
protected CustomerSnapshot() {
}
public CustomerSnapshot(Long customerId, String name, String email) {
this.customerId = customerId;
this.name = name;
this.email = email;
}
public Long getCustomerId() { return customerId; }
public String getName() { return name; }
public String getEmail() { return email; }
}
package com.javabytechie.order.customer;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class CustomerEventListener {
private final CustomerSnapshotRepository snapshots;
public CustomerEventListener(CustomerSnapshotRepository snapshots) {
this.snapshots = snapshots;
}
@KafkaListener(topics = "customer-events", groupId = "order-service")
public void onCustomerChanged(CustomerChangedEvent event) {
// save() inserts or overwrites, so a repeated event is harmless (idempotent).
snapshots.save(new CustomerSnapshot(event.customerId(), event.name(), event.email()));
}
public record CustomerChangedEvent(Long customerId, String name, String email) {
}
}
The copy is eventually consistent: for a short time after a customer changes their email, the Order Service may still show the old one. For most read-only display data this is perfectly acceptable. Never write to the copy; changes must always go through the owner.
The Key Trade-off: Challenges
This pattern brings real benefits, but it also adds complexity that must be managed:
- Data Consistency (the hardest part): Creating an order and reducing product stock now touches two databases, so one ACID transaction cannot cover both.
- Solution: Use the Saga pattern. A saga is a sequence of local transactions; each step publishes an event that triggers the next. If a step fails, compensating transactions (such as releasing stock or refunding a payment) undo the earlier steps.
- Use the Transactional Outbox pattern so a database change and its event are always saved together.
- Joins and Queries: You can no longer write one SQL
JOINacross customers, orders and products.- Solution: Use API Composition for simple views (as shown above). For complex or heavy queries, use CQRS (Command Query Responsibility Segregation): build a separate, read-optimised view that is kept up to date from events.
- Referential Integrity: There are no foreign keys between services, so an order could point to a customer that was deleted.
- Solution: Validate IDs through the owner's API when creating data, prefer soft deletes, and react to events such as
CustomerDeleted.
- Solution: Validate IDs through the owner's API when creating data, prefer soft deletes, and react to events such as
- Reporting and Analytics: Business reports often need data from every service.
- Solution: Stream changes into a data warehouse or data lake using events or Change Data Capture tools (such as Debezium), and run reports there, not on the live service databases.
- Operational Overhead: Many databases mean more provisioning, monitoring, backups, upgrades and cost.
- Solution: Use managed cloud databases, infrastructure-as-code and shared monitoring dashboards, and keep the number of different technologies small.
Moving Away from a Shared Database
Most teams do not start with private databases; they inherit a shared one. You can split it safely, one piece at a time:
- Find the owner of each table. Decide which service is responsible for each table, using the business domains (see Domain-Driven Design).
- Stop cross-service access. Replace direct reads of another service's tables with API calls or events.
- Separate the schema. Move the service's tables into its own schema with its own database user.
- Move the data. Copy the data to the new database, keep both in sync for a while, then switch the service over.
- Remove the old tables once nothing reads them anymore.
Repeat this for one service at a time, so each step is small and easy to roll back.
Best Practices
- Give every service its own database credentials; no service should be able to log in to another's database.
- Share data only through APIs and events, never through tables or database views.
- Store only the IDs of data owned by other services, plus local copies of a few fields when needed.
- Use Sagas and the Outbox pattern instead of distributed transactions.
- Make event handlers idempotent so repeated events do no harm.
- Manage each service's schema with versioned migrations (Flyway or Liquibase) in its own repository.
- Choose new database technologies only when they bring a clear benefit.
- Run reports and analytics on a separate data platform, not on service databases.
- Back up, monitor and secure every database, not just the "main" one.
- Design APIs so consumers can fetch what they need in as few calls as possible.
When to Use Database per Service
| Use It When | Think Twice When |
|---|---|
| Several teams own different services and must move independently | A small team builds a simple application |
| Services have very different data or scaling needs | Most features need strong, instant consistency across all data |
| You want to limit the impact of failures and changes | The team has no experience with events, Sagas or eventual consistency |
| You are building a true microservices architecture | A modular monolith would meet your needs |
Summary
| Aspect | Monolith (Shared DB) | Microservices (DB per Service) |
|---|---|---|
| Coupling | Tightly coupled | Loosely coupled |
| Data Access | Direct database access | Through the service API or events only |
| Database Technology | Usually one type for everything | Best tool for each service |
| Agility | Slow; changes need coordination | Fast; teams work independently |
| Transactions | Simple ACID transactions | Local transactions plus Sagas |
| Queries Across Data | SQL joins | API Composition or CQRS |
| Operations | One database | Many databases |
The Database per Service pattern is a cornerstone of a well-designed microservices architecture. It trades the convenience of one shared database for independence, scalability, fault isolation and technology freedom. In return, you must handle distributed data carefully, with patterns such as API Composition, CQRS, Sagas and the Transactional Outbox. Apply it step by step, keep data ownership clear, and your services will be free to evolve on their own.
Frequently Asked Questions
1. What is the Database per Service pattern?
It is a microservices pattern where each service owns its data and keeps it in a private store. Other services can access that data only through the owner's API or events, never by connecting to its database directly.
2. Why is a shared database a problem in microservices?
A shared database couples services through their tables. A schema change by one team can break other services, one service's heavy queries can slow everyone down, and data ownership becomes unclear. Services can no longer be changed and deployed independently.
3. Does every service need its own database server?
No. The rule is that the data is private, not that the hardware is separate. Private tables or a separate schema per service on a shared server are valid options, as long as each service has its own credentials and cannot access another's data.
4. What is the difference between schema-per-service and database-per-service?
With schema per service, services share one database server but each has its own schema and user. With a database server per service, each service runs its own instance. The second gives stronger isolation and independent scaling, but costs more to run.
5. How do I join data that lives in different services?
Use API Composition for simple cases: a service or gateway calls each owner and combines the results. For complex or frequent queries, use CQRS to maintain a read-optimised view built from events.
6. What is the API Composition pattern?
A component (often a service or API Gateway) calls several services, waits for their answers and merges them into one response. Running the calls in parallel keeps it fast:
CompletableFuture<CustomerDto> customer = CompletableFuture.supplyAsync(() -> customerClient.find(id));
CompletableFuture<ProductDto> product = CompletableFuture.supplyAsync(() -> productClient.find(pid));
return OrderSummary.of(order, customer.join(), product.join());
7. What is CQRS and how does it help here?
CQRS (Command Query Responsibility Segregation) separates the model used for writing data from the model used for reading it. A query service listens to events from many services and builds a ready-to-read view, so complex screens can be served with one fast query.
8. How do I keep data consistent across services?
Use the Saga pattern: each service performs a local transaction and publishes an event that triggers the next step. If a step fails, compensating transactions undo the earlier steps. Accept that data will be eventually consistent.
9. Why not use distributed transactions (two-phase commit)?
Two-phase commit locks resources in several databases until every participant agrees, which hurts performance and availability. Many modern databases and message brokers do not support it, and a single slow participant can block the whole transaction.
10. What is the Transactional Outbox pattern?
A service saves its business change and the event it wants to publish in an outbox table within the same local transaction. A separate relay then publishes the outbox rows to the message broker. This prevents a saved change without an event, or an event without a saved change.
11. Is it okay to duplicate data between services?
Yes, if it is done deliberately. A service may keep a read-only copy of a few fields it needs, updated from the owner's events. There must still be exactly one owner who decides the true value, and the copy must never be edited directly.
12. How do I run reports that need data from all services?
Do not query each live database. Stream data changes into a data warehouse or data lake through events or Change Data Capture, and build reports there. This keeps analytics heavy work away from the services' operational databases.
13. What is polyglot persistence?
It means using different database technologies for different services based on their needs, for example PostgreSQL for orders, MongoDB for a product catalog and Redis for shopping carts.
14. How do I handle foreign keys between services?
You cannot have database foreign keys across services. Store the other service's ID as a plain value, validate it through the owner's API when needed, and react to events such as deletions. Soft deletes help avoid broken references.
15. Can two services share a read-only database view?
It is best avoided. Even a read-only view couples the reader to the owner's internal schema, so schema changes become risky again. Prefer an API, events or a local copy built from events.
16. How should each service manage its database schema?
Keep versioned migration scripts (with Flyway or Liquibase) in the service's own repository and run them automatically when the service is deployed. Only the owning service ever changes its schema.
17. How do I migrate from a shared database to database per service?
Do it step by step: assign an owner to every table, replace cross-service table access with APIs or events, move each service's tables to its own schema or database, synchronise the data during the switch, and finally remove the old tables.
18. Does Database per Service make the system slower?
Some reads can be slower because data must be fetched from several services instead of joined in SQL. You can reduce this with parallel calls, caching, local copies and CQRS views. At the same time, each service can tune its own database for its workload, which often improves overall performance.
19. What are the main disadvantages of this pattern?
The main disadvantages are harder consistency (no cross-service transactions), more complex queries, no foreign keys between services, more databases to operate and back up, and a need for skills in events, Sagas and eventual consistency.
20. When should I not use Database per Service?
It may be too much for a small team, a simple application or a system where nearly every feature needs instant, strong consistency across all data. In those cases a well-structured modular monolith with one database is often the better choice.