Database per service vs shared database: a 2025 decision matrix

A practical decision matrix for picking between database-per-service and shared databases in 2025, with migration paths, saga patterns, and the cost numbers that actually move the needle.

By Tharindu Perera·Published 2025-08-22·Updated 2026-04-19·16 minutes
16 minutes
Advanced
2025-08-22

Few microservices decisions split teams the way the database question does. One camp says database-per-service is the only honest way to run independent services. The other camp watches teams hit eventual consistency, saga compensation logic, and the operational drag of running four databases instead of one, and asks if the original problem was really that bad.

Both camps have a point. Teams running database-per-service do report faster deploy cycles and better team autonomy. They also report the predictable tax: harder cross-service queries, more failure modes to handle, more infrastructure to babysit. Shared databases keep ACID and simple joins. They also create the coordination problems that pushed people toward microservices in the first place.

The 2025 picture is less dogmatic than the 2018 one. Saga orchestrators are mature, Debezium 3.0 made change-data-capture boring in a good way, and managed services like Aurora DSQL and CockroachDB Serverless blur the line between "one database" and "many." The right answer depends on team size, deployment frequency, transaction shape, and how much pain you can absorb. That is what this guide is built around.

If you are also questioning whether microservices were the right call to begin with, my migration guide from microservices to modular monolith is the companion piece.

The two patterns at a glance

Shared database pattern

All services access a single database:

// Services access same database directly

// Order Service
class OrderService {
  async createOrder(data: CreateOrderDTO): Promise<Order> {
    return this.db.query(
      `INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING *`,
      [data.customerId, data.total]
    );
  }
}

// Customer Service
class CustomerService {
  async getCustomer(id: string): Promise<Customer> {
    return this.db.query(
      `SELECT * FROM customers WHERE id = $1`,
      [id]
    );
  }

  // Can directly query orders table
  async getCustomerWithOrders(id: string): Promise<CustomerWithOrders> {
    return this.db.query(
      `SELECT c.*, 
              json_agg(o.*) as orders
       FROM customers c
       LEFT JOIN orders o ON o.customer_id = c.id
       WHERE c.id = $1
       GROUP BY c.id`,
      [id]
    );
  }
}

// Payment Service
class PaymentService {
  async processPayment(orderId: string, amount: number): Promise<void> {
    await this.db.transaction(async (tx) => {
      // Direct transaction across entities
      await tx.query(
        `UPDATE orders SET status = 'paid' WHERE id = $1`,
        [orderId]
      );
      
      await tx.query(
        `INSERT INTO payments (order_id, amount) VALUES ($1, $2)`,
        [orderId, amount]
      );
    });
    // ACID guarantees maintained
  }
}

Benefits:

  • ACID transactions across entities, no compensation logic needed
  • Joins and analytical queries are trivial
  • One source of truth, no duplicated data to keep in sync
  • Operations stay simple: one cluster to patch, back up, monitor

Drawbacks:

  • Services end up coupled at the schema layer
  • Schema changes require coordination across teams
  • Deployments often have to happen together
  • Scaling is bounded by what the single database can do
  • The database team becomes everyone's bottleneck

Database per service pattern

Each service owns its database:

// Each service has its own database

// Order Service (orders_db)
class OrderService {
  async createOrder(data: CreateOrderDTO): Promise<Order> {
    // Only access orders database
    return this.ordersDb.query(
      `INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING *`,
      [data.customerId, data.total]
    );
  }
}

// Customer Service (customers_db)
class CustomerService {
  async getCustomer(id: string): Promise<Customer> {
    // Only access customers database
    return this.customersDb.query(
      `SELECT * FROM customers WHERE id = $1`,
      [id]
    );
  }

  // Cannot directly join with orders - must call Order Service API
  async getCustomerWithOrders(id: string): Promise<CustomerWithOrders> {
    const customer = await this.getCustomer(id);
    
    // API call to Order Service
    const orders = await this.orderServiceClient.getOrdersByCustomer(id);
    
    return {
      ...customer,
      orders
    };
  }
}

