Theoretical Foundations
Welcome to the curriculum workspace. Here you will find long-form technical guidelines outlining core architectural blueprints and implementation mechanics.
Module 8: Monoliths to Domain-Driven Microservices
PREREQUISITE STATEMENT: Read this module after completing Module 7 (CAP & PACELC). Once you understand the physical and consistency limits of distributed networks, you must learn how to design software boundaries that align with these limits, preventing tight integration that turns a distributed system into a fragile "distributed monolith."
1. Monoliths, Modular Monoliths, and Microservices
One of the most critical decisions a systems architect makes is selecting the deployment and execution boundaries of the application codebase. A misaligned boundary architecture leads to slow release velocity, organizational friction, and fragile system execution.
[ Monolith ] [ Modular Monolith ] [ Microservices ]
+----------------------+ +----------------------+ +---------+ +---------+
| App Engine Process | | App Engine Process | | Service | | Service |
| (Shared Database) | | (Schema Isolation) | | A DB | | B DB |
+----------------------+ +----------------------+ +---------+ +---------+
A. The Monolith
A monolithic architecture packages all business capabilities, routing logic, database access modules, and background tasks into a single executable binary or runtime process:
- Pros: Easy to deploy (one artifact), low communication latency (in-memory method calls instead of network hops), simplified debugging, and straightforward transactional guarantees (single-database ACID).
- Cons: Single point of failure (a memory leak in one module crashes the entire process), long build and deployment times, scaling bottlenecks (you must scale the whole process even if only one module is under load), and high organizational coupling (hundreds of engineers committing to a single repository, leading to merge conflicts and release blockages).
B. The Modular Monolith
A modular monolith maintains a single deployable process, but enforces strict, code-level logical separation between modules using language-level access modifiers (e.g., package-private visibility in Java, internal modifiers in C#, or decoupled modules in Go):
- Mechanics: Modules can only communicate with each other through defined public interfaces. Directly referencing another module's internal classes or querying another module's database tables is strictly prohibited. Database schemas are logically separated (e.g. by schema namespace prefixes), and cross-module transactions are avoided.
- Pros: Combines the simple deployment and low latency of a monolith with the organizational clean boundaries and clean interfaces of microservices.
- Cons: Requires constant developer discipline and architectural linting (e.g. using ArchUnit) to prevent boundary leakages. If modularity fails, the codebase decays back into a "big ball of mud."
C. Microservices
Microservices partition business capabilities into physically separate, independently deployable processes that communicate over the network (e.g., via HTTP/REST, gRPC, or messaging queues):
- The Golden Rule: Database-per-Service. A microservice must own its data store. No external service is allowed to read or write directly to its database. All data access must go through the service's public API.
- Pros: Independent deployment cycles (deploy a fix to Service A without touching Service B), technology stack flexibility (Service A can use Go/Postgres while Service B uses Node.js/DynamoDB), and independent scaling.
- Cons: High operational complexity (monitoring, tracing, service mesh routing), latency amplification (network hops replace in-memory calls), distributed data consistency issues (Saga patterns replace local database transactions), and testing friction.
The "Distributed Monolith" Anti-Pattern
A distributed monolith occurs when a team splits a monolithic application into separate network services but retains the tight dependencies of the monolith:
- Symptom A: Services share a single centralized database, reading and writing to the same tables directly. A change to the database schema in one service breaks others.
- Symptom B: Services communicate via synchronous RPC chains (Service A calls B, which calls C, which calls D). If Service D experiences a latency spike, the entire chain stalls, propagating the failure up the stack.
- Result: You inherit all the operational complexity and network latency of microservices, with none of the independent deployment or scaling benefits of decoupled architectures.
2. Strategic Domain-Driven Design (DDD)
To prevent the distributed monolith anti-pattern, you must decompose your system using Domain-Driven Design (DDD). DDD provides the strategic tools to map application boundaries to actual business capabilities rather than technical convenience.
[ Strategic Domain-Driven Design ]
|
+---------------------------------+---------------------------------+
| | |
[ Core Domain ] [ Supporting Domain ] [ Generic Domain ]
- Direct differentiator - Necessary but standard - Standard industry problem
- Allocate top engineers - Custom built - Outsource or SaaS
- Example: Pricing Engine - Example: Inventory Catalog - Example: Identity/Auth
A. Ubiquitous Language
A core pillar of DDD is establishing a Ubiquitous Language — a shared, unambiguous terminology co-created and agreed upon by software developers, product managers, and domain experts.
- This language must be used consistently in conversations, product requirements, database designs, and source code variable names.
- Anti-pattern: Developers using technical jargon (e.g.,
UserRow,PaymentTransactionPayload) while business stakeholders use domain words (e.g.,Customer,Settlement). The translation layer between business and code inevitably introduces bugs.
B. Subdomains
A subdomain is a partition of the business's overall domain. DDD classifies subdomains into three tiers:
- Core Domain: The primary competitive advantage of the organization. This is what the company does better than anyone else, and it must be custom-developed internally. (e.g., Netflix's recommendation algorithm; Stripe's fraud-detection engine).
- Supporting Domain: Operations that are necessary for the business to function but do not act as a market differentiator. These require custom builds but receive less engineering priority. (e.g., an inventory replenishment system for an e-commerce platform).
- Generic Domain: Standard software needs that are identical across industries. These should be solved using off-the-shelf software or SaaS integrations. (e.g., identity access management, billing, email notifications).
C. Bounded Contexts
A Bounded Context is the boundary within which a specific domain model applies and the Ubiquitous Language is valid. Inside a bounded context, terms have a single, precise meaning. In a large enterprise, a single word can mean completely different things to different departments:
[ The Multi-Meaning Domain Object "Product" ]
|
+----------------------+----------------------+
| |
[ Catalog Context ] [ Shipping Context ]
- Price - Weight
- Marketing Description - Dimensions
- Images - Box Type
By defining separate Bounded Contexts (CatalogContext and ShippingContext), we allow each team to build an optimized domain model for their specific tasks, avoiding a single, bloated "Enterprise Product Class" containing 200 unrelated properties.
3. Context Mapping & Relationship Patterns
Because Bounded Contexts must exchange data, you must explicitly define their interactions using Context Mapping. The relationship patterns below establish both code integration structures and team coordination models:
graph LR
subgraph Upstream [Upstream Bounded Context]
OHS[Open Host Service]
PL[Published Language]
end
subgraph Downstream [Downstream Bounded Context]
ACL[Anti-Corruption Layer]
Domain[Domain Model]
end
OHS -->|JSON Payload via PL| ACL
ACL -->|Translates into Native Domain Objects| Domain
style Upstream fill:#1e293b,stroke:#3b82f6,stroke-width:2px
style Downstream fill:#0f172a,stroke:#10b981,stroke-width:2px
style ACL fill:#374151,stroke:#f59e0b,stroke-width:2px
Strategic Relationship Catalog
- Shared Kernel: Two contexts share a subset of the domain model and database tables. Changes to the shared kernel require synchronous coordination between both teams.
- Risk: High coupling. Use sparingly for closely tied core teams.
- Customer-Supplier: Upstream (Supplier) team delivers data to Downstream (Customer) team. The Supplier must accommodate the Customer's requirements in its release planning.
- Conformist: Downstream context conforms strictly to the schema and domain model of the Upstream context, accepting upstream changes without translation.
- Anti-Corruption Layer (ACL): Downstream context implements a translation layer (Adapter/Facade pattern) that converts incoming upstream payloads into native downstream domain entities.
- Benefit: Shields the downstream context from messy legacy or external schemas.
- Open Host Service (OHS) / Published Language (PL): Upstream context exposes a standardized, stable API (e.g. Protobuf/gRPC or REST/JSON) with explicit version management for arbitrary downstream consumers.
- Separate Ways: Two contexts declare zero technical integration, choosing to duplicate capabilities or use manual offline reconciliations to eliminate technical coupling completely.
4. Tactical DDD Primitives
While Strategic DDD defines application boundaries, Tactical DDD provides building blocks inside a Bounded Context:
[ Tactical DDD Building Blocks ]
|
+-------------------+---------------+---------------+-------------------+
| | | | |
[ Value Objects ] [ Entities ] [ Aggregates ] [ Repositories ] [ Domain Events ]
- Immutable - Identity - Consistency - Persistence - State Changes
- Measured/Described - Mutability - Transaction - Decoupled DB - Asynchronous
Tactical Taxonomy
- Value Object: An immutable object defined entirely by its attribute values rather than a unique identity. (e.g.,
Money(Amount: 100, Currency: "USD"),Address). Two value objects with identical attributes are equal. - Entity: An object defined by a unique identity that persists across state mutations throughout its lifecycle. (e.g.,
User(Id: 42),Order(OrderNumber: "ORD-991")). - Aggregate & Aggregate Root: An Aggregate is a cluster of associated Entities and Value Objects that are treated as a single unit for data changes. The Aggregate Root is the sole entry point into the aggregate. External objects cannot modify internal entities directly; all mutations must flow through public methods on the Aggregate Root to enforce business invariants.
- Repository: Encapsulates persistence logic, presenting an in-memory collection interface to retrieve and persist complete Aggregate Roots.
5. C# Production Tactical DDD Implementation
Below is a production C# implementation demonstrating Tactical DDD patterns, including an Aggregate Root, Value Objects, Invariants Enforcement, and Domain Event Dispatching.
using System;
using System.Collections.Generic;
using System.Linq;
namespace MacroPatternsConsortium.DDD
{
// 1. Value Object Base Class
public abstract class ValueObject
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object obj)
{
if (obj == null || obj.GetType() != GetType()) return false;
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x != null ? x.GetHashCode() : 0)
.Aggregate((x, y) => x ^ y);
}
}
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative.", nameof(amount));
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}
// 2. Domain Event Contract
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public record OrderPlacedEvent(Guid OrderId, string CustomerId, decimal TotalAmount) : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
// 3. Aggregate Root Base Class
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = new();
public Guid Id { get; protected set; }
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// 4. Order Aggregate Root
public class OrderLine : ValueObject
{
public string ProductId { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(string productId, int quantity, Money unitPrice)
{
ProductId = productId ?? throw new ArgumentNullException(nameof(productId));
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
Quantity = quantity;
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
}
public Money LineTotal => new Money(UnitPrice.Amount * Quantity, UnitPrice.Currency);
protected override IEnumerable<object> GetEqualityComponents()
{
yield return ProductId;
yield return Quantity;
yield return UnitPrice;
}
}
public enum OrderStatus { Draft, Placed, Cancelled }
public class Order : AggregateRoot
{
private readonly List<OrderLine> _lines = new();
public string CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // For ORM
public Order(Guid orderId, string customerId)
{
Id = orderId;
CustomerId = customerId ?? throw new ArgumentNullException(nameof(customerId));
Status = OrderStatus.Draft;
}
public void AddItem(string productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a non-draft order.");
var existing = _lines.FirstOrDefault(x => x.ProductId == productId);
if (existing != null)
{
_lines.Remove(existing);
_lines.Add(new OrderLine(productId, existing.Quantity + quantity, unitPrice));
}
else
{
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
}
public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is already placed or cancelled.");
if (!_lines.Any())
throw new InvalidOperationException("Cannot place an empty order.");
Status = OrderStatus.Placed;
var totalAmount = _lines.Sum(x => x.LineTotal.Amount);
string currency = _lines.First().UnitPrice.Currency;
AddDomainEvent(new OrderPlacedEvent(Id, CustomerId, totalAmount));
}
}
}
6. Event Storming Methodology
Event Storming is a rapid, highly collaborative workshop method invented by Alberto Brandolini for discovering complex business domains, mapping Bounded Contexts, and identifying aggregate boundaries.
[ Command ] ---> ( Target Aggregate ) ---> [ Domain Event (Orange) ] ---> [ Read Model (Green) ]
(Blue Sticky) (Yellow Sticky) (Green Sticky)
The 5 Workshop Color Codes
| Sticky Note Color | DDD Artifact | Description | Example |
|---|---|---|---|
| 🟠 Orange | Domain Event | Something significant that happened in the past (written in past tense). | OrderPlaced, PaymentFailed |
| 🔵 Blue | Command | An action triggered by a user or external system. | PlaceOrder, SubmitPayment |
| 🟡 Yellow | Aggregate | Business component that processes commands and enforces invariants. | Order Aggregate, Account |
| 🟢 Green | Read Model | View/data layout required by the user to make a decision to execute a command. | Customer Cart Summary View |
| 🟣 Purple | Policy / Rule | Reactive logic triggered by a Domain Event ("Whenever X, do Y"). | Whenever Payment Fails, Send SMS |
Step-by-Step Workshop Workflow
- Unconstrained Event Brainstorming: Participants write every domain event they can think of on orange sticky notes and post them on a long wall.
- Timeline Sequencing: Arrange events in chronological order from left to right, eliminating duplicates and clarifying terminology.
- Identify Triggers (Commands): Place blue command stickies to the left of the events that they trigger.
- Group into Aggregates: Group related commands and events around yellow Aggregate boundaries.
- Identify Bounded Contexts: Draw boundary lines around clusters of aggregates that share a cohesive domain purpose, defining your microservice candidates.
7. Decoupling Case Study: E-Commerce Monolith to Microservices
Legacy Monolith State
A global retailer runs on a 15-year-old monolithic SQL database containing 450 tables. A single Orders table contains 85 columns read and modified directly by Marketing, Shipping, Billing, and Inventory modules. A deployment by the Shipping team frequently breaks Billing calculations.
graph TD
subgraph Decomposed Microservices Architecture
OrderSvc[Order Microservice] -->|Publish OrderPlaced| Kafka[Kafka Event Log]
Kafka -->|Consume OrderPlaced| InventorySvc[Inventory Service]
Kafka -->|Consume OrderPlaced| ShippingSvc[Fulfillment Service]
Kafka -->|Consume OrderPlaced| AnalyticsSvc[Analytics Service]
OrderSvc --> OrderDB[(Order DB)]
InventorySvc --> InvDB[(Inventory DB)]
ShippingSvc --> ShipDB[(Shipping DB)]
end
style OrderSvc fill:#1e293b,stroke:#3b82f6
style InventorySvc fill:#1e293b,stroke:#10b981
style ShippingSvc fill:#1e293b,stroke:#f59e0b
style Kafka fill:#312e81,stroke:#6366f1
Migration Execution Plan
- Apply Strangler Fig Pattern: Introduce an API Gateway to route incoming checkout endpoints away from the monolith to a new
OrderingBounded Context. - Establish Anti-Corruption Layer: Build an ACL in the new
Orderingservice to translate calls to legacy legacy ERP tables. - Isolate Database Schemas: Move the
Orderstable out of the shared schema into a dedicated schema (orders_db). - Publish Asynchronous Domain Events: Instead of synchronous triggers, broadcast
OrderPlacedevents to Apache Kafka, allowing Inventory and Fulfillment services to react independently.
8. Hands-on Architecture Challenge
Scenario Description
An enterprise banking system maintains a monolithic Accounts entity containing Customer Demographics, Credit Risk Ratings, Checking Balances, and Loan Applications. All teams commit to a single C# namespace and shared database.
Your Goal:
- Decompose this domain into three distinct Bounded Contexts:
Customer Management ContextChecking & Ledger ContextLoan Origination Context
- Define the Bounded Context Map:
- Model
Customer Managementexposing an Open Host Service (OHS) API. - Model
Loan OriginationconsumingCustomer Managementvia an Anti-Corruption Layer (ACL).
- Model
- Model an Event-Driven Integration:
- When an order or transaction is processed in
Checking & Ledger, publish an asynchronousAccountOverdrawnEventto an Event Bus consumed byCustomer Management.
- When an order or transaction is processed in
9. Practice Challenge Template
Use this template in your sandbox to model the context boundary map:
graph TD
subgraph Customer_Context [Customer Management Context]
CustomerOHS[Customer Open Host Service]
CustomerAggregate[Customer Aggregate]
end
subgraph Ledger_Context [Checking & Ledger Context]
LedgerAggregate[Account Ledger Aggregate] -->|Publish AccountOverdrawn| EventBus((Enterprise Event Bus))
end
subgraph Loan_Context [Loan Origination Context]
LoanACL[Anti-Corruption Layer] --> LoanAggregate[Loan Application Aggregate]
end
EventBus -->|Consume Overdrawn Event| CustomerAggregate
CustomerOHS -->|REST JSON Payload| LoanACL
style Customer_Context fill:#0f172a,stroke:#38bdf8,stroke-width:2px
style Ledger_Context fill:#0f172a,stroke:#34d399,stroke-width:2px
style Loan_Context fill:#0f172a,stroke:#fbbf24,stroke-width:2px
style EventBus fill:#312e81,stroke:#818cf8,stroke-width:2px
NEXT MODULE BRIDGE: Defining clear Bounded Contexts isolates domain models, but your microservices must still communicate. Proceed to Module 9: Event-Driven Architectures (EDA) to learn how to connect bounded contexts asynchronously using Kafka, Event Logs, and the Transactional Outbox Pattern.