Skip to main content

Database Patterns

Data management is one of the most challenging aspects of microservices. This chapter covers patterns for managing data across distributed services.
Learning Objectives:
  • Implement database-per-service pattern
  • Handle data consistency across services
  • Design shared data strategies
  • Execute zero-downtime database migrations
  • Choose the right database for each service

Database Per Service Pattern

Why Separate Databases?

┌─────────────────────────────────────────────────────────────────────────────┐
│                    SHARED DATABASE (Anti-Pattern)                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                       │
│  │  Service A   │  │  Service B   │  │  Service C   │                       │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘                       │
│         │                 │                 │                                │
│         └─────────────────┼─────────────────┘                               │
│                           ▼                                                  │
│                 ┌───────────────────┐                                       │
│                 │  Shared Database  │                                       │
│                 │  ┌─────────────┐  │                                       │
│                 │  │   users     │  │                                       │
│                 │  │   orders    │  │                                       │
│                 │  │   products  │  │                                       │
│                 │  │   payments  │  │                                       │
│                 │  └─────────────┘  │                                       │
│                 └───────────────────┘                                       │
│                                                                              │
│  ⚠️ Problems:                                                                │
│  • Tight coupling through schema                                            │
│  • Can't deploy independently                                               │
│  • Schema changes affect all services                                       │
│  • Single point of failure                                                  │
│  • Can't scale databases independently                                      │
│  • Technology lock-in                                                       │
│                                                                              │
│  ═══════════════════════════════════════════════════════════════════════════│
│                                                                              │
│                   DATABASE PER SERVICE (Recommended)                         │
│  ─────────────────────────────────────────────────────────────────────────  │
│                                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                       │
│  │  User Svc    │  │  Order Svc   │  │ Product Svc  │                       │
│  │  ┌────────┐  │  │  ┌────────┐  │  │  ┌────────┐  │                       │
│  │  │  API   │  │  │  │  API   │  │  │  │  API   │  │                       │
│  │  └───┬────┘  │  │  └───┬────┘  │  │  └───┬────┘  │                       │
│  │      │       │  │      │       │  │      │       │                       │
│  │  ┌───▼────┐  │  │  ┌───▼────┐  │  │  ┌───▼────┐  │                       │
│  │  │MongoDB │  │  │  │Postgres│  │  │  │  Redis │  │                       │
│  │  │(users) │  │  │  │(orders)│  │  │  │(cache) │  │                       │
│  │  └────────┘  │  │  └────────┘  │  │  └────────┘  │                       │
│  └──────────────┘  └──────────────┘  └──────────────┘                       │
│                                                                              │
│  ✅ Benefits:                                                                │
│  • Loose coupling                                                           │
│  • Independent deployment                                                   │
│  • Right database for the job                                               │
│  • Independent scaling                                                      │
│  • Fault isolation                                                          │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Implementation Patterns

// services/user-service/database.js
// Each service manages its own database connection

const mongoose = require('mongoose');

class UserDatabase {
  constructor() {
    this.connection = null;
  }

  async connect() {
    // User service uses MongoDB
    this.connection = await mongoose.connect(process.env.USER_DB_URL, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
      maxPoolSize: 10,
      serverSelectionTimeoutMS: 5000,
      socketTimeoutMS: 45000
    });

    mongoose.connection.on('error', (err) => {
      console.error('MongoDB connection error:', err);
    });

    mongoose.connection.on('disconnected', () => {
      console.log('MongoDB disconnected, attempting reconnection...');
      this.connect();
    });

    console.log('User service connected to MongoDB');
  }

  async disconnect() {
    await mongoose.connection.close();
  }
}

// User schema - owned by user service only
const userSchema = new mongoose.Schema({
  id: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  name: { type: String, required: true },
  hashedPassword: { type: String, required: true },
  profile: {
    avatar: String,
    bio: String,
    preferences: mongoose.Schema.Types.Mixed
  },
  status: {
    type: String,
    enum: ['active', 'inactive', 'suspended'],
    default: 'active'
  },
  createdAt: { type: Date, default: Date.now },
  updatedAt: { type: Date, default: Date.now }
});

const User = mongoose.model('User', userSchema);

module.exports = { UserDatabase, User };
// services/order-service/database.js
// Order service uses PostgreSQL for ACID transactions

