~/architect
#microservices #databases #architecture

Database-per-Service: The Microservices Data Boundary

Why each microservice should own its data store, and the real costs of enforcing that boundary.

The database-per-service pattern is one of the harder constraints to enforce when migrating from a monolith. It’s also one of the most important.

Why the Boundary Exists

When two services share a database, you’ve created a hidden coupling point. Schema changes in a shared table require coordinating deployments across every service that touches it. You’ve lost independent deployability — one of the core promises of microservices.

// Shared Database Anti-Pattern vs Database-per-Service
flowchart TB
  subgraph bad ["Anti-Pattern: Shared DB"]
      direction TB
      s1["Order Service"] --> db1[("Shared DB")]
      s2["User Service"] --> db1
      s3["Inventory Service"] --> db1
  end

  subgraph good ["Pattern: DB per Service"]
      direction TB
      s4["Order Service"] --> db2[("Orders DB")]
      s5["User Service"] --> db3[("Users DB")]
      s6["Inventory Service"] --> db4[("Inventory DB")]
  end

Choosing the Right Store per Service

One benefit of this pattern: you’re free to pick the right tool for each service’s access patterns.

ServiceAccess PatternGood Fit
User profilesKey-value lookupRedis / DynamoDB
OrdersRelational, joinsPostgreSQL
Product catalogFull-text searchElasticsearch
Event logAppend-only, time-seriesKafka / TimescaleDB
RecommendationsGraph traversalNeo4j

The Cross-Service Query Problem

The hardest consequence: you lose the JOIN. Queries that were trivial in a monolith now require coordination.

Option 1: API Composition

The API gateway or a dedicated service fetches data from each service and assembles the response. Works for simple cases; latency compounds with more services.

Option 2: CQRS + Read Models

Build a dedicated read model that aggregates data from multiple services via events. Higher complexity, better query performance.

// CQRS Read Model Pattern
flowchart LR
  OrderSvc["Order Service"] -- "OrderPlaced" --> Bus[("Event Bus")]
  UserSvc["User Service"] -- "UserUpdated" --> Bus
  Bus --> Projector["Read Model Projector"]
  Projector --> ReadDB[("Read DB
(Denormalized)")]
  QuerySvc["Query Service"] --> ReadDB
  Client["Client"] --> QuerySvc

The Migration Path

Going from a shared database to database-per-service is a journey, not a switch:

1. Identify service boundaries (bounded contexts)
2. Create service-specific schemas within the shared DB
3. Route all access through the owning service's API
4. Migrate data to separate physical stores
5. Remove old shared tables

The Strangler Fig pattern works well here — incrementally extract services while the monolith still handles the rest.

This is a real cost. Budget for it accordingly.

← back to posts