PCSalt
YouTube GitHub
Back to Architecture
Architecture · 5 min read

Database Per Service — Patterns for Data Isolation in Microservices

How to implement database-per-service in microservices — data ownership, cross-service queries, eventual consistency, and practical patterns for data isolation.


In a monolith, all modules share one database. Need order data in the billing module? Just join the tables. Simple, fast, consistent.

In microservices, each service owns its data. The Order service has its own database. The Billing service has its own. No shared tables, no cross-service joins, no direct database access between services.

This is the database-per-service pattern. It’s fundamental to microservices — and it’s where most of the hard problems come from.

Why separate databases?

Loose coupling

If Service A reads Service B’s tables directly, any schema change in B breaks A. You’ve built a distributed monolith — the worst of both worlds.

With separate databases, Service B exposes data through its API. It can change its schema freely as long as the API contract holds.

Independent scaling

The Order service needs a relational database with strong consistency. The Analytics service needs a time-series database. The Search service needs Elasticsearch. One database can’t serve all of these well.

Independent deployment

If services share a database, a schema migration in one service can break another. Deployments become coordinated events. With separate databases, each service migrates independently.

Failure isolation

If the Billing database goes down, only billing is affected. Order processing continues. With a shared database, one failure takes everything down.

The hard part: data you need from other services

This is where it gets complicated. Common scenarios:

1. API composition

The simplest pattern. Need data from another service? Call its API.

Client → API Gateway → Order Service → (calls) → User Service
                                      → (calls) → Product Service
                                      → (returns composed response)
suspend fun getOrderDetails(orderId: String): OrderDetails {
    val order = orderRepository.findById(orderId)
    val user = userServiceClient.getUser(order.userId) // HTTP call
    val products = order.productIds.map { productServiceClient.getProduct(it) }

    return OrderDetails(order, user, products)
}

Pros: Simple, real-time data, strong consistency. Cons: Latency (multiple network calls), coupling (if User Service is down, Order details fail), no joins.

2. Data replication via events

Keep a local copy of data from other services, updated through events:

User Service → publishes UserUpdated → Kafka → Order Service (local user_cache table)
@KafkaListener(topics = ["user-events"])
fun handleUserEvent(event: UserEvent) {
    when (event) {
        is UserCreated -> userCacheRepository.save(
            UserCache(id = event.userId, name = event.name, email = event.email)
        )
        is UserUpdated -> userCacheRepository.updateName(event.userId, event.name)
    }
}

Now the Order service queries its local user_cache table instead of calling the User service.

Pros: Fast (local queries), no runtime dependency on other services, supports joins. Cons: Eventually consistent (data might be stale), extra storage, need to handle event failures.

3. CQRS — Separate read models

Build dedicated read models optimized for specific queries:

Order Service → OrderPlaced event → Read Model Builder → Denormalized "order_details" table
User Service  → UserUpdated event →

The read model table contains all the data needed for the query — order info, user name, product names — in a single denormalized row. No joins, no API calls.

See the CQRS series for a full implementation.

Pros: Fastest queries, optimized for specific use cases. Cons: Most complex, eventual consistency, more infrastructure.

Choosing the right pattern

ScenarioPattern
Low-frequency queries, need real-time dataAPI composition
High-frequency queries, can tolerate stalenessData replication via events
Complex queries spanning multiple servicesCQRS read model
Reporting / analytics across servicesDedicated analytics database

Most systems use a mix. Real-time checkout uses API composition. Dashboard uses replicated data. Analytics uses a separate warehouse.

Data ownership rules

Each service owns its data

Service A never reads or writes Service B’s database. Not through a shared connection string, not through a read replica, not through a database view. The only way to access another service’s data is through its API or events.

One writer per entity

Each piece of data has exactly one service that writes it. The User service writes user data. The Order service writes order data. If the Order service needs to update a user’s order count, it publishes an event and the User service handles it.

IDs are the glue

Services reference each other through IDs, not foreign keys:

// Order service's table
data class Order(
    val id: String,
    val userId: String,      // reference, not a FK
    val productIds: List<String>, // references, not FKs
    val total: Double,
    val status: OrderStatus
)

No foreign key constraints across service boundaries. The Order service trusts that userId refers to a valid user. If validation is needed, it calls the User service API.

Handling cross-service transactions

You can’t use database transactions across services. The patterns:

Saga pattern

A sequence of local transactions, each publishing an event that triggers the next step:

1. Order Service: Create order (PENDING) → publish OrderCreated
2. Payment Service: Process payment → publish PaymentProcessed
3. Inventory Service: Reserve stock → publish StockReserved
4. Order Service: Confirm order (CONFIRMED)

If step 3 fails:

3. Inventory Service: Out of stock → publish StockReservationFailed
4. Payment Service: Refund payment → publish PaymentRefunded
5. Order Service: Cancel order (CANCELLED)

Each step is a local transaction. Compensation (refund, cancel) handles failures.

Outbox pattern

Ensure events are published reliably by writing them to an outbox table in the same transaction:

@Transactional
fun placeOrder(order: Order) {
    orderRepository.save(order)
    outboxRepository.save(
        OutboxEvent(
            aggregateId = order.id,
            type = "OrderPlaced",
            payload = serialize(order)
        )
    )
}

// Separate process reads outbox and publishes to Kafka

The event is part of the database transaction. If the transaction rolls back, the event isn’t created. A separate process reads the outbox table and publishes to the message broker.

Practical migration from shared database

If you’re splitting a monolith:

Step 1: Identify ownership

For each table, determine which service owns it. If two services write to the same table, you need to assign one owner and have the other service go through the owner’s API.

Step 2: Create service APIs

Before splitting the database, create API endpoints in the owning service for each table. Other services call the API instead of querying the table directly.

Step 3: Split reads first

Move read queries to use the API. The database is still shared, but access patterns are going through APIs.

Step 4: Split writes

Move write operations to use the API. Now all access goes through the owning service.

Step 5: Separate the database

Create a new database for the service. Migrate the tables it owns. The shared database shrinks with each service you extract.

This is a gradual process. Don’t try to split everything at once.

Common mistakes

1. Distributed joins

“Let’s query Service A’s database from Service B — just this once.” There is no “just this once.” Once you have a cross-service database dependency, it spreads.

2. Shared database with separate schemas

Separate schemas in the same database instance provide naming isolation but not true isolation. Services can still access each other’s schemas. Use actually separate database instances.

3. Too many API calls

If every page load triggers 10 inter-service API calls, you’ve drawn your service boundaries wrong. Consider merging services or using data replication.

4. Ignoring consistency requirements

Not everything can be eventually consistent. If the user changes their email, the next API call should return the new email — not the cached old one. Understand your consistency needs per use case.

Summary

Database-per-service is a tradeoff: you gain independence, scaling flexibility, and failure isolation, but you lose easy joins and strong cross-service consistency.

Use the right pattern for each situation:

  • API composition for real-time, low-frequency queries
  • Data replication for high-frequency queries that tolerate staleness
  • CQRS for complex cross-service reads
  • Saga + Outbox for cross-service writes

Start by identifying data ownership. Every table has one owner. Once ownership is clear, the patterns follow naturally.