const { Pool } = require('pg');
const { Sequelize } = require('sequelize');

class OrderDatabase {
  constructor() {
    this.sequelize = null;
  }

  async connect() {
    this.sequelize = new Sequelize(process.env.ORDER_DB_URL, {
      dialect: 'postgres',
      pool: {
        max: 20,
        min: 5,
        acquire: 30000,
        idle: 10000
      },
      logging: process.env.NODE_ENV === 'development' ? console.log : false
    });

    await this.sequelize.authenticate();
    console.log('Order service connected to PostgreSQL');
  }

  async runMigrations() {
    await this.sequelize.sync({ alter: process.env.NODE_ENV !== 'production' });
  }
}

// Order model - owned by order service only
const { DataTypes } = require('sequelize');

module.exports = (sequelize) => {
  const Order = sequelize.define('Order', {
    id: {
      type: DataTypes.UUID,
      defaultValue: DataTypes.UUIDV4,
      primaryKey: true
    },
    userId: {
      type: DataTypes.STRING,
      allowNull: false,
      // Note: No foreign key to users table - that's in a different database!
      index: true
    },
    status: {
      type: DataTypes.ENUM('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled'),
      defaultValue: 'pending'
    },
    items: {
      type: DataTypes.JSONB,
      allowNull: false
    },
    totalAmount: {
      type: DataTypes.DECIMAL(10, 2),
      allowNull: false
    },
    shippingAddress: {
      type: DataTypes.JSONB
    },
    metadata: {
      type: DataTypes.JSONB,
      defaultValue: {}
    }
  }, {
    timestamps: true,
    indexes: [
      { fields: ['userId', 'createdAt'] },
      { fields: ['status'] }
    ]
  });

  return Order;
};

Handling Data Ownership

// Order service needs user data but doesn't own it
// Pattern: Store only the user ID, fetch details when needed

// services/order-service/services/order-service.js
class OrderService {
  constructor(userClient, productClient) {
    this.userClient = userClient;  // HTTP client to user service
    this.productClient = productClient;  // HTTP client to product service
  }

  async createOrder(userId, items) {
    // Validate user exists (call user service)
    const user = await this.userClient.getUser(userId);
    if (!user) {
      throw new NotFoundError('User not found');
    }

    // Validate products and get prices (call product service)
    const products = await this.productClient.getProducts(
      items.map(i => i.productId)
    );

    // Calculate total
    const orderItems = items.map(item => {
      const product = products.find(p => p.id === item.productId);
      if (!product) {
        throw new NotFoundError(`Product ${item.productId} not found`);
      }
      return {
        productId: product.id,
        name: product.name,  // Denormalize for historical record
        price: product.price,  // Price at time of order
        quantity: item.quantity,
        subtotal: product.price * item.quantity
      };
    });

    const totalAmount = orderItems.reduce((sum, item) => sum + item.subtotal, 0);

    // Create order (only store references, not full data)
    const order = await Order.create({
      userId,  // Just the ID, not full user data
      items: orderItems,
      totalAmount,
      status: 'pending'
    });

    // Publish event for other services
    await this.eventBus.publish('order.created', {
      orderId: order.id,
      userId,
      items: orderItems,
      totalAmount
    });

    return order;
  }

  async getOrderWithDetails(orderId) {
    const order = await Order.findByPk(orderId);
    if (!order) return null;

    // Enrich with current user data (for display purposes)
    const user = await this.userClient.getUser(order.userId);

    return {
      ...order.toJSON(),
      user: user ? {
        id: user.id,
        name: user.name,
        email: user.email
      } : null
    };
  }
}

Data Consistency Strategies

Eventual Consistency

// Most microservices use eventual consistency
// Example: User updates email, order service eventually learns about it

// user-service/handlers/user-updated.js
class UserEventHandler {
  async handleUserUpdated(event) {
    const { userId, changes } = event;
    
    // Publish event
    await eventBus.publish('user.updated', {
      userId,
      email: changes.email,
      name: changes.name,
      updatedAt: new Date().toISOString()
    });
  }
}

// order-service/handlers/user-events.js
class UserEventConsumer {
  constructor() {
    // Local cache/read model of user data needed by order service
    this.userCache = new Map();
  }

  async handleUserUpdated(event) {
    const { userId, email, name } = event;
    
    // Update local cache
    this.userCache.set(userId, {
      id: userId,
      email,
      name,
      lastUpdated: new Date()
    });
    
    // Optionally update denormalized data in orders
    // (depends on business requirements)
  }
}

Saga Pattern for Distributed Transactions

// Example: Order creation saga with compensation

class OrderSaga {
  constructor(orderService, inventoryClient, paymentClient, eventBus) {
    this.orderService = orderService;
    this.inventoryClient = inventoryClient;
    this.paymentClient = paymentClient;
    this.eventBus = eventBus;
  }

  async execute(orderData) {
    const saga = new SagaExecution();
    let order = null;
    let reservation = null;
    let payment = null;

    try {
      // Step 1: Create order
      saga.addStep({
        name: 'createOrder',
        execute: async () => {
          order = await this.orderService.createOrder(orderData);
          return order;
        },
        compensate: async () => {
          if (order) {
            await this.orderService.cancelOrder(order.id);
          }
        }
      });

      // Step 2: Reserve inventory
      saga.addStep({
        name: 'reserveInventory',
        execute: async () => {
          reservation = await this.inventoryClient.reserve({
            orderId: order.id,
            items: order.items
          });
          return reservation;
        },
        compensate: async () => {
          if (reservation) {
            await this.inventoryClient.release(reservation.id);
          }
        }
      });

      // Step 3: Process payment
      saga.addStep({
        name: 'processPayment',
        execute: async () => {
          payment = await this.paymentClient.charge({
            orderId: order.id,
            userId: order.userId,
            amount: order.totalAmount
          });
          return payment;
        },
        compensate: async () => {
          if (payment) {
            await this.paymentClient.refund(payment.id);
          }
        }
      });

      // Step 4: Confirm order
      saga.addStep({
        name: 'confirmOrder',
        execute: async () => {
          await this.orderService.confirmOrder(order.id);
          await this.inventoryClient.commit(reservation.id);
          return order;
        },
        compensate: async () => {
          // Nothing to compensate - previous steps will handle it
        }
      });

      // Execute saga
      await saga.run();

      // Publish success event
      await this.eventBus.publish('order.completed', {
        orderId: order.id,
        userId: order.userId
      });

      return order;

    } catch (error) {
      // Saga will automatically run compensations
      console.error('Order saga failed:', error);
      
      await this.eventBus.publish('order.failed', {
        orderId: order?.id,
        userId: orderData.userId,
        reason: error.message
      });

      throw error;
    }
  }
}

// Saga execution engine
class SagaExecution {
  constructor() {
    this.steps = [];
    this.completedSteps = [];
  }

  addStep(step) {
    this.steps.push(step);
  }

  async run() {
    for (const step of this.steps) {
      try {
        console.log(`Executing step: ${step.name}`);
        const result = await step.execute();
        this.completedSteps.push(step);
      } catch (error) {
        console.error(`Step ${step.name} failed:`, error);
        await this.compensate();
        throw error;
      }
    }
  }

  async compensate() {
    // Execute compensations in reverse order
    for (const step of this.completedSteps.reverse()) {
      try {
        console.log(`Compensating step: ${step.name}`);
        await step.compensate();
      } catch (error) {
        console.error(`Compensation for ${step.name} failed:`, error);
        // Log for manual intervention
      }
    }
  }
}

Outbox Pattern

Ensure events are published reliably:
// database/models/outbox.js
const OutboxMessage = sequelize.define('OutboxMessage', {
  id: {
    type: DataTypes.UUID,
    defaultValue: DataTypes.UUIDV4,
    primaryKey: true
  },
  aggregateType: {
    type: DataTypes.STRING,
    allowNull: false
  },
  aggregateId: {
    type: DataTypes.STRING,
    allowNull: false
  },
  eventType: {
    type: DataTypes.STRING,
    allowNull: false
  },
  payload: {
    type: DataTypes.JSONB,
    allowNull: false
  },
  published: {
    type: DataTypes.BOOLEAN,
    defaultValue: false
  },
  publishedAt: {
    type: DataTypes.DATE
  },
  retryCount: {
    type: DataTypes.INTEGER,
    defaultValue: 0
  }
}, {
  indexes: [
    { fields: ['published', 'createdAt'] }
  ]
});

// services/order-service.js
class OrderService {
  async createOrder(orderData, transaction) {
    // Create order and outbox message in same transaction
    const t = transaction || await sequelize.transaction();

    try {
      const order = await Order.create(orderData, { transaction: t });

      // Write to outbox in same transaction
      await OutboxMessage.create({
        aggregateType: 'Order',
        aggregateId: order.id,
        eventType: 'order.created',
        payload: {
          orderId: order.id,
          userId: order.userId,
          items: order.items,
          totalAmount: order.totalAmount,
          createdAt: order.createdAt
        }
      }, { transaction: t });

      await t.commit();
      return order;

    } catch (error) {
      await t.rollback();
      throw error;
    }
  }
}

// workers/outbox-publisher.js
class OutboxPublisher {
  constructor(eventBus) {
    this.eventBus = eventBus;
    this.batchSize = 100;
    this.pollInterval = 1000;  // 1 second
  }

  async start() {
    console.log('Outbox publisher started');
    
    while (true) {
      try {
        await this.publishPendingMessages();
      } catch (error) {
        console.error('Outbox publisher error:', error);
      }
      
      await new Promise(r => setTimeout(r, this.pollInterval));
    }
  }

  async publishPendingMessages() {
    const messages = await OutboxMessage.findAll({
      where: {
        published: false,
        retryCount: { [Op.lt]: 5 }
      },
      order: [['createdAt', 'ASC']],
      limit: this.batchSize
    });

    for (const message of messages) {
      try {
        await this.eventBus.publish(message.eventType, {
          ...message.payload,
          _metadata: {
            messageId: message.id,
            aggregateType: message.aggregateType,
            aggregateId: message.aggregateId,
            timestamp: message.createdAt
          }
        });

        await message.update({
          published: true,
          publishedAt: new Date()
        });

      } catch (error) {
        console.error(`Failed to publish message ${message.id}:`, error);
        await message.increment('retryCount');
      }
    }
  }
}

Data Replication Strategies

Change Data Capture (CDC)

// Using Debezium for CDC (conceptual Node.js consumer)

const { Kafka } = require('kafkajs');

class CDCConsumer {
  constructor(kafkaConfig) {
    this.kafka = new Kafka(kafkaConfig);
    this.consumer = this.kafka.consumer({ groupId: 'cdc-consumer' });
  }

  async start() {
    await this.consumer.connect();
    
    // Subscribe to CDC topics (Debezium format)
    await this.consumer.subscribe({
      topics: [
        'dbserver1.inventory.products',  // CDC topic for products table
        'dbserver1.users.users'          // CDC topic for users table
      ]
    });

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

  async handleChange(topic, change) {
    // Debezium message format
    const { before, after, op, source } = change.payload;
    
    /*
      op values:
      'c' = create
      'u' = update
      'd' = delete
      'r' = read (snapshot)
    */
    
    switch (op) {
      case 'c':
        await this.handleCreate(topic, after);
        break;
      case 'u':
        await this.handleUpdate(topic, before, after);
        break;
      case 'd':
        await this.handleDelete(topic, before);
        break;
    }
  }

  async handleCreate(topic, data) {
    if (topic.includes('products')) {
      // Update local product cache/read model
      await this.productCache.set(data.id, {
        id: data.id,
        name: data.name,
        price: data.price / 100,  // Convert cents to dollars
        stock: data.stock
      });
    }
  }

  async handleUpdate(topic, before, after) {
    if (topic.includes('products')) {
      // Update local cache
      await this.productCache.set(after.id, {
        id: after.id,
        name: after.name,
        price: after.price / 100,
        stock: after.stock
      });

      // Check for significant changes
      if (before.price !== after.price) {
        await this.eventBus.publish('product.price_changed', {
          productId: after.id,
          oldPrice: before.price / 100,
          newPrice: after.price / 100
        });
      }
    }
  }

  async handleDelete(topic, before) {
    if (topic.includes('products')) {
      await this.productCache.delete(before.id);
    }
  }
}

CQRS (Command Query Responsibility Segregation)

// Separate read and write models

// Write side - commands
class OrderCommandService {
  constructor(orderRepository, eventStore) {
    this.orderRepository = orderRepository;
    this.eventStore = eventStore;
  }

  async createOrder(command) {
    // Validate command
    this.validateCreateOrder(command);

    // Create order aggregate
    const order = new OrderAggregate();
    order.create(command);

    // Save to event store
    await this.eventStore.save(order.id, order.uncommittedEvents);

    // Publish events
    for (const event of order.uncommittedEvents) {
      await this.eventBus.publish(event.type, event);
    }

    return order.id;
  }

  async cancelOrder(orderId, reason) {
    // Load aggregate from event store
    const events = await this.eventStore.getEvents(orderId);
    const order = OrderAggregate.fromEvents(events);

    // Apply command
    order.cancel(reason);

    // Save new events
    await this.eventStore.save(orderId, order.uncommittedEvents);

    // Publish events
    for (const event of order.uncommittedEvents) {
      await this.eventBus.publish(event.type, event);
    }
  }
}

// Read side - queries (optimized for reads)
class OrderQueryService {
  constructor(readDatabase) {
    this.db = readDatabase;  // Optimized read database (could be Elasticsearch, Redis, etc.)
  }

  async getOrderById(orderId) {
    return this.db.orders.findOne({ id: orderId });
  }

  async getOrdersByUser(userId, options = {}) {
    const { page = 1, limit = 20, status } = options;
    
    const query = { userId };
    if (status) query.status = status;

    return this.db.orders.find(query)
      .sort({ createdAt: -1 })
      .skip((page - 1) * limit)
      .limit(limit);
  }

  async getOrderStats(userId) {
    // Aggregate query on read model
    return this.db.orders.aggregate([
      { $match: { userId } },
      {
        $group: {
          _id: '$status',
          count: { $sum: 1 },
          totalAmount: { $sum: '$totalAmount' }
        }
      }
    ]);
  }
}

// Projection - updates read model from events
class OrderProjection {
  constructor(readDatabase) {
    this.db = readDatabase;
  }

  async handleEvent(event) {
    switch (event.type) {
      case 'order.created':
        await this.onOrderCreated(event);
        break;
      case 'order.confirmed':
        await this.onOrderConfirmed(event);
        break;
      case 'order.shipped':
        await this.onOrderShipped(event);
        break;
      case 'order.cancelled':
        await this.onOrderCancelled(event);
        break;
    }
  }

  async onOrderCreated(event) {
    await this.db.orders.insertOne({
      id: event.orderId,
      userId: event.userId,
      items: event.items,
      totalAmount: event.totalAmount,
      status: 'pending',
      createdAt: event.timestamp,
      updatedAt: event.timestamp
    });
  }

  async onOrderShipped(event) {
    await this.db.orders.updateOne(
      { id: event.orderId },
      {
        $set: {
          status: 'shipped',
          shippedAt: event.timestamp,
          trackingNumber: event.trackingNumber,
          updatedAt: event.timestamp
        }
      }
    );
  }
}

Zero-Downtime Database Migrations

Expand-Contract Pattern

// migrations/expand-contract/add-email-verified.js

/*
 * Goal: Add email_verified column to users table
 * 
 * Phase 1: EXPAND (add column, nullable)
 * Phase 2: MIGRATE (backfill data)
 * Phase 3: CONTRACT (make non-nullable, remove old logic)
 */

// Phase 1: Expand - Add nullable column
const phase1_expand = {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn('users', 'email_verified', {
      type: Sequelize.BOOLEAN,
      allowNull: true,  // Nullable initially
      defaultValue: null
    });
  },
  
  async down(queryInterface) {
    await queryInterface.removeColumn('users', 'email_verified');
  }
};

// Phase 2: Update application to write to new column
// Deploy code that writes to both old and new columns

class UserService {
  async updateEmailVerification(userId, verified) {
    // Write to new column
    await User.update(
      { email_verified: verified },
      { where: { id: userId } }
    );
    
    // Also update any legacy fields if they exist
    // ...
  }
}

// Phase 3: Backfill - Migrate existing data
const phase3_backfill = {
  async up(queryInterface) {
    // Backfill in batches to avoid locking
    let offset = 0;
    const batchSize = 1000;
    
    while (true) {
      const [results] = await queryInterface.sequelize.query(`
        UPDATE users 
        SET email_verified = CASE 
          WHEN verified_at IS NOT NULL THEN true 
          ELSE false 
        END
        WHERE email_verified IS NULL
        LIMIT ${batchSize}
      `);
      
      if (results.affectedRows === 0) break;
      
      console.log(`Backfilled ${offset + results.affectedRows} rows`);
      offset += batchSize;
      
      // Small delay to reduce database load
      await new Promise(r => setTimeout(r, 100));
    }
  }
};

// Phase 4: Contract - Make non-nullable after backfill complete
const phase4_contract = {
  async up(queryInterface, Sequelize) {
    // Verify all rows have values
    const [nullCount] = await queryInterface.sequelize.query(`
      SELECT COUNT(*) as count FROM users WHERE email_verified IS NULL
    `);
    
    if (nullCount[0].count > 0) {
      throw new Error('Cannot contract: null values still exist');
    }
    
    // Make column non-nullable
    await queryInterface.changeColumn('users', 'email_verified', {
      type: Sequelize.BOOLEAN,
      allowNull: false,
      defaultValue: false
    });
  }
};

Renaming a Column

// Renaming user.name to user.full_name (zero-downtime)

// Step 1: Add new column
const step1 = {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn('users', 'full_name', {
      type: Sequelize.STRING,
      allowNull: true
    });
  }
};

// Step 2: Deploy code that writes to BOTH columns
class UserService {
  async updateName(userId, name) {
    await User.update(
      {
        name: name,       // Old column
        full_name: name   // New column
      },
      { where: { id: userId } }
    );
  }
}

// Step 3: Backfill new column
const step3 = {
  async up(queryInterface) {
    await queryInterface.sequelize.query(`
      UPDATE users SET full_name = name WHERE full_name IS NULL
    `);
  }
};

// Step 4: Deploy code that reads from new column
class UserService {
  async getUser(userId) {
    const user = await User.findByPk(userId);
    return {
      ...user.toJSON(),
      // Use new column, fallback to old
      fullName: user.full_name || user.name
    };
  }
}

// Step 5: Deploy code that ONLY writes to new column
class UserService {
  async updateName(userId, name) {
    await User.update(
      { full_name: name },
      { where: { id: userId } }
    );
  }
}

// Step 6: Drop old column (after verifying all reads use new column)
const step6 = {
  async up(queryInterface) {
    await queryInterface.removeColumn('users', 'name');
  }
};

Polyglot Persistence

Choosing the Right Database

┌─────────────────────────────────────────────────────────────────────────────┐
│                    POLYGLOT PERSISTENCE                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Service          │ Database        │ Why                                   │
│  ─────────────────┼─────────────────┼─────────────────────────────────────  │
│  User Service     │ PostgreSQL      │ ACID transactions, complex queries    │
│  Product Catalog  │ MongoDB         │ Flexible schema, document structure   │
│  Shopping Cart    │ Redis           │ Fast, ephemeral, TTL support          │
│  Search Service   │ Elasticsearch   │ Full-text search, faceting            │
│  Order Service    │ PostgreSQL      │ Transactions, consistency required    │
│  Analytics        │ ClickHouse      │ Columnar, fast aggregations           │
│  Session Store    │ Redis           │ Fast reads, TTL, distributed          │
│  Notifications    │ MongoDB         │ Flexible schema, time-series          │
│  Audit Logs       │ Cassandra       │ Write-heavy, time-series, scalable    │
│  Graph Relations  │ Neo4j           │ Relationship queries, recommendations │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘
// Multi-database service example

// services/product-service/databases/index.js
class ProductDatabases {
  constructor() {
    this.mongo = null;      // Primary storage
    this.elastic = null;    // Search index
    this.redis = null;      // Cache
  }

  async connect() {
    // MongoDB for product data
    this.mongo = await mongoose.connect(process.env.MONGO_URL);
    
    // Elasticsearch for search
    this.elastic = new Client({
      node: process.env.ELASTICSEARCH_URL
    });
    
    // Redis for caching
    this.redis = createClient({
      url: process.env.REDIS_URL
    });
    await this.redis.connect();
  }
}

// Product service using multiple databases
class ProductService {
  constructor(databases) {
    this.db = databases;
  }

  async createProduct(productData) {
    // Write to MongoDB (source of truth)
    const product = await Product.create(productData);
    
    // Index in Elasticsearch for search
    await this.db.elastic.index({
      index: 'products',
      id: product.id,
      body: {
        name: product.name,
        description: product.description,
        category: product.category,
        price: product.price,
        tags: product.tags
      }
    });
    
    // Invalidate cache
    await this.db.redis.del(`product:${product.id}`);
    
    return product;
  }

  async getProduct(productId) {
    // Try cache first
    const cached = await this.db.redis.get(`product:${productId}`);
    if (cached) {
      return JSON.parse(cached);
    }
    
    // Fetch from MongoDB
    const product = await Product.findById(productId);
    if (!product) return null;
    
    // Cache for 5 minutes
    await this.db.redis.setEx(
      `product:${productId}`,
      300,
      JSON.stringify(product)
    );
    
    return product;
  }

  async searchProducts(query, filters = {}) {
    // Use Elasticsearch for search
    const result = await this.db.elastic.search({
      index: 'products',
      body: {
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query,
                  fields: ['name^3', 'description', 'tags']
                }
              }
            ],
            filter: this.buildFilters(filters)
          }
        },
        aggs: {
          categories: { terms: { field: 'category.keyword' } },
          price_ranges: {
            range: {
              field: 'price',
              ranges: [
                { to: 25 },
                { from: 25, to: 50 },
                { from: 50, to: 100 },
                { from: 100 }
              ]
            }
          }
        }
      }
    });
    
    return {
      hits: result.hits.hits.map(h => ({ id: h._id, ...h._source })),
      total: result.hits.total.value,
      facets: result.aggregations
    };
  }
}

Interview Questions

Answer:Benefits:
  • Loose coupling between services
  • Independent scaling
  • Right database for each use case
  • Isolated failures
  • Independent deployments
Challenges:
  • Data consistency (no ACID across services)
  • Query complexity (no JOINs across databases)
  • Duplicate data management
  • Operational overhead
Solutions:
  • Saga pattern for transactions
  • Event sourcing for consistency
  • CQRS for complex queries
  • API composition for cross-service data
Answer:Avoid 2PC (two-phase commit) - doesn’t scale, blocks resources.Use Saga pattern:
  1. Choreography: Services react to events, publish compensating events on failure
  2. Orchestration: Central coordinator manages the flow
Example (Order saga):
Create Order → Reserve Inventory → Charge Payment → Confirm
     ↓ failure       ↓ failure          ↓ failure
Cancel Order ← Release Inventory ← Refund Payment
Key practices:
  • Design idempotent operations
  • Store saga state for recovery
  • Implement timeouts
  • Log everything for debugging
Answer:The Outbox pattern ensures reliable event publishing by writing events to an outbox table in the same transaction as the business data.Flow:
  1. Begin transaction
  2. Update business data
  3. Insert event to outbox table
  4. Commit transaction
  5. Background worker publishes events from outbox
  6. Mark events as published
Benefits:
  • Atomic: Data and event saved together
  • Reliable: Won’t lose events if message broker is down
  • Idempotent: Can replay if needed
Alternative: Change Data Capture (Debezium)
Answer:Expand-Contract Pattern:
  1. Expand: Add new column (nullable)
  2. Deploy: Code writes to both old and new
  3. Migrate: Backfill existing data
  4. Switch: Code reads from new column
  5. Contract: Make non-nullable, remove old
Never do in one deploy:
  • Rename columns
  • Change column types
  • Drop columns
  • Add NOT NULL without default
Always:
  • Backward compatible migrations
  • Small batches for data migration
  • Test rollback procedures
Answer:CQRS = Command Query Responsibility SegregationSeparate read (Query) and write (Command) models.Use when:
  • Read and write patterns differ significantly
  • Complex read queries (aggregations, joins)
  • Need to scale reads independently
  • Event sourcing is used
Benefits:
  • Optimized read models (denormalized)
  • Better read performance
  • Simpler write logic
Avoid when:
  • Simple CRUD operations
  • Strong consistency required
  • Small team (adds complexity)

Chapter Summary

Key Takeaways:
  • Each service should own its database (no shared schemas)
  • Use Saga pattern for distributed transactions
  • Outbox pattern ensures reliable event publishing
  • CQRS separates read and write concerns
  • Zero-downtime migrations use expand-contract pattern
  • Choose the right database for each use case (polyglot persistence)
Next Chapter: Caching Strategies - Distributed caching patterns for microservices.