// Payment Service (payments_db)
class PaymentService {
  async processPayment(orderId: string, amount: number): Promise<void> {
    // No direct transaction - use Saga pattern
    const saga = new PaymentSaga();
    
    try {
      // Step 1: Create payment in local db
      const payment = await this.paymentsDb.query(
        `INSERT INTO payments (order_id, amount, status) 
         VALUES ($1, $2, 'pending') RETURNING *`,
        [orderId, amount]
      );
      saga.addCompensation(() => this.cancelPayment(payment.id));

      // Step 2: Call Order Service to update order
      await this.orderServiceClient.markOrderAsPaid(orderId);
      saga.addCompensation(() => this.orderServiceClient.markOrderAsPending(orderId));

      // Step 3: Complete payment
      await this.paymentsDb.query(
        `UPDATE payments SET status = 'completed' WHERE id = $1`,
        [payment.id]
      );

      await saga.commit();
    } catch (error) {
      // Compensate all completed steps
      await saga.rollback();
      throw error;
    }
  }
}

Benefits:

  • Each service owns its data and can evolve it independently
  • Hot services scale without dragging cold ones along
  • Polyglot persistence is on the table (Postgres for one service, DynamoDB for another)
  • Deploys happen per service, no coordination tax
  • Domain boundaries are enforced by the database, not by convention

Drawbacks:

  • Cross-service transactions require sagas, with compensation logic to write and test
  • Some data has to live in more than one place
  • Queries that used to be a join now need API composition or a materialized view
  • More databases means more ops surface
  • Eventual consistency is the default, and you have to design around it

The 2025 decision matrix

Factor Shared Database Database Per Service Winning Pattern
Team Size < 5 developers > 10 developers DB per service at scale
Service Count < 5 services > 10 services DB per service at scale
Transaction Complexity High (multi-entity ACID required) Low (mostly single-entity) Shared for complex transactions
Query Complexity High (complex joins, analytics) Low (simple lookups) Shared for complex queries
Deployment Frequency < 1/week > 1/day DB per service for fast deploys
Team Autonomy Need Low (coordinated releases OK) High (independent teams) DB per service for autonomy
Scalability Requirements Uniform (all entities scale same) Varied (some hot, some cold) DB per service for varied scale
Consistency Requirements Strong (ACID mandatory) Eventual OK Shared for strong consistency
Technology Diversity Single stack preferred Polyglot needed DB per service for polyglot
Operational Maturity Low (small ops team) High (mature DevOps) Shared for simpler ops
Data Duplication Tolerance Low (single source of truth) High (eventual consistency OK) Depends on domain
Compliance/Audit Complex (cross-entity audits) Simple (entity-level audits) Depends on requirements

How to read the matrix

Pick shared database if most of the following are true:

  • You have fewer than ten developers
  • You genuinely need ACID across multiple entities
  • You run complex analytical queries that would be painful to compose across services
  • Your team has not done distributed systems before
  • Services scale roughly together
  • You are still finding product-market fit

Pick database per service if most of the following are true:

  • You have more than ten developers across multiple teams
  • Services have meaningfully different scaling needs
  • You deploy services independently multiple times a day
  • You want technology diversity (Postgres plus DynamoDB plus a graph store)
  • You can tolerate eventual consistency, often via CQRS and event sourcing for audit-heavy domains
  • DevOps is a strong muscle on your team

A hybrid usually beats both extremes. Start with a shared database. Pull out services that have high change rates or high scale into their own databases. Keep tightly coupled entities together. Use DDD bounded contexts to find the seams. Most successful migrations end up here.

Implementing database per service

Saga pattern for distributed transactions

// Saga Orchestrator Pattern
export class PaymentSaga {
  private steps: SagaStep[] = [];
  private completedSteps: SagaStep[] = [];

  async execute(): Promise<void> {
    for (const step of this.steps) {
      try {
        await step.execute();
        this.completedSteps.push(step);
      } catch (error) {
        // Compensate in reverse order
        await this.compensate();
        throw error;
      }
    }
  }

  private async compensate(): Promise<void> {
    // Reverse order compensation
    for (const step of this.completedSteps.reverse()) {
      try {
        await step.compensate();
      } catch (error) {
        console.error('Compensation failed:', error);
        // Log and alert - manual intervention may be needed
      }
    }
  }

  addStep(step: SagaStep): void {
    this.steps.push(step);
  }
}

interface SagaStep {
  execute(): Promise<void>;
  compensate(): Promise<void>;
}

// Order creation saga
export class CreateOrderSaga {
  constructor(
    private orderService: OrderService,
    private inventoryService: InventoryService,
    private paymentService: PaymentService
  ) {}

  async execute(data: CreateOrderDTO): Promise<string> {
    const saga = new PaymentSaga();
    let orderId: string;
    let reservationId: string;

    // Step 1: Create order
    saga.addStep({
      execute: async () => {
        orderId = await this.orderService.createOrder(data);
      },
      compensate: async () => {
        await this.orderService.cancelOrder(orderId);
      }
    });

    // Step 2: Reserve inventory
    saga.addStep({
      execute: async () => {
        reservationId = await this.inventoryService.reserveItems(
          data.items,
          orderId
        );
      },
      compensate: async () => {
        await this.inventoryService.releaseReservation(reservationId);
      }
    });

    // Step 3: Process payment
    saga.addStep({
      execute: async () => {
        await this.paymentService.authorizePayment(
          orderId,
          data.paymentDetails
        );
      },
      compensate: async () => {
        await this.paymentService.refundPayment(orderId);
      }
    });

    // Execute all steps
    await saga.execute();
    return orderId;
  }
}

// Usage
const saga = new CreateOrderSaga(orderService, inventoryService, paymentService);

try {
  const orderId = await saga.execute(orderData);
  console.log('Order created:', orderId);
} catch (error) {
  // All steps compensated automatically
  console.error('Order creation failed:', error);
}

Event-driven data synchronization

// Maintain local copies of data via events

// Customer Service publishes events
export class CustomerService {
  async updateCustomer(id: string, data: UpdateCustomerDTO): Promise<void> {
    await this.customersDb.query(
      `UPDATE customers SET name = $1, email = $2 WHERE id = $3`,
      [data.name, data.email, id]
    );

    // Publish event
    await this.eventBus.publish(new CustomerUpdatedEvent({
      customerId: id,
      name: data.name,
      email: data.email,
      timestamp: new Date()
    }));
  }
}

// Order Service subscribes and maintains local copy
export class OrderService {
  async onCustomerUpdated(event: CustomerUpdatedEvent): Promise<void> {
    // Update local denormalized customer data
    await this.ordersDb.query(
      `UPDATE order_customer_cache 
       SET name = $1, email = $2, updated_at = $3
       WHERE customer_id = $4`,
      [event.name, event.email, event.timestamp, event.customerId]
    );

    // Also update existing orders for reporting
    await this.ordersDb.query(
      `UPDATE orders 
       SET customer_name = $1, customer_email = $2
       WHERE customer_id = $3 AND created_at > NOW() - INTERVAL '30 days'`,
      [event.name, event.email, event.customerId]
    );
  }
}

// Order Service schema includes denormalized customer data
/*
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  customer_id UUID NOT NULL,
  
  -- Denormalized customer data (from events)
  customer_name VARCHAR(255),
  customer_email VARCHAR(255),
  
  total_amount DECIMAL(10, 2),
  status VARCHAR(50),
  created_at TIMESTAMP
);

CREATE TABLE order_customer_cache (
  customer_id UUID PRIMARY KEY,
  name VARCHAR(255),
  email VARCHAR(255),
  updated_at TIMESTAMP
);
*/

Change data capture for sync

// Use Debezium to capture changes and sync data

// Docker Compose configuration
/*
version: '3.8'

services:
  postgres-customers:
    image: postgres:16
    environment:
      POSTGRES_DB: customers
      # Enable logical replication
      POSTGRES_INITDB_ARGS: "-c wal_level=logical"

  debezium:
    image: debezium/connect:3.0
    environment:
      BOOTSTRAP_SERVERS: kafka:9092
      CONFIG_STORAGE_TOPIC: debezium_configs
      OFFSET_STORAGE_TOPIC: debezium_offsets

  kafka:
    image: confluentinc/cp-kafka:7.6
*/

// Debezium connector configuration
const debeziumConfig = {
  name: 'customers-connector',
  config: {
    'connector.class': 'io.debezium.connector.postgresql.PostgresConnector',
    'database.hostname': 'postgres-customers',
    'database.port': '5432',
    'database.user': 'debezium',
    'database.password': 'secret',
    'database.dbname': 'customers',
    'table.include.list': 'public.customers',
    'topic.prefix': 'customers',
    'plugin.name': 'pgoutput'
  }
};

// Order Service consumes CDC events
export class OrderServiceCDCConsumer {
  async consumeCustomerChanges(): Promise<void> {
    const consumer = kafka.consumer({ groupId: 'order-service-cdc' });
    
    await consumer.subscribe({ topic: 'customers.public.customers' });

    await consumer.run({
      eachMessage: async ({ message }) => {
        const change = JSON.parse(message.value.toString());

        if (change.op === 'u') {
          // Update operation
          await this.updateCustomerCache({
            customerId: change.after.id,
            name: change.after.name,
            email: change.after.email
          });
        } else if (change.op === 'd') {
          // Delete operation
          await this.deleteCustomerCache(change.before.id);
        }
      }
    });
  }

  private async updateCustomerCache(data: any): Promise<void> {
    await this.ordersDb.query(
      `INSERT INTO order_customer_cache (customer_id, name, email, updated_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT (customer_id) DO UPDATE SET
         name = $2, email = $3, updated_at = NOW()`,
      [data.customerId, data.name, data.email]
    );
  }
}

API composition for queries

// BFF (Backend for Frontend) composes data from multiple services

export class OrderDetailsComposer {
  constructor(
    private orderService: OrderServiceClient,
    private customerService: CustomerServiceClient,
    private inventoryService: InventoryServiceClient,
    private paymentService: PaymentServiceClient
  ) {}

  async getOrderDetails(orderId: string): Promise<OrderDetailsDTO> {
    // Parallel fetch from multiple services
    const [order, customer, items, payment] = await Promise.all([
      this.orderService.getOrder(orderId),
      this.customerService.getCustomer(order.customerId),
      this.inventoryService.getOrderItems(orderId),
      this.paymentService.getPaymentStatus(orderId)
    ]);

    // Compose into single response
    return {
      order: {
        id: order.id,
        status: order.status,
        createdAt: order.createdAt
      },
      customer: {
        name: customer.name,
        email: customer.email,
        phone: customer.phone
      },
      items: items.map(item => ({
        productId: item.productId,
        name: item.name,
        quantity: item.quantity,
        price: item.price
      })),
      payment: {
        status: payment.status,
        method: payment.method,
        amount: payment.amount
      }
    };
  }
}

// GraphQL alternative - dataloader for N+1 prevention
export class OrderGraphQLResolver {
  private customerLoader = new DataLoader(async (customerIds: string[]) => {
    // Batch fetch customers
    return this.customerService.getCustomersBatch(customerIds);
  });

  @Query(() => Order)
  async order(@Arg('id') id: string): Promise<Order> {
    return this.orderService.getOrder(id);
  }

  @ResolveField(() => Customer)
  async customer(@Root() order: Order): Promise<Customer> {
    // Uses dataloader to batch requests
    return this.customerLoader.load(order.customerId);
  }
}

Migration strategies

Strategy 1: extract one service at a time

// Phase 1: Identify bounded context
// Analyze dependencies and coupling

// Before: All in one database
/*
┌─────────────────────────────┐
│      Monolith Database      │
├─────────────────────────────┤
│ customers                   │
│ orders                      │
│ order_items                 │
│ products                    │
│ inventory                   │
│ payments                    │
└─────────────────────────────┘
*/

// Phase 2: Create read-only replica for new service
// New service reads from replica, writes to old database

export class InventoryService {
  async getStock(productId: string): Promise<number> {
    // Read from replica
    return this.replicaDb.query(
      `SELECT quantity FROM inventory WHERE product_id = $1`,
      [productId]
    );
  }

  async reserveStock(productId: string, quantity: number): Promise<void> {
    // Still write to primary database
    await this.primaryDb.query(
      `UPDATE inventory 
       SET quantity = quantity - $1 
       WHERE product_id = $2`,
      [quantity, productId]
    );
  }
}

// Phase 3: Implement CDC to sync data
// Set up Debezium to sync inventory table to new database

// Phase 4: Switch writes to new database
export class InventoryService {
  async reserveStock(productId: string, quantity: number): Promise<void> {
    // Now write to new database
    await this.inventoryDb.query(
      `UPDATE inventory 
       SET quantity = quantity - $1 
       WHERE product_id = $2`,
      [quantity, productId]
    );

    // Publish event for other services
    await this.eventBus.publish(new StockReservedEvent({
      productId,
      quantity,
      timestamp: new Date()
    }));
  }
}

// Phase 5: Update dependent services to use events
export class OrderService {
  async onStockReserved(event: StockReservedEvent): Promise<void> {
    // Update local cache
    await this.updateProductStockCache(
      event.productId,
      event.quantity
    );
  }
}

// Phase 6: Decommission old tables
// Once all services migrated, drop inventory tables from monolith

Strategy 2: strangler fig

// Gradually route traffic to new service

export class DatabaseRouter {
  constructor(
    private oldDb: Database,
    private newDb: Database,
    private migrationPercentage: number = 0
  ) {}

  async query(sql: string, params: any[]): Promise<any> {
    // Route based on feature flag
    if (this.shouldUseNewDatabase()) {
      try {
        // Try new database
        const result = await this.newDb.query(sql, params);
        
        // Double-write for consistency verification
        if (this.isWriteOperation(sql)) {
          await this.oldDb.query(sql, params);
        }
        
        return result;
      } catch (error) {
        // Fallback to old database
        console.error('New database failed, falling back:', error);
        return this.oldDb.query(sql, params);
      }
    }

    // Use old database
    return this.oldDb.query(sql, params);
  }

  private shouldUseNewDatabase(): boolean {
    // Gradual rollout: 0% -> 10% -> 50% -> 100%
    return Math.random() * 100 < this.migrationPercentage;
  }

  private isWriteOperation(sql: string): boolean {
    return /^(INSERT|UPDATE|DELETE)/i.test(sql.trim());
  }
}

// Increase percentage gradually
// Day 1: 0% (new database deployed, not used)
// Day 2: 10% (monitor errors)
// Day 3: 50% (if no errors)
// Day 4: 100% (full migration)

Handling cross-service queries

Pattern 1: CQRS with materialized views

// Maintain optimized read models

// Order Service publishes events
export class OrderService {
  async createOrder(data: CreateOrderDTO): Promise<void> {
    const order = await this.ordersDb.query(
      `INSERT INTO orders (...) VALUES (...) RETURNING *`,
      [...]
    );

    await this.eventBus.publish(new OrderCreatedEvent(order));
  }
}

// Analytics Service subscribes and builds materialized view
export class AnalyticsService {
  async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    // Fetch related data
    const customer = await this.customerCache.get(event.customerId);
    const items = await this.productCache.getMany(event.productIds);

    // Insert into denormalized view optimized for analytics
    await this.analyticsDb.query(
      `INSERT INTO order_analytics_view (
        order_id, customer_id, customer_name, customer_segment,
        total_amount, product_categories, created_at
      ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
      [
        event.orderId,
        customer.id,
        customer.name,
        customer.segment,
        event.totalAmount,
        items.map(i => i.category),
        event.createdAt
      ]
    );
  }

  // Complex analytical query now simple
  async getRevenueBySegment(startDate: Date, endDate: Date): Promise<any> {
    return this.analyticsDb.query(
      `SELECT 
        customer_segment,
        SUM(total_amount) as revenue,
        COUNT(*) as orders
       FROM order_analytics_view
       WHERE created_at BETWEEN $1 AND $2
       GROUP BY customer_segment`,
      [startDate, endDate]
    );
  }
}

Pattern 2: distributed query engine

// Use Presto/Trino to query multiple databases

// Presto catalog configuration
/*
# customer-db.properties
connector.name=postgresql
connection-url=jdbc:postgresql://customer-db:5432/customers
connection-user=presto
connection-password=secret

# order-db.properties
connector.name=postgresql
connection-url=jdbc:postgresql://order-db:5432/orders
connection-user=presto
connection-password=secret
*/

// Query across databases with Presto SQL
const query = `
  SELECT 
    c.name as customer_name,
    COUNT(o.id) as order_count,
    SUM(o.total_amount) as total_revenue
  FROM "customer-db".public.customers c
  LEFT JOIN "order-db".public.orders o ON o.customer_id = c.id
  WHERE o.created_at >= DATE '2025-01-01'
  GROUP BY c.name
  ORDER BY total_revenue DESC
  LIMIT 100
`;

// Execute via Presto client
export class DistributedQueryService {
  async executeQuery(sql: string): Promise<any[]> {
    const result = await this.prestoClient.execute(sql);
    return result.rows;
  }
}

// Note: Use for analytics/reporting, not real-time queries

Real-world cost analysis

Aspect Shared Database Database Per Service Winner
Infrastructure $500/month (1 RDS instance) $2000/month (4 RDS instances) Shared (4x cheaper)
Development Speed Initial: Fast, Later: Slow Initial: Slow, Later: Fast Depends on phase
Deployment Time 45 min (coordinated) 10 min (independent) DB per service
Incident Resolution Easy (single database) Complex (distributed trace) Shared
Team Velocity Slows at 10+ devs Scales linearly DB per service
Data Inconsistency Risk Low (ACID) Medium (eventual) Shared
Scaling Cost High (vertical only) Low (horizontal) DB per service
Operational Overhead 1 DBA 1 DBA + DevOps Shared

Three-year cost picture for a team of 20 developers:

  • Shared database: $250K infrastructure + $600K in slower velocity = $850K
  • Database per service: $350K infrastructure + $200K in initial migration = $550K

Database per service wins at scale because team velocity dwarfs infrastructure cost, but only once you are actually at that scale.

Practical advice

For shared database setups:

  • Assign clear ownership for each table, even if the database is shared
  • Use views to hide complexity and constrain what each service sees
  • Use row-level security where you need isolation between tenants or services
  • Plan schema migrations together, with backward-compatible deploys
  • Watch cross-service query performance, one service's bad join can starve everyone

For database per service:

  • Start with the minimum data duplication you can get away with
  • Make event handlers idempotent, retries will happen
  • Propagate correlation IDs through every async hop so you can trace a request end-to-end
  • Monitor projection lag and saga failures as first-class signals
  • Version events from day one, schema evolution is harder when it is already in production
  • Write tests for compensation paths, not just happy paths

For the hybrid approach:

  • Keep tightly coupled entities in the same database
  • Extract the services with the highest change rate or highest scale first
  • Let DDD bounded contexts guide where the seams go
  • Start shared, extract gradually, and stop extracting when the pain stops

Conclusion

The right answer here is rarely doctrinal. Small teams with complex transactions are usually better served by a shared database, both for ACID guarantees and operational simplicity. Large teams deploying multiple times a day benefit from database-per-service, despite the eventual-consistency tax.

The mistake worth avoiding in both directions: extracting databases too early because the architecture diagram says so, or refusing to extract until tight coupling has already throttled velocity. Use the matrix as a forcing function for an honest conversation, start with a hybrid, and let the pain points tell you which services need their own database next.

Next Steps

  1. Assess current state: Team size, deployment frequency, transaction patterns
  2. Identify pain points: Deployment bottlenecks, scaling issues, team conflicts
  3. Start small: Extract one service to its own database
  4. Implement patterns: Saga for transactions, events for sync, API composition for queries
  5. Measure impact: Deployment time, team velocity, incident resolution time
  6. Iterate: Extract more services if benefits outweigh costs

About the author

T

Tharindu Perera

Tharindu Perera is a software engineer and solutions architect. He writes Refactix to share patterns from production work across AWS, distributed systems, and AI-driven development.

Follow RefactixLinkedIn·Facebook

Share this article

Topics Covered

Database Per ServiceShared DatabaseMicroservices DatabaseDistributed TransactionsSaga PatternDatabase Design

You Might Also Like

More from Refactix

Browse the full archive of guides and tutorials on AI, cloud, and modern architecture.

Explore All Guides
Subscribe

New articles, straight to your inbox

I publish new guides on AI-driven development, cloud infrastructure, and software architecture on a Tuesday and Friday cadence. Subscribe to get each one when it lands.

No spam, unsubscribe anytimeReal tech insights weekly