Episode 6 — Scaling Reliability Microservices Web3 / 6.1 — Microservice Foundations

6.1.c -- Service Boundaries

The hardest problem in microservices is not technology -- it is deciding where one service ends and another begins. Domain-Driven Design gives us the tools to draw those lines correctly.


Navigation << 6.1.b When Microservices Make Sense | 6.1.c Service Boundaries | 6.1.d Database per Service >>


1. Why Boundaries Matter

Wrong boundaries create a distributed monolith -- all the pain of microservices with none of the benefits:

Good boundaries:                    Bad boundaries:
+--------+  +--------+             +--------+  +--------+
| Order  |  | Payment|             | Order  |  | Order  |
| Service|  | Service|             | Create |  | Update |
|        |  |        |             |        |  |        |
| owns:  |  | owns:  |             | needs: |  | needs: |
| orders |  | txns   |             | Order  |  | Order  |
| items  |  | refunds|             | Create |  | Create |
+--------+  +--------+             +--------+  +--------+
                                        |           |
Independent. Rarely talk.          Must call each other for
                                   every operation. Tightly coupled.

2. Domain-Driven Design (DDD) Basics

DDD, introduced by Eric Evans in 2003, provides a vocabulary and methodology for decomposing complex systems.

2.1 Core Concepts

ConceptDefinitionExample
DomainThe subject area your software addressesE-commerce, healthcare, social media
SubdomainA distinct area within the domainCatalog, ordering, shipping, payments
Bounded ContextA boundary within which a domain model is defined and consistent"Product" means one thing in Catalog, another in Shipping
Ubiquitous LanguageThe shared vocabulary between developers and domain experts within a bounded context"Order" in the Order context vs "Shipment" in the Logistics context
AggregateA cluster of objects treated as a single unit for data changesAn Order with its OrderItems
EntityAn object with a unique identity that persists over timeUser (identified by userId)
Value ObjectAn object defined by its attributes, not identityMoney(100, 'USD'), Address('123 Main St')
Domain EventA record of something meaningful that happened in the domainOrderPlaced, PaymentReceived, InventoryReserved

2.2 The Key Insight: Bounded Contexts

The same word can mean different things in different contexts. This is not a bug -- it is a feature.

+-------------------+    +-------------------+    +-------------------+
|   Catalog Context  |    |   Order Context    |    |  Shipping Context  |
+-------------------+    +-------------------+    +-------------------+
|                   |    |                   |    |                   |
| Product:          |    | Product:          |    | Product:          |
|   - name          |    |   - productId     |    |   - productId     |
|   - description   |    |   - price (at     |    |   - weight        |
|   - price         |    |     time of order)|    |   - dimensions    |
|   - images[]      |    |   - quantity      |    |   - fragile       |
|   - category      |    |                   |    |   - hazardous     |
|   - reviews[]     |    |                   |    |                   |
+-------------------+    +-------------------+    +-------------------+

"Product" is a DIFFERENT model in each context.
Each context stores only what it needs.

This is the foundation of microservice boundaries: each bounded context can become a service.


3. Identifying Service Boundaries

3.1 The Process

Step 1: Map the Domain
  - Talk to domain experts (product managers, business stakeholders)
  - Identify nouns (entities) and verbs (actions)
  - Group related nouns and verbs together

Step 2: Identify Bounded Contexts
  - Where does the meaning of a term change?
  - Where do teams naturally separate?
  - Where are the data ownership boundaries?

Step 3: Define Context Maps
  - How do bounded contexts relate to each other?
  - What data flows between them?
  - What is the interface (API contract) between them?

Step 4: Validate with Team Structure
  - Can one team own this context?
  - Is the boundary aligned with organisational structure?
  - Does the team have all the skills needed?

3.2 Practical Example: E-Commerce Domain Decomposition

+------------------------------------------------------------------+
|                    E-COMMERCE DOMAIN                              |
|                                                                  |
|  +------------+  +------------+  +------------+  +------------+  |
|  |  Catalog   |  |  Ordering  |  |  Payment   |  |  Shipping  |  |
|  +------------+  +------------+  +------------+  +------------+  |
|  | Products   |  | Cart       |  | Charges    |  | Shipments  |  |
|  | Categories |  | Orders     |  | Refunds    |  | Tracking   |  |
|  | Search     |  | OrderItems |  | Invoices   |  | Returns    |  |
|  | Reviews    |  | Discounts  |  | PayMethods |  | Labels     |  |
|  +------------+  +------------+  +------------+  +------------+  |
|                                                                  |
|  +------------+  +------------+  +------------+                  |
|  |  Identity  |  | Notification|  | Analytics  |                  |
|  +------------+  +------------+  +------------+                  |
|  | Users      |  | Email      |  | Events     |                  |
|  | Auth       |  | SMS        |  | Reports    |                  |
|  | Profiles   |  | Push       |  | Dashboards |                  |
|  | Addresses  |  | Templates  |  |            |                  |
|  +------------+  +------------+  +------------+                  |
+------------------------------------------------------------------+

Each box is a bounded context and a candidate microservice.

3.3 Code Example: Service Boundary Definition

// catalog-service/index.js
// Owns: products, categories, search, reviews
// Does NOT own: pricing rules (those belong to Order context)

const express = require('express');
const { Pool } = require('pg');

const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.CATALOG_DB_URL });

// Product in the Catalog context -- full product information
app.get('/products/:id', async (req, res) => {
  const result = await pool.query(`
    SELECT p.*, 
           array_agg(DISTINCT c.name) as categories,
           avg(r.rating) as avg_rating,
           count(r.id) as review_count
    FROM products p
    LEFT JOIN product_categories pc ON p.id = pc.product_id
    LEFT JOIN categories c ON pc.category_id = c.id
    LEFT JOIN reviews r ON p.id = r.product_id
    WHERE p.id = $1
    GROUP BY p.id
  `, [req.params.id]);

  if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
  res.json(result.rows[0]);
});

app.get('/products', async (req, res) => {
  const { category, search, page = 1, limit = 20 } = req.query;
  const offset = (page - 1) * limit;

  let query = 'SELECT * FROM products WHERE 1=1';
  const params = [];

  if (search) {
    params.push(`%${search}%`);
    query += ` AND (name ILIKE $${params.length} OR description ILIKE $${params.length})`;
  }

  query += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
  params.push(limit, offset);

  const result = await pool.query(query, params);
  res.json({ products: result.rows, page: Number(page), limit: Number(limit) });
});

app.listen(3001, () => console.log('Catalog Service on :3001'));
// order-service/index.js
// Owns: orders, cart, order items
// Does NOT own: product details, user profiles, payment processing

const express = require('express');
const axios = require('axios');
const { Pool } = require('pg');

const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.ORDER_DB_URL });

const CATALOG_SERVICE = process.env.CATALOG_SERVICE_URL || 'http://localhost:3001';
const PAYMENT_SERVICE = process.env.PAYMENT_SERVICE_URL || 'http://localhost:3003';

app.post('/orders', async (req, res) => {
  const { userId, items } = req.body; // items: [{ productId, quantity }]

  try {
    // 1. Fetch product info from Catalog Service (only what we need: id, price, name)
    const productPromises = items.map(item =>
      axios.get(`${CATALOG_SERVICE}/products/${item.productId}`)
    );
    const productResponses = await Promise.all(productPromises);

    // 2. Build order with snapshotted prices (price at time of order)
    const orderItems = items.map((item, i) => ({
      productId: item.productId,
      productName: productResponses[i].data.name,   // snapshot
      priceAtOrder: productResponses[i].data.price,  // snapshot
      quantity: item.quantity,
    }));

    const totalAmount = orderItems.reduce(
      (sum, item) => sum + item.priceAtOrder * item.quantity, 0
    );

    // 3. Create order in our own database
    const client = await pool.connect();
    try {
      await client.query('BEGIN');

      const orderResult = await client.query(
        'INSERT INTO orders (user_id, total_amount, status) VALUES ($1, $2, $3) RETURNING *',
        [userId, totalAmount, 'pending']
      );
      const order = orderResult.rows[0];

      for (const item of orderItems) {
        await client.query(
          `INSERT INTO order_items (order_id, product_id, product_name, price_at_order, quantity) 
           VALUES ($1, $2, $3, $4, $5)`,
          [order.id, item.productId, item.productName, item.priceAtOrder, item.quantity]
        );
      }

      await client.query('COMMIT');

      // 4. Request payment (async -- could also be event-driven)
      await axios.post(`${PAYMENT_SERVICE}/payments`, {
        orderId: order.id,
        amount: totalAmount,
        userId,
      });

      res.status(201).json({ ...order, items: orderItems });
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  } catch (err) {
    console.error('Order creation failed:', err.message);
    res.status(500).json({ error: 'Order creation failed' });
  }
});

app.listen(3002, () => console.log('Order Service on :3002'));

Notice how the Order Service:

  • Snapshots product prices (does not depend on Catalog for historical accuracy).
  • Has its own order_items table with product_name and price_at_order.
  • Calls Payment Service but does not handle payment logic.

4. Social Media Domain Decomposition

+------------------------------------------------------------------+
|                    SOCIAL MEDIA DOMAIN                            |
|                                                                  |
|  +------------+  +------------+  +------------+  +------------+  |
|  |  Identity  |  |   Content  |  |   Social   |  |   Feed     |  |
|  +------------+  +------------+  +------------+  +------------+  |
|  | Users      |  | Posts      |  | Follows    |  | Timeline   |  |
|  | Profiles   |  | Comments   |  | Blocks     |  | Ranking    |  |
|  | Auth       |  | Media      |  | Likes      |  | Caching    |  |
|  | Settings   |  | Tags       |  | Shares     |  |            |  |
|  +------------+  +------------+  +------------+  +------------+  |
|                                                                  |
|  +------------+  +------------+  +------------+                  |
|  |  Messaging |  | Notification|  |   Search   |                  |
|  +------------+  +------------+  +------------+                  |
|  | Threads    |  | Push       |  | Users      |                  |
|  | Messages   |  | Email      |  | Posts      |                  |
|  | Read       |  | In-App     |  | Tags       |                  |
|  | Receipts   |  | Preferences|  | Trending   |                  |
|  +------------+  +------------+  +------------+                  |
+------------------------------------------------------------------+

Why these boundaries?

  • Identity and Content change at different rates and have different scaling needs.
  • Social (follows, likes) is read-heavy and needs aggressive caching -- different scaling profile.
  • Feed is the most computationally expensive (ranking algorithms) and benefits from independent scaling.
  • Messaging has real-time requirements (WebSockets) that differ from the rest.
  • Search uses specialised technology (Elasticsearch) and has its own indexing pipeline.

5. Common Boundary Mistakes

5.1 Splitting Too Thin (Nano-Services)

BAD: Each CRUD operation as a service
+-------------+  +-------------+  +-------------+  +-------------+
| CreateUser  |  | ReadUser    |  | UpdateUser  |  | DeleteUser  |
| Service     |  | Service     |  | Service     |  | Service     |
+-------------+  +-------------+  +-------------+  +-------------+

These are NOT business capabilities. They are HTTP verbs.
They will be tightly coupled and deploy together anyway.

Rule of thumb: If two "services" always change together and always deploy together, they are one service.

5.2 Splitting by Technical Layer

BAD: Services split by architecture layer
+-------------+  +-------------+  +-------------+
| Frontend    |  | Business    |  | Database    |
| Service     |  | Logic Svc   |  | Service     |
+-------------+  +-------------+  +-------------+

This is just a monolith with network hops.
Every request traverses all three. No independent deployment.

Rule of thumb: Split by business capability, not by technical layer.

5.3 Wrong Boundaries (High Coupling)

If Service A calls Service B for 80% of its requests:

  Request --> [Service A] ---> [Service B] ---> response
                  |                 |
             20% handled       80% handled
             here               here

These should probably be ONE service.

Rule of thumb: Services should be loosely coupled (few calls between them) and highly cohesive (related logic together).

5.4 Shared Data (Hidden Coupling)

BAD:
+--------+     +--------+
| Svc A  |     | Svc B  |
+---+----+     +---+----+
    |              |
    +----+---------+
         |
   +-----+------+
   | Shared DB   |
   +-------------+

A schema change in the shared DB breaks both services.
Deployment is no longer independent.

6. Data Ownership at Boundaries

6.1 The Principle

Each service owns its data. Other services can only access that data through the service's API. No backdoor database queries.

// WRONG: Order Service directly queries User database
const user = await userDbPool.query('SELECT * FROM users WHERE id = $1', [userId]);

// RIGHT: Order Service calls User Service API
const user = await axios.get(`${USER_SERVICE}/users/${userId}`);

6.2 Data Duplication Is Acceptable

In microservices, some data duplication is expected and healthy:

// Order Service stores a SNAPSHOT of product data at order time
// This is NOT redundant -- it is historically accurate

const orderItem = {
  orderId: 'ord_123',
  productId: 'prod_456',
  productName: 'Widget Pro',       // snapshot from Catalog at order time
  priceAtOrder: 29.99,             // snapshot -- price may change later
  quantity: 2,
};

// Six months later, the product is renamed to "Widget Ultra" and costs $39.99.
// The order still correctly reflects what the customer bought at $29.99.

6.3 Reference Data vs Owned Data

Data TypeHow to Handle
Owned dataFull CRUD in the owning service; other services call its API
Reference data (IDs)Store the foreign ID; call the owning service when you need details
Snapshot dataCopy critical fields at the time of the event (price at order time)
Cached dataCache another service's data locally with TTL; accept eventual staleness

7. Context Mapping: How Contexts Relate

+----------+            +----------+
| Upstream |  ------->  | Downstream|
| (Catalog)|  publishes | (Order)   |
|          |  events    |           |
+----------+            +----------+

Relationship types:

1. Customer-Supplier:  Downstream depends on upstream; upstream
                       accommodates downstream's needs.

2. Conformist:         Downstream must accept upstream's model as-is.
                       No negotiation.

3. Anti-Corruption     Downstream translates upstream's model into
   Layer (ACL):        its own. Protects internal model from external changes.

4. Published Language: Both sides agree on a shared schema (e.g., events).

7.1 Anti-Corruption Layer Example

// order-service/adapters/catalogAdapter.js
// Translates Catalog Service's product model into Order Service's model

const axios = require('axios');

const CATALOG_SERVICE = process.env.CATALOG_SERVICE_URL;

class CatalogAdapter {
  /**
   * Fetches a product from Catalog and maps it to the Order domain model.
   * The Order context does not need images, reviews, or categories.
   */
  async getProductForOrder(productId) {
    const response = await axios.get(`${CATALOG_SERVICE}/products/${productId}`);
    const catalogProduct = response.data;

    // Anti-Corruption Layer: map external model to internal model
    return {
      productId: catalogProduct.id,
      name: catalogProduct.name,
      currentPrice: catalogProduct.price,
      available: catalogProduct.stock > 0,
    };
  }
}

module.exports = new CatalogAdapter();
// Usage in order-service/routes/orders.js
const catalogAdapter = require('../adapters/catalogAdapter');

app.post('/orders', async (req, res) => {
  const product = await catalogAdapter.getProductForOrder(req.body.productId);
  // 'product' is now in OUR domain language, not Catalog's
  if (!product.available) {
    return res.status(400).json({ error: 'Product not available' });
  }
  // ...create order
});

8. Heuristics for Good Boundaries

HeuristicQuestion to Ask
Single ResponsibilityDoes this service do one business thing well?
Independent DeployabilityCan we deploy this without coordinating with other teams?
Data OwnershipDoes this service own its data, or is it sharing a database?
Team AlignmentCan one team (2-8 people) own this service?
Change FrequencyDo these features change together or independently?
Scaling ProfileDoes this need to scale differently from the rest?
Failure IsolationIf this fails, does it take down other capabilities?
Domain LanguageIs the vocabulary within this boundary consistent?

9. Key Takeaways

  1. Bounded contexts are the natural unit of decomposition. Each microservice should map to one bounded context.
  2. The same concept (e.g., "Product") has different meanings in different contexts. This is correct -- each context stores only what it needs.
  3. Split by business capability, not by technical layer. A "database service" is not a microservice -- it is a distributed monolith layer.
  4. Nano-services that always change and deploy together are one service. If the coupling is that tight, merge them.
  5. Data duplication across services is expected. Snapshots and caches are necessary for independence.
  6. Use Anti-Corruption Layers to translate external models into your domain language.
  7. Validate boundaries against team structure. If one team cannot own the service, the boundary is wrong.
  8. Get boundaries wrong and you get a distributed monolith -- all the cost, none of the benefit.

10. Explain-It Challenge

  1. You are designing a food delivery platform (like DoorDash). Identify at least 5 bounded contexts, and for each, list the key entities it owns and 2-3 operations it supports.

  2. A colleague proposes splitting the User Service into "UserAuth Service" and "UserProfile Service." Under what circumstances is this a good idea? Under what circumstances is it a mistake?

  3. Explain the Anti-Corruption Layer pattern to a developer who has never heard of DDD. Use a concrete code example to illustrate why mapping between models is important.


Navigation << 6.1.b When Microservices Make Sense | 6.1.c Service Boundaries | 6.1.d Database per Service >>