00:00

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.

Simple analogy: Think of departments in a company. The HR team keeps its own employee files, and the finance team keeps its own accounts. If finance needs an employee's bank details, it asks HR through an official request. It does not walk into the HR office and open the filing cabinet.

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

AspectShared DatabaseDatabase per Service
CouplingServices coupled through tablesServices coupled only through APIs and events
Schema changesNeed coordination across teamsOwner team changes freely
TransactionsSimple ACID transactions across dataLocal transactions; Sagas across services
QueriesSQL joins across all dataAPI Composition or CQRS views
ScalingWhole database scales togetherEach database scales on its own
Failure impactOne database issue affects all servicesIssues stay within one service
OperationsOne database to runMany 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:

OptionHow It WorksIsolationCost
Private tables per serviceServices share a database server but each owns its own set of tables; access is limited with database users and permissionsLowLow
Schema per serviceEach service has its own schema on a shared server, with its own database userMediumLow
Database server per serviceEach service runs its own database instance or clusterHighHigher

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.

ServiceData NeedsGood Database Choice
Order ServiceTransactions, strong consistencyPostgreSQL, MySQL
Product Catalog ServiceFlexible attributes per productMongoDB (document store)
Search ServiceFull-text search and filtersElasticsearch, OpenSearch
Cart / Session ServiceVery fast reads and writes, short-lived dataRedis (key-value)
Recommendation ServiceRelationships between users and productsNeo4j (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:

  1. Order Service: Creates and manages orders.
  2. Customer Service: Manages customer details such as name, email and address.
  3. 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:

order-summary.sql
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:

ServicePrivate DatabaseWhat It Stores
Order Serviceorder_db (PostgreSQL)orders table with order_id, customer_id, product_id, quantity, status
Customer Servicecustomer_db (PostgreSQL)customers table with customer_id, name, email, address
Product Catalog Serviceproduct_db (MongoDB)Product documents with id, name, price, attributes
Architecture
  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:

  1. The front-end calls the Order Service API: GET /orders/123/summary.
  2. The Order Service reads order 123 from order_db. It finds customer_id = 456, product_id = 789, quantity = 1 and status = SHIPPED. It does not have the customer's name or the product's name, only their IDs.
  3. The Order Service calls the Customer Service: GET /customers/456 and receives { "name": "John Doe", "email": "john@example.com" }.
  4. At the same time, it calls the Product Catalog Service: GET /products/789 and receives { "name": "Wireless Mouse", "price": 29.99 }.
  5. The Order Service combines the three results and returns one response:
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:

order-service/application.yml
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:

product-service/application.yml
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.

Order.java
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; }
}
Product.java (Product Catalog Service)
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.

OrderSummaryService.java
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());
    }
}
OrderSummary.java
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());
    }
}
CustomerDto.java
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) {
}
ProductDto.java
package com.javabytechie.order;

import java.math.BigDecimal;

public record ProductDto(String id, String name, BigDecimal price) {
}
OrderController.java
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);
    }
}
Tip: Protect these remote calls with timeouts and a circuit breaker, and decide what to show if a call fails (for example, the order without the product image). See the Circuit Breaker Pattern tutorial.

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.

CustomerSnapshot.java
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; }
}
CustomerEventListener.java
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:

  1. 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.
  2. Joins and Queries: You can no longer write one SQL JOIN across 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.
  3. 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.
  4. 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.
  5. 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:

  1. Find the owner of each table. Decide which service is responsible for each table, using the business domains (see Domain-Driven Design).
  2. Stop cross-service access. Replace direct reads of another service's tables with API calls or events.
  3. Separate the schema. Move the service's tables into its own schema with its own database user.
  4. Move the data. Copy the data to the new database, keep both in sync for a while, then switch the service over.
  5. 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

  1. Give every service its own database credentials; no service should be able to log in to another's database.
  2. Share data only through APIs and events, never through tables or database views.
  3. Store only the IDs of data owned by other services, plus local copies of a few fields when needed.
  4. Use Sagas and the Outbox pattern instead of distributed transactions.
  5. Make event handlers idempotent so repeated events do no harm.
  6. Manage each service's schema with versioned migrations (Flyway or Liquibase) in its own repository.
  7. Choose new database technologies only when they bring a clear benefit.
  8. Run reports and analytics on a separate data platform, not on service databases.
  9. Back up, monitor and secure every database, not just the "main" one.
  10. Design APIs so consumers can fetch what they need in as few calls as possible.

When to Use Database per Service

Use It WhenThink Twice When
Several teams own different services and must move independentlyA small team builds a simple application
Services have very different data or scaling needsMost features need strong, instant consistency across all data
You want to limit the impact of failures and changesThe team has no experience with events, Sagas or eventual consistency
You are building a true microservices architectureA modular monolith would meet your needs

Summary

AspectMonolith (Shared DB)Microservices (DB per Service)
CouplingTightly coupledLoosely coupled
Data AccessDirect database accessThrough the service API or events only
Database TechnologyUsually one type for everythingBest tool for each service
AgilitySlow; changes need coordinationFast; teams work independently
TransactionsSimple ACID transactionsLocal transactions plus Sagas
Queries Across DataSQL joinsAPI Composition or CQRS
OperationsOne databaseMany 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.