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
| Concept | Definition | Example |
|---|---|---|
| Domain | The subject area your software addresses | E-commerce, healthcare, social media |
| Subdomain | A distinct area within the domain | Catalog, ordering, shipping, payments |
| Bounded Context | A boundary within which a domain model is defined and consistent | "Product" means one thing in Catalog, another in Shipping |
| Ubiquitous Language | The shared vocabulary between developers and domain experts within a bounded context | "Order" in the Order context vs "Shipment" in the Logistics context |
| Aggregate | A cluster of objects treated as a single unit for data changes | An Order with its OrderItems |
| Entity | An object with a unique identity that persists over time | User (identified by userId) |
| Value Object | An object defined by its attributes, not identity | Money(100, 'USD'), Address('123 Main St') |
| Domain Event | A record of something meaningful that happened in the domain | OrderPlaced, 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_itemstable withproduct_nameandprice_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 Type | How to Handle |
|---|---|
| Owned data | Full 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 data | Copy critical fields at the time of the event (price at order time) |
| Cached data | Cache 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
| Heuristic | Question to Ask |
|---|---|
| Single Responsibility | Does this service do one business thing well? |
| Independent Deployability | Can we deploy this without coordinating with other teams? |
| Data Ownership | Does this service own its data, or is it sharing a database? |
| Team Alignment | Can one team (2-8 people) own this service? |
| Change Frequency | Do these features change together or independently? |
| Scaling Profile | Does this need to scale differently from the rest? |
| Failure Isolation | If this fails, does it take down other capabilities? |
| Domain Language | Is the vocabulary within this boundary consistent? |
9. Key Takeaways
- Bounded contexts are the natural unit of decomposition. Each microservice should map to one bounded context.
- The same concept (e.g., "Product") has different meanings in different contexts. This is correct -- each context stores only what it needs.
- Split by business capability, not by technical layer. A "database service" is not a microservice -- it is a distributed monolith layer.
- Nano-services that always change and deploy together are one service. If the coupling is that tight, merge them.
- Data duplication across services is expected. Snapshots and caches are necessary for independence.
- Use Anti-Corruption Layers to translate external models into your domain language.
- Validate boundaries against team structure. If one team cannot own the service, the boundary is wrong.
- Get boundaries wrong and you get a distributed monolith -- all the cost, none of the benefit.
10. Explain-It Challenge
-
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.
-
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?
-
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 >>