Loading learning content...
In the previous page, we explored the myriad challenges of shared databases—from schema coupling and operational hazards to organizational friction and compliance risks. The solution to these challenges is one of the foundational principles of microservices architecture: Database per Service.
This principle is deceptively simple to state but profound in its implications: Each microservice owns and manages its own database, and no other service may access it directly.
This pattern represents a fundamental shift in how we think about data in distributed systems. Rather than treating the database as a shared integration platform, we treat each service's data as a private implementation detail, accessible only through the service's public API.
This page provides a comprehensive examination of the Database per Service pattern. You'll understand the core principles that underpin this approach, explore different implementation options (including polyglot persistence), learn about the significant benefits this pattern provides, and honestly assess the new challenges it introduces. By the end, you'll have a solid foundation for making informed decisions about database architecture in microservices.
The Database per Service pattern is built on several interconnected principles that, together, enable true service independence.
Principle 1: Data Encapsulation
Just as object-oriented programming encapsulates state within objects, accessible only through public methods, microservices encapsulate data within services, accessible only through public APIs.
123456789101112131415161718192021222324252627
// Object-Oriented Encapsulationclass BankAccount { private balance: number; // Private state // Public interface - the only way to interact with state public withdraw(amount: number): Result { if (amount > this.balance) { return Result.InsufficientFunds; } this.balance -= amount; return Result.Success; }} // Microservice Encapsulation// UserService owns user data - private database// Public API - the only way other services interact with user data@Controller('/users')class UserController { @Get('/:id') async getUser(@Param('id') id: string): Promise<UserDTO> { // Internal database access - hidden from consumers const user = await this.userRepo.findById(id); // Returns DTO, not database entity return this.toUserDTO(user); }}Principle 2: Loose Coupling
Services should be loosely coupled—changes to one service should not require changes to other services. When services share a database, they're coupled at the schema level. Database per Service eliminates this coupling.
Principle 3: Single Source of Truth
Each piece of business data has exactly one authoritative source: the owning service. Other services may cache or replicate data for their needs, but the owning service is always the source of truth.
Principle 4: API-First Integration
Services communicate exclusively through well-defined APIs (synchronous REST/gRPC or asynchronous messaging). The database is never an integration point.
This boundary must be strictly enforced. "Just a quick read query" is how shared databases start. Once one service has direct access, others will follow. Use network policies, dedicated database credentials, and code review gates to prevent any exception to this rule.
"Database per Service" can be implemented in several ways, each with different isolation levels, operational characteristics, and cost implications.
| Strategy | Isolation Level | Pros | Cons |
|---|---|---|---|
| Private tables per service (single DB) | Low | Simple operations, low cost | Still shares resources; not true isolation |
| Schema per service (single DB) | Medium | Logical separation, moderate cost | Shared resources; operational coupling |
| Database per service (shared cluster) | High | Strong isolation, independent scaling | Higher cost; cluster management overhead |
| Dedicated database instance per service | Complete | Complete independence, different DB types possible | Highest cost; most operational complexity |
Option 1: Private Tables Per Service (Low Isolation)
All services share a single database instance, but each service has dedicated tables that only it accesses. This is often a transition state during migration but provides minimal actual isolation.
123456789101112131415161718
-- All in the same database, different table prefixes-- User Service tables (only user service touches these)user_service_usersuser_service_profilesuser_service_preferences -- Order Service tables (only order service touches these)order_service_ordersorder_service_order_itemsorder_service_order_history -- Product Service tables (only product service touches these)product_service_productsproduct_service_categoriesproduct_service_inventory -- Enforcement: Naming conventions + code review-- Weakness: All services have credentials to all tablesOption 2: Schema Per Service (Medium Isolation)
Each service has its own database schema within a shared database instance. Services have credentials only to their own schema.
123456789101112131415161718
-- PostgreSQL schema separationCREATE SCHEMA user_service;CREATE SCHEMA order_service;CREATE SCHEMA product_service; -- User Service has its own user with schema accessCREATE USER user_service_app WITH PASSWORD 'xxx';GRANT USAGE ON SCHEMA user_service TO user_service_app;GRANT ALL ON ALL TABLES IN SCHEMA user_service TO user_service_app;-- No access to other schemas -- Tables are namespace-isolatedCREATE TABLE user_service.users (...);CREATE TABLE order_service.orders (...); -- Cross-schema references are possible but should be avoided-- The database doesn't prevent: SELECT * FROM order_service.orders-- (if user has broader access by mistake)Option 3: Database Per Service on Shared Cluster (High Isolation)
Each service has its own database, but databases share underlying infrastructure (e.g., same RDS cluster, same Kubernetes PersistentVolume provider).
123456789101112131415161718192021
AWS RDS Cluster├── user-service-db ← PostgreSQL database│ └── Credentials: user-service-app/xxx│ └── Tables: users, profiles, preferences│├── order-service-db ← PostgreSQL database│ └── Credentials: order-service-app/yyy│ └── Tables: orders, order_items│└── product-service-db ← PostgreSQL database └── Credentials: product-service-app/zzz └── Tables: products, categories Pros:- Complete logical isolation- Independent credentials and permissions- Some shared operational tooling Cons:- Still share underlying compute/storage- Resource contention still possibleOption 4: Dedicated Database Instances (Complete Isolation)
Each service runs its own database instance(s), potentially using different database technologies. This is the purest implementation of Database per Service.
123456789101112131415161718192021222324
Service Databases (Complete Isolation): User Service└── Primary: RDS PostgreSQL (us-east-1)└── Replica: RDS PostgreSQL (us-west-2) Product Service└── Elasticsearch Cluster (for search)└── Redis Cluster (for caching) Order Service└── RDS Aurora PostgreSQL (ACID transactions) Session Service└── ElastiCache Redis Cluster Analytics Service└── Amazon Redshift (OLAP workloads) Each service:- Owns infrastructure completely- Scales independently- Uses optimal technology for workload- No shared resources whatsoeverMost organizations progress through these options as they mature. Starting with schema separation is practical for early-stage systems. As services prove their boundaries and scaling needs diverge, moving to dedicated databases becomes more valuable. Don't over-engineer early—but design with eventual separation in mind.
One of the most powerful benefits of Database per Service is polyglot persistence—the ability to choose the optimal database technology for each service's specific requirements. Rather than forcing all data access patterns into a single database type, each service can use the technology that best fits its needs.
Why different databases for different needs?
No single database excels at everything. Each database technology makes fundamental tradeoffs:
| Database Type | Optimized For | Tradeoffs |
|---|---|---|
| Relational (PostgreSQL, MySQL) | ACID transactions, complex queries, data integrity | Harder to scale horizontally; schema rigidity |
| Document (MongoDB, Couchbase) | Flexible schemas, nested data, rapid development | Weaker consistency guarantees; no JOINs |
| Key-Value (Redis, DynamoDB) | Ultra-fast lookups, simple data models, caching | Limited query capabilities; no relationships |
| Wide Column (Cassandra, HBase) | Write-heavy workloads, time-series data | Eventual consistency; limited query patterns |
| Graph (Neo4j, JanusGraph) | Relationship-heavy queries, network traversal | Not for general-purpose OLTP; specialized use |
| Search (Elasticsearch, Solr) | Full-text search, logging, analytics | Not a system of record; replication required |
| Time-Series (InfluxDB, TimescaleDB) | Metrics, IoT data, time-ordered events | Optimized for specific access patterns only |
Polyglot Persistence in Practice
Consider an e-commerce platform with diverse data access patterns:
123456789101112131415161718192021222324252627282930313233343536
┌─────────────────────────────────────────────────────────────────────┐│ E-Commerce Platform │├─────────────────────────────────────────────────────────────────────┤│ ││ User Service ─────────────── PostgreSQL ││ │ ↑ ACID for user data ││ │ ↑ Complex profile queries ││ │ ││ Session Service ──────────── Redis ││ │ ↑ Sub-millisecond session lookups ││ │ ↑ Auto-expiration (TTL) ││ │ ││ Product Catalog ─────────── Elasticsearch + PostgreSQL ││ │ ↑ Elasticsearch: Full-text search ││ │ ↑ PostgreSQL: Source of truth ││ │ ││ Order Service ───────────── PostgreSQL (Aurora) ││ │ ↑ Strong consistency for orders ││ │ ↑ ACID transactions ││ │ ││ Shopping Cart ───────────── Redis Cluster ││ │ ↑ High-speed temporary storage ││ │ ↑ Automatic expiration ││ │ ││ Recommendations ─────────── Neo4j + Redis ││ │ ↑ Neo4j: Graph of user-product ││ │ ↑ Redis: Recommendations cache ││ │ ││ Analytics Service ───────── ClickHouse / Redshift ││ │ ↑ OLAP for business intelligence ││ │ ↑ Columnar storage for aggregations ││ │ ││ Metrics & Monitoring ────── InfluxDB / Prometheus ││ ↑ Time-series optimized ││ ↑ Efficient downsampling │└─────────────────────────────────────────────────────────────────────┘Multiple database technologies mean multiple operational skill sets, monitoring tools, backup procedures, and security configurations. Only adopt additional database types when the benefit clearly outweighs the operational cost. Start with one well-understood database and add technologies only when specific requirements demand it.
While the previous page detailed the problems with shared databases, let's examine the specific benefits that Database per Service provides.
1. True Independent Deployability
With a private database, a service can be deployed at any time without coordinating with other services. Schema changes are entirely within the team's control.
2. Independent Scalability
Each service's database can be scaled according to its specific needs, without affecting or being affected by other services:
123456789101112131415161718192021222324252627
Black Friday traffic pattern: Product Service Database:- Read traffic: 100x normal (browsing product pages)- Scaling action: Add 5 read replicas- Cost impact: $500/day temporary increase- Other services: Unaffected Order Service Database:- Write traffic: 20x normal (checkout surge)- Scaling action: Vertical scale to larger instance- Cost impact: $200/day temporary increase- Other services: Unaffected Session Service Database:- Read/write traffic: 50x normal- Scaling action: Add Redis cluster nodes- Cost impact: $100/day temporary increase- Other services: Unaffected User Service Database:- Traffic: 2x normal (login/signup)- Scaling action: None needed- Cost impact: $0- Other services: Unaffected Total scaling: Precise, targeted, cost-efficient3. Fault Isolation
Database failures are contained to the affected service. Other services continue operating, potentially with degraded functionality but not complete failure.
4. Team Autonomy
Teams make database decisions independently:
No cross-team coordination, no design committees, no waiting for approval from other teams.
5. Clearer Data Ownership
With a private database, ownership is unambiguous:
The benefits extend beyond technical architecture. Teams with autonomous services develop faster, onboard new engineers more easily, and have clearer accountability. The clarity of "your service, your database, your responsibility" eliminates the diffused ownership that plagues shared database environments.
Database per Service isn't a free lunch. While it solves the problems of shared databases, it introduces new challenges that must be addressed. Understanding these challenges is essential for successful implementation.
1. Cross-Service Queries Become Difficult
With shared databases, joining data across domains was trivial—just add a JOIN clause. With separate databases, there's no direct JOIN possible:
1234567891011121314
-- Easy with shared database:SELECT o.id, o.total, o.created_at, u.name, u.email, p.name as product_nameFROM orders oJOIN users u ON o.user_id = u.idJOIN order_items oi ON o.id = oi.order_idJOIN products p ON oi.product_id = p.idWHERE o.status = 'pending'; -- With database per service:-- ??? This query is impossible-- Data lives in three separate databasesSolutions to cross-service queries (covered in detail later):
2. Distributed Transactions
Operations that span multiple services can no longer rely on database transactions for atomicity:
1234567891011121314151617
// With shared database - single transactionawait db.transaction(async (tx) => { await tx.orders.create({ userId, items }); await tx.inventory.decrementStock(items); await tx.users.updateLoyaltyPoints(userId, points); await tx.payments.recordPayment(orderId, amount);});// Either all succeed or all rollback - simple // With database per service - distributed operationconst orderId = await orderService.createOrder({ userId, items });await inventoryService.reserveStock(orderId, items);// If inventory reservation fails, how do we rollback the order?await userService.addLoyaltyPoints(userId, points);// If loyalty points fails, do we release inventory?await paymentService.processPayment(orderId, amount);// If payment fails, do we rollback everything?Solutions to distributed transactions:
3. Data Consistency
Across separate databases, data cannot be immediately consistent. You must embrace eventual consistency:
4. Increased Operational Complexity
More databases means more infrastructure to manage:
The operational overhead is real and significant. Before adopting Database per Service, ensure your organization has the tooling, automation, and platform engineering capabilities to manage multiple databases efficiently. Under-investment in platform capabilities is a common cause of failed microservices adoptions.
When a service needs data owned by another service, it must request that data through the owning service's API. This is the fundamental integration pattern in Database per Service architecture.
Synchronous API Access
The most straightforward pattern: make an HTTP/gRPC call to the owning service:
123456789101112131415161718192021222324252627282930313233343536
// Order Service needs user data for order processingclass OrderService { constructor(private userServiceClient: UserServiceClient) {} async createOrder(userId: string, items: OrderItem[]): Promise<Order> { // Fetch user data via API const user = await this.userServiceClient.getUser(userId); if (!user.isActive) { throw new Error('User account is not active'); } // Use user data for order creation const order = await this.orderRepository.create({ userId: user.id, shippingAddress: user.defaultAddress, email: user.email, // Denormalized for this order items, }); return order; }} // UserServiceClient abstracts the API callclass UserServiceClient { private baseUrl = 'http://user-service:8080'; async getUser(userId: string): Promise<User> { const response = await fetch(`${this.baseUrl}/users/${userId}`); if (!response.ok) { throw new UserServiceError(response.status); } return response.json(); }}Considerations for synchronous access:
Asynchronous Data Replication
For frequently needed data, maintain a local copy synchronized via events:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// User Service publishes events when user data changesclass UserService { async updateUser(userId: string, updates: UserUpdate): Promise<User> { const user = await this.userRepository.update(userId, updates); // Publish event for interested services await this.eventBus.publish('user.updated', { userId: user.id, email: user.email, name: user.name, defaultAddress: user.defaultAddress, updatedAt: new Date(), }); return user; }} // Order Service maintains a local copy of user dataclass OrderServiceUserConsumer { @Subscribe('user.updated') async handleUserUpdated(event: UserUpdatedEvent) { // Upsert local copy of user data needed by Order Service await this.localUserCache.upsert({ userId: event.userId, email: event.email, name: event.name, defaultAddress: event.defaultAddress, lastSyncedAt: new Date(), }); }} // Order Service uses local cache for most operationsclass OrderService { async createOrder(userId: string, items: OrderItem[]): Promise<Order> { // Fast local lookup - no network call const userCache = await this.localUserCache.get(userId); if (!userCache) { // Fallback to API if cache miss const user = await this.userServiceClient.getUser(userId); await this.localUserCache.upsert(user); } // Use cached data return this.orderRepository.create({ userId: userCache.userId, shippingAddress: userCache.defaultAddress, email: userCache.email, items, }); }}Replicating data improves performance and availability but introduces eventual consistency. The local copy may be stale. For many use cases—like displaying a user's name on an order—eventual consistency is acceptable. For cases requiring the absolute latest data (like checking if a user is blocked), use synchronous API calls.
A prerequisite for Database per Service is clear data ownership. You must answer: Which service owns which data?
The Ownership Decision Framework
Data ownership should be assigned to the service for which that data is a core concern:
| Data Entity | Core Concern Of | Owner | Rationale |
|---|---|---|---|
| User profile | User identity | User Service | User Service manages who users are |
| Order details | Transaction processing | Order Service | Order Service manages the order lifecycle |
| Product catalog | Inventory and merchandising | Product Service | Product Service defines what's for sale |
| Payment records | Financial transactions | Payment Service | Payment Service handles money movement |
| User preferences | User experience | User Service | Preferences are part of user identity |
| Order history | Transaction records | Order Service | History is an extension of order data |
| User address book | User identity | User Service | Addresses belong to users, not orders |
What about shared concepts?
Some data concepts seem to belong to multiple services. These are often cross-cutting concerns that require careful handling:
Example: User address in the context of an order
The shipping address on an order is a snapshot at order time, not a live reference to the user's current address. This leads to an ownership pattern:
12345678910111213141516171819202122232425262728293031323334353637
// User Service owns the user's address bookinterface UserService { // Current addresses the user manages addresses: Address[]; // Owned by User Service defaultAddressId: string; // Owned by User Service // CRUD operations addAddress(address: Address): Promise<Address>; updateAddress(id: string, updates: Address): Promise<Address>; deleteAddress(id: string): Promise<void>; setDefaultAddress(id: string): Promise<void>;} // Order Service owns the shipping address *on an order*interface OrderService { // This is a snapshot, not a reference shippingAddress: OrderAddress; // Owned by Order Service billingAddress: OrderAddress; // Owned by Order Service // When order is created, address is COPIED, not referenced async createOrder(userId: string, addressId: string, items: Item[]) { // Fetch current address from User Service const address = await userService.getAddress(userId, addressId); // Store a copy - this never changes, even if user updates address later return this.orderRepo.create({ userId, shippingAddress: { street: address.street, city: address.city, country: address.country, postalCode: address.postalCode, }, items, }); }}Data ownership aligns with Domain-Driven Design's concept of Bounded Contexts. Each service represents a bounded context with its own ubiquitous language and data model. The same real-world concept (like "customer") may appear in multiple contexts with different attributes and meaning—and that's appropriate, not a problem to solve.
For organizations with existing shared databases, transitioning to Database per Service is a significant undertaking that should be approached incrementally.
Phase 1: Logical Separation
Start by establishing logical ownership without physical separation:
Phase 2: Schema Separation
Once logical ownership is enforced, physically separate schemas:
Phase 3: Database Separation
For services with distinct scaling needs or technology requirements, move to separate databases:
Transitioning from a shared database to Database per Service is not a weekend project. It typically takes months to years for mature applications. The key is continuous incremental progress rather than a big-bang migration. Each small step reduces coupling and improves your architecture.
Database per Service is a powerful pattern, but it's not always the right choice. In some contexts, a single shared database is appropriate:
Early-Stage Startups
When you're still discovering your domain and boundaries are unclear, a monolithic database with well-structured code is pragmatic. Premature database separation adds complexity without clear benefit.
Small, Cohesive Teams
If a single team owns the entire system and coordination isn't a bottleneck, the organizational benefits of database separation are reduced.
Strong Transactional Requirements
Some domains genuinely require strong consistency across what might otherwise be separate services. Financial ledger systems, for example, may benefit from single-database ACID guarantees.
Limited Scale
If your system will never face significant scale (internal tools, niche applications), the scalability benefits of database per service may not justify the complexity.
| Factor | Favors Database per Service | Favors Single Database |
|---|---|---|
| Team size | Multiple teams (3+) | Single team |
| Domain clarity | Well-understood bounded contexts | Still discovering boundaries |
| Scale requirements | Different scaling needs per domain | Uniform, modest scale |
| Transactional needs | Eventual consistency acceptable | Strong ACID required across domains |
| Technology diversity | Different DB types beneficial | Single DB type sufficient |
| Deployment independence | Critical for velocity | Coordinated releases OK |
Even if you choose a single database now, design your application as if databases were separate. Avoid cross-domain JOINs in application code. Use internal service boundaries. When the time comes to separate databases, the refactoring will be much easier.
The Database per Service pattern is foundational to achieving the benefits of microservices architecture. By ensuring each service owns and controls its data, we enable true independence, clear ownership, and flexible technology choices.
What's next:
The next page dives into Data Migration Strategies—how to actually move data out of a shared database into service-specific databases. We'll cover techniques for safe, incremental migration that minimize risk and maintain data integrity throughout the process.
You now understand the Database per Service pattern, its core principles, implementation options, benefits, and challenges. This pattern is the target state for database decomposition. The following pages will provide the practical techniques needed to achieve this architecture.