Fix Blossom CORS headers and add root-level upload routes (v0.36.12)
Some checks failed
Go / build-and-release (push) Has been cancelled
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add proper CORS headers for Blossom endpoints including X-SHA-256, X-Content-Length, X-Content-Type headers required by blossom-client-sdk - Add root-level Blossom routes (/upload, /media, /mirror, /report, /list/) for clients like Jumble that expect Blossom at root - Export BaseURLKey from pkg/blossom for use by app handlers - Make blossomRootHandler return URLs with /blossom prefix so blob downloads work via the registered /blossom/ route - Remove Access-Control-Allow-Credentials header (not needed for * origin) - Add Access-Control-Expose-Headers for X-Reason and other response headers Files modified: - app/blossom.go: Add blossomRootHandler, use exported BaseURLKey - app/server.go: Add CORS handling for blossom paths, register root routes - pkg/blossom/server.go: Fix CORS headers, export BaseURLKey - pkg/blossom/utils.go: Minor formatting - pkg/version/version: Bump to v0.36.12 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
166
.claude/skills/domain-driven-design/SKILL.md
Normal file
166
.claude/skills/domain-driven-design/SKILL.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: domain-driven-design
|
||||
description: This skill should be used when designing software architecture, modeling domains, reviewing code for DDD compliance, identifying bounded contexts, designing aggregates, or discussing strategic and tactical DDD patterns. Provides comprehensive Domain-Driven Design principles, axioms, heuristics, and anti-patterns for building maintainable, domain-centric software systems.
|
||||
---
|
||||
|
||||
# Domain-Driven Design
|
||||
|
||||
## Overview
|
||||
|
||||
Domain-Driven Design (DDD) is an approach to software development that centers the design on the core business domain. This skill provides principles, patterns, and heuristics for both strategic design (system boundaries and relationships) and tactical design (code-level patterns).
|
||||
|
||||
## When to Apply This Skill
|
||||
|
||||
- Designing new systems or features with complex business logic
|
||||
- Identifying and defining bounded contexts
|
||||
- Modeling aggregates, entities, and value objects
|
||||
- Reviewing code for DDD pattern compliance
|
||||
- Decomposing monoliths into services
|
||||
- Establishing ubiquitous language with domain experts
|
||||
|
||||
## Core Axioms
|
||||
|
||||
### Axiom 1: The Domain is Supreme
|
||||
|
||||
Software exists to solve domain problems. Technical decisions serve the domain, not vice versa. When technical elegance conflicts with domain clarity, domain clarity wins.
|
||||
|
||||
### Axiom 2: Language Creates Reality
|
||||
|
||||
The ubiquitous language shapes how teams think about the domain. Ambiguous language creates ambiguous software. Invest heavily in precise terminology.
|
||||
|
||||
### Axiom 3: Boundaries Enable Autonomy
|
||||
|
||||
Explicit boundaries (bounded contexts) allow teams to evolve independently. The cost of integration is worth the benefit of isolation.
|
||||
|
||||
### Axiom 4: Models are Imperfect Approximations
|
||||
|
||||
No model captures all domain complexity. Accept that models simplify reality. Refine models continuously as understanding deepens.
|
||||
|
||||
## Strategic Design Quick Reference
|
||||
|
||||
| Pattern | Purpose | Key Heuristic |
|
||||
|---------|---------|---------------|
|
||||
| **Bounded Context** | Define linguistic/model boundaries | One team, one language, one model |
|
||||
| **Context Map** | Document context relationships | Make implicit integrations explicit |
|
||||
| **Subdomain** | Classify domain areas by value | Core (invest), Supporting (adequate), Generic (outsource) |
|
||||
| **Ubiquitous Language** | Shared vocabulary | If experts don't use the term, neither should code |
|
||||
|
||||
For detailed strategic patterns, consult `references/strategic-patterns.md`.
|
||||
|
||||
## Tactical Design Quick Reference
|
||||
|
||||
| Pattern | Purpose | Key Heuristic |
|
||||
|---------|---------|---------------|
|
||||
| **Entity** | Identity-tracked object | "Same identity = same thing" regardless of attributes |
|
||||
| **Value Object** | Immutable, identity-less | Equality by value, always immutable, self-validating |
|
||||
| **Aggregate** | Consistency boundary | Small aggregates, reference by ID, one transaction = one aggregate |
|
||||
| **Domain Event** | Record state changes | Past tense naming, immutable, contains all relevant data |
|
||||
| **Repository** | Collection abstraction | One per aggregate root, domain-focused interface |
|
||||
| **Domain Service** | Stateless operations | When logic doesn't belong to any single entity |
|
||||
| **Factory** | Complex object creation | When construction logic is complex or variable |
|
||||
|
||||
For detailed tactical patterns, consult `references/tactical-patterns.md`.
|
||||
|
||||
## Essential Heuristics
|
||||
|
||||
### Aggregate Design Heuristics
|
||||
|
||||
1. **Protect business invariants inside aggregate boundaries** - If two pieces of data must be consistent, they belong in the same aggregate
|
||||
2. **Design small aggregates** - Large aggregates cause concurrency issues and slow performance
|
||||
3. **Reference other aggregates by identity only** - Never hold direct object references across aggregate boundaries
|
||||
4. **Update one aggregate per transaction** - Eventual consistency across aggregates using domain events
|
||||
5. **Aggregate roots are the only entry point** - External code never reaches inside to manipulate child entities
|
||||
|
||||
### Bounded Context Heuristics
|
||||
|
||||
1. **Linguistic boundaries** - When the same word means different things, you have different contexts
|
||||
2. **Team boundaries** - One context per team enables autonomy
|
||||
3. **Process boundaries** - Different business processes often indicate different contexts
|
||||
4. **Data ownership** - Each context owns its data; no shared databases
|
||||
|
||||
### Modeling Heuristics
|
||||
|
||||
1. **Nouns → Entities or Value Objects** - Things with identity become entities; descriptive things become value objects
|
||||
2. **Verbs → Domain Services or Methods** - Actions become methods on entities or stateless services
|
||||
3. **Business rules → Invariants** - Rules the domain must always satisfy become aggregate invariants
|
||||
4. **Events in domain expert language → Domain Events** - "When X happens" becomes a domain event
|
||||
|
||||
## Decision Guides
|
||||
|
||||
### Entity vs Value Object
|
||||
|
||||
```
|
||||
Does this thing have a lifecycle and identity that matters?
|
||||
├─ YES → Is identity based on an ID (not attributes)?
|
||||
│ ├─ YES → Entity
|
||||
│ └─ NO → Reconsider; might be Value Object with natural key
|
||||
└─ NO → Value Object
|
||||
```
|
||||
|
||||
### Where Does This Logic Belong?
|
||||
|
||||
```
|
||||
Is this logic stateless?
|
||||
├─ NO → Does it belong to a single aggregate?
|
||||
│ ├─ YES → Method on the aggregate/entity
|
||||
│ └─ NO → Reconsider aggregate boundaries
|
||||
└─ YES → Does it coordinate multiple aggregates?
|
||||
├─ YES → Application Service
|
||||
└─ NO → Does it represent a domain concept?
|
||||
├─ YES → Domain Service
|
||||
└─ NO → Infrastructure Service
|
||||
```
|
||||
|
||||
### Should This Be a Separate Bounded Context?
|
||||
|
||||
```
|
||||
Do different stakeholders use different language for this?
|
||||
├─ YES → Separate bounded context
|
||||
└─ NO → Does a different team own this?
|
||||
├─ YES → Separate bounded context
|
||||
└─ NO → Would a separate model reduce complexity?
|
||||
├─ YES → Consider separation (but weigh integration cost)
|
||||
└─ NO → Keep in current context
|
||||
```
|
||||
|
||||
## Anti-Patterns Overview
|
||||
|
||||
| Anti-Pattern | Description | Fix |
|
||||
|--------------|-------------|-----|
|
||||
| **Anemic Domain Model** | Entities with only getters/setters | Move behavior into domain objects |
|
||||
| **Big Ball of Mud** | No clear boundaries | Identify bounded contexts |
|
||||
| **Smart UI** | Business logic in presentation layer | Extract domain layer |
|
||||
| **Database-Driven Design** | Model follows database schema | Model follows domain, map to database |
|
||||
| **Leaky Abstractions** | Infrastructure concerns in domain | Dependency inversion, ports and adapters |
|
||||
| **God Aggregate** | One aggregate does everything | Split by invariant boundaries |
|
||||
| **Premature Abstraction** | Abstracting before understanding | Concrete first, abstract when patterns emerge |
|
||||
|
||||
For detailed anti-patterns and remediation, consult `references/anti-patterns.md`.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When implementing DDD in a codebase:
|
||||
|
||||
- [ ] Ubiquitous language documented and used consistently in code
|
||||
- [ ] Bounded contexts identified with clear boundaries
|
||||
- [ ] Context map documenting integration patterns
|
||||
- [ ] Aggregates designed small with clear invariants
|
||||
- [ ] Entities have behavior, not just data
|
||||
- [ ] Value objects are immutable and self-validating
|
||||
- [ ] Domain events capture important state changes
|
||||
- [ ] Repositories abstract persistence for aggregate roots
|
||||
- [ ] No business logic in application services (orchestration only)
|
||||
- [ ] No infrastructure concerns in domain layer
|
||||
|
||||
## Resources
|
||||
|
||||
### references/
|
||||
|
||||
- `strategic-patterns.md` - Detailed strategic DDD patterns including bounded contexts, context maps, subdomain classification, and ubiquitous language
|
||||
- `tactical-patterns.md` - Detailed tactical DDD patterns including entities, value objects, aggregates, domain events, repositories, and services
|
||||
- `anti-patterns.md` - Common DDD anti-patterns, how to identify them, and remediation strategies
|
||||
|
||||
To search references for specific topics:
|
||||
- Bounded contexts: `grep -i "bounded context" references/`
|
||||
- Aggregate design: `grep -i "aggregate" references/`
|
||||
- Value objects: `grep -i "value object" references/`
|
||||
853
.claude/skills/domain-driven-design/references/anti-patterns.md
Normal file
853
.claude/skills/domain-driven-design/references/anti-patterns.md
Normal file
@@ -0,0 +1,853 @@
|
||||
# DDD Anti-Patterns
|
||||
|
||||
This reference documents common anti-patterns encountered when implementing Domain-Driven Design, how to identify them, and remediation strategies.
|
||||
|
||||
## Anemic Domain Model
|
||||
|
||||
### Description
|
||||
|
||||
Entities that are mere data containers with getters and setters, while all business logic lives in "service" classes. The domain model looks like a relational database schema mapped to objects.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Entities with only get/set methods and no behavior
|
||||
- Service classes with methods like `orderService.calculateTotal(order)`
|
||||
- Business rules scattered across multiple services
|
||||
- Heavy use of DTOs that mirror entity structure
|
||||
- "Transaction scripts" in application services
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Anemic domain model
|
||||
class Order {
|
||||
id: string;
|
||||
customerId: string;
|
||||
items: OrderItem[];
|
||||
status: string;
|
||||
total: number;
|
||||
|
||||
// Only data access, no behavior
|
||||
getId(): string { return this.id; }
|
||||
setStatus(status: string): void { this.status = status; }
|
||||
getItems(): OrderItem[] { return this.items; }
|
||||
setTotal(total: number): void { this.total = total; }
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
// All logic external to the entity
|
||||
calculateTotal(order: Order): number {
|
||||
let total = 0;
|
||||
for (const item of order.getItems()) {
|
||||
total += item.price * item.quantity;
|
||||
}
|
||||
order.setTotal(total);
|
||||
return total;
|
||||
}
|
||||
|
||||
canShip(order: Order): boolean {
|
||||
return order.status === 'PAID' && order.getItems().length > 0;
|
||||
}
|
||||
|
||||
ship(order: Order, trackingNumber: string): void {
|
||||
if (!this.canShip(order)) {
|
||||
throw new Error('Cannot ship order');
|
||||
}
|
||||
order.setStatus('SHIPPED');
|
||||
order.trackingNumber = trackingNumber;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Rich domain model
|
||||
class Order {
|
||||
private _id: OrderId;
|
||||
private _items: OrderItem[];
|
||||
private _status: OrderStatus;
|
||||
|
||||
// Behavior lives in the entity
|
||||
get total(): Money {
|
||||
return this._items.reduce(
|
||||
(sum, item) => sum.add(item.subtotal()),
|
||||
Money.zero()
|
||||
);
|
||||
}
|
||||
|
||||
canShip(): boolean {
|
||||
return this._status === OrderStatus.Paid && this._items.length > 0;
|
||||
}
|
||||
|
||||
ship(trackingNumber: TrackingNumber): void {
|
||||
if (!this.canShip()) {
|
||||
throw new OrderNotShippableError(this._id, this._status);
|
||||
}
|
||||
this._status = OrderStatus.Shipped;
|
||||
this._trackingNumber = trackingNumber;
|
||||
}
|
||||
|
||||
addItem(item: OrderItem): void {
|
||||
this.ensureCanModify();
|
||||
this._items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Application service is thin - only orchestration
|
||||
class OrderApplicationService {
|
||||
async shipOrder(orderId: OrderId, trackingNumber: TrackingNumber): Promise<void> {
|
||||
const order = await this.orderRepository.findById(orderId);
|
||||
order.ship(trackingNumber); // Domain logic in entity
|
||||
await this.orderRepository.save(order);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Root Causes
|
||||
|
||||
- Developers treating objects as data structures
|
||||
- Thinking in terms of database tables
|
||||
- Copying patterns from CRUD applications
|
||||
- Misunderstanding "service" to mean "all logic goes here"
|
||||
|
||||
## God Aggregate
|
||||
|
||||
### Description
|
||||
|
||||
An aggregate that has grown to encompass too much. It handles multiple concerns, has many child entities, and becomes a performance and concurrency bottleneck.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Aggregates with 10+ child entity types
|
||||
- Long load times due to eager loading everything
|
||||
- Frequent optimistic concurrency conflicts
|
||||
- Methods that only touch a small subset of the aggregate
|
||||
- Difficulty reasoning about invariants
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: God aggregate
|
||||
class Customer {
|
||||
private _id: CustomerId;
|
||||
private _profile: CustomerProfile;
|
||||
private _addresses: Address[];
|
||||
private _paymentMethods: PaymentMethod[];
|
||||
private _orders: Order[]; // History of all orders!
|
||||
private _wishlist: WishlistItem[];
|
||||
private _reviews: Review[];
|
||||
private _loyaltyPoints: LoyaltyAccount;
|
||||
private _preferences: Preferences;
|
||||
private _notifications: Notification[];
|
||||
private _supportTickets: SupportTicket[];
|
||||
|
||||
// Loading this customer loads EVERYTHING
|
||||
// Updating preferences causes concurrency conflict with order placement
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Small, focused aggregates
|
||||
class Customer {
|
||||
private _id: CustomerId;
|
||||
private _profile: CustomerProfile;
|
||||
private _defaultAddressId: AddressId;
|
||||
private _membershipTier: MembershipTier;
|
||||
}
|
||||
|
||||
class CustomerAddressBook {
|
||||
private _customerId: CustomerId;
|
||||
private _addresses: Address[];
|
||||
}
|
||||
|
||||
class ShoppingCart {
|
||||
private _customerId: CustomerId; // Reference by ID
|
||||
private _items: CartItem[];
|
||||
}
|
||||
|
||||
class Wishlist {
|
||||
private _customerId: CustomerId; // Reference by ID
|
||||
private _items: WishlistItem[];
|
||||
}
|
||||
|
||||
class LoyaltyAccount {
|
||||
private _customerId: CustomerId; // Reference by ID
|
||||
private _points: Points;
|
||||
private _transactions: LoyaltyTransaction[];
|
||||
}
|
||||
```
|
||||
|
||||
### Identification Heuristic
|
||||
|
||||
Ask: "Do all these things need to be immediately consistent?" If the answer is no, they probably belong in separate aggregates.
|
||||
|
||||
## Aggregate Reference Violation
|
||||
|
||||
### Description
|
||||
|
||||
Aggregates holding direct object references to other aggregates instead of referencing by identity. Creates implicit coupling and makes it impossible to reason about transactional boundaries.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Navigation from one aggregate to another: `order.customer.address`
|
||||
- Loading an aggregate brings in connected aggregates
|
||||
- Unclear what gets saved when calling `save()`
|
||||
- Difficulty implementing eventual consistency
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Direct reference
|
||||
class Order {
|
||||
private customer: Customer; // Direct reference!
|
||||
private shippingAddress: Address;
|
||||
|
||||
getCustomerEmail(): string {
|
||||
return this.customer.email; // Navigating through!
|
||||
}
|
||||
|
||||
validate(): void {
|
||||
// Touching another aggregate's data
|
||||
if (this.customer.creditLimit < this.total) {
|
||||
throw new Error('Credit limit exceeded');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Reference by identity
|
||||
class Order {
|
||||
private _customerId: CustomerId; // ID only!
|
||||
private _shippingAddress: Address; // Value object copied at order time
|
||||
|
||||
// If customer data is needed, it must be explicitly loaded
|
||||
static create(
|
||||
customerId: CustomerId,
|
||||
shippingAddress: Address,
|
||||
creditLimit: Money // Passed in, not navigated to
|
||||
): Order {
|
||||
return new Order(customerId, shippingAddress, creditLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// Application service coordinates loading if needed
|
||||
class OrderApplicationService {
|
||||
async getOrderWithCustomerDetails(orderId: OrderId): Promise<OrderDetails> {
|
||||
const order = await this.orderRepository.findById(orderId);
|
||||
const customer = await this.customerRepository.findById(order.customerId);
|
||||
|
||||
return new OrderDetails(order, customer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Smart UI
|
||||
|
||||
### Description
|
||||
|
||||
Business logic embedded directly in the user interface layer. Controllers, presenters, or UI components contain domain rules.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Validation logic in form handlers
|
||||
- Business calculations in controllers
|
||||
- State machines in UI components
|
||||
- Domain rules duplicated across different UI views
|
||||
- "If we change the UI framework, we lose the business logic"
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Smart UI
|
||||
class OrderController {
|
||||
submitOrder(request: Request): Response {
|
||||
const cart = request.body;
|
||||
|
||||
// Business logic in controller!
|
||||
let total = 0;
|
||||
for (const item of cart.items) {
|
||||
total += item.price * item.quantity;
|
||||
}
|
||||
|
||||
// Discount rules in controller!
|
||||
if (cart.items.length > 10) {
|
||||
total *= 0.9; // 10% bulk discount
|
||||
}
|
||||
|
||||
if (total > 1000 && !this.hasValidPaymentMethod(cart.customerId)) {
|
||||
return Response.error('Orders over $1000 require verified payment');
|
||||
}
|
||||
|
||||
// More business rules...
|
||||
const order = {
|
||||
customerId: cart.customerId,
|
||||
items: cart.items,
|
||||
total: total,
|
||||
status: 'PENDING'
|
||||
};
|
||||
|
||||
this.database.insert('orders', order);
|
||||
return Response.ok(order);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: UI delegates to domain
|
||||
class OrderController {
|
||||
submitOrder(request: Request): Response {
|
||||
const command = new PlaceOrderCommand(
|
||||
request.body.customerId,
|
||||
request.body.items
|
||||
);
|
||||
|
||||
try {
|
||||
const orderId = this.orderApplicationService.placeOrder(command);
|
||||
return Response.ok({ orderId });
|
||||
} catch (error) {
|
||||
if (error instanceof DomainError) {
|
||||
return Response.badRequest(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Domain logic in domain layer
|
||||
class Order {
|
||||
private calculateTotal(): Money {
|
||||
const subtotal = this._items.reduce(
|
||||
(sum, item) => sum.add(item.subtotal()),
|
||||
Money.zero()
|
||||
);
|
||||
return this._discountPolicy.apply(subtotal, this._items.length);
|
||||
}
|
||||
}
|
||||
|
||||
class BulkDiscountPolicy implements DiscountPolicy {
|
||||
apply(subtotal: Money, itemCount: number): Money {
|
||||
if (itemCount > 10) {
|
||||
return subtotal.multiply(0.9);
|
||||
}
|
||||
return subtotal;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database-Driven Design
|
||||
|
||||
### Description
|
||||
|
||||
The domain model is derived from the database schema rather than from domain concepts. Tables become classes; foreign keys become object references; database constraints become business rules.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Class names match table names exactly
|
||||
- Foreign key relationships drive object graph
|
||||
- ID fields everywhere, even where identity doesn't matter
|
||||
- `nullable` database columns drive optional properties
|
||||
- Domain model changes require database migration first
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Database-driven model
|
||||
// Mirrors database schema exactly
|
||||
class orders {
|
||||
order_id: number;
|
||||
customer_id: number;
|
||||
order_date: Date;
|
||||
status_cd: string;
|
||||
shipping_address_id: number;
|
||||
billing_address_id: number;
|
||||
total_amt: number;
|
||||
tax_amt: number;
|
||||
created_ts: Date;
|
||||
updated_ts: Date;
|
||||
}
|
||||
|
||||
class order_items {
|
||||
order_item_id: number;
|
||||
order_id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
discount_pct: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Domain-driven model
|
||||
class Order {
|
||||
private readonly _id: OrderId;
|
||||
private _status: OrderStatus;
|
||||
private _items: OrderItem[];
|
||||
private _shippingAddress: Address; // Value object, not FK
|
||||
private _billingAddress: Address;
|
||||
|
||||
// Domain behavior, not database structure
|
||||
get total(): Money {
|
||||
return this._items.reduce(
|
||||
(sum, item) => sum.add(item.lineTotal()),
|
||||
Money.zero()
|
||||
);
|
||||
}
|
||||
|
||||
ship(trackingNumber: TrackingNumber): void {
|
||||
// Business logic
|
||||
}
|
||||
}
|
||||
|
||||
// Mapping is infrastructure concern
|
||||
class OrderRepository {
|
||||
async save(order: Order): Promise<void> {
|
||||
// Map rich domain object to database tables
|
||||
await this.db.query(
|
||||
'INSERT INTO orders (id, status, shipping_street, shipping_city...) VALUES (...)'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Principle
|
||||
|
||||
The domain model reflects how domain experts think, not how data is stored. Persistence is an infrastructure detail.
|
||||
|
||||
## Leaky Abstractions
|
||||
|
||||
### Description
|
||||
|
||||
Infrastructure concerns bleeding into the domain layer. Domain objects depend on frameworks, databases, or external services.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Domain entities with ORM decorators
|
||||
- Repository interfaces returning database-specific types
|
||||
- Domain services making HTTP calls
|
||||
- Framework annotations on domain objects
|
||||
- `import { Entity } from 'typeorm'` in domain layer
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Infrastructure leaking into domain
|
||||
import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
@Entity('customers') // ORM in domain!
|
||||
export class Customer {
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
@IsNotEmpty() // Validation framework in domain!
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ManyToOne(() => Subscription) // ORM relationship in domain!
|
||||
subscription: Subscription;
|
||||
}
|
||||
|
||||
// Domain service calling external API directly
|
||||
class ShippingCostService {
|
||||
async calculateCost(order: Order): Promise<number> {
|
||||
// HTTP call in domain!
|
||||
const response = await fetch('https://shipping-api.com/rates', {
|
||||
body: JSON.stringify(order)
|
||||
});
|
||||
return response.json().cost;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Clean domain layer
|
||||
// Domain object - no framework dependencies
|
||||
class Customer {
|
||||
private constructor(
|
||||
private readonly _id: CustomerId,
|
||||
private readonly _name: CustomerName,
|
||||
private readonly _email: Email
|
||||
) {}
|
||||
|
||||
static create(name: string, email: string): Customer {
|
||||
return new Customer(
|
||||
CustomerId.generate(),
|
||||
CustomerName.create(name), // Self-validating value object
|
||||
Email.create(email) // Self-validating value object
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Port (interface) defined in domain
|
||||
interface ShippingRateProvider {
|
||||
getRate(destination: Address, weight: Weight): Promise<Money>;
|
||||
}
|
||||
|
||||
// Domain service uses port
|
||||
class ShippingCostCalculator {
|
||||
constructor(private rateProvider: ShippingRateProvider) {}
|
||||
|
||||
async calculate(order: Order): Promise<Money> {
|
||||
return this.rateProvider.getRate(
|
||||
order.shippingAddress,
|
||||
order.totalWeight()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter (infrastructure) implements port
|
||||
class ShippingApiRateProvider implements ShippingRateProvider {
|
||||
async getRate(destination: Address, weight: Weight): Promise<Money> {
|
||||
const response = await fetch('https://shipping-api.com/rates', {
|
||||
body: JSON.stringify({ destination, weight })
|
||||
});
|
||||
const data = await response.json();
|
||||
return Money.of(data.cost, Currency.USD);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Database
|
||||
|
||||
### Description
|
||||
|
||||
Multiple bounded contexts accessing the same database tables. Changes in one context break others. No clear data ownership.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Multiple services querying the same tables
|
||||
- Fear of schema changes because "something else might break"
|
||||
- Unclear which service is authoritative for data
|
||||
- Cross-context joins in queries
|
||||
- Database triggers coordinating contexts
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Shared database
|
||||
// Sales context
|
||||
class SalesOrderService {
|
||||
async getOrder(orderId: string) {
|
||||
return this.db.query(`
|
||||
SELECT o.*, c.name, c.email, p.name as product_name
|
||||
FROM orders o
|
||||
JOIN customers c ON o.customer_id = c.id
|
||||
JOIN products p ON o.product_id = p.id
|
||||
WHERE o.id = ?
|
||||
`, [orderId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Shipping context - same tables!
|
||||
class ShippingService {
|
||||
async getOrdersToShip() {
|
||||
return this.db.query(`
|
||||
SELECT o.*, c.address
|
||||
FROM orders o
|
||||
JOIN customers c ON o.customer_id = c.id
|
||||
WHERE o.status = 'PAID'
|
||||
`);
|
||||
}
|
||||
|
||||
async markShipped(orderId: string) {
|
||||
// Directly modifying shared table
|
||||
await this.db.query(
|
||||
"UPDATE orders SET status = 'SHIPPED' WHERE id = ?",
|
||||
[orderId]
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Each context owns its data
|
||||
// Sales context - owns order creation
|
||||
class SalesOrderRepository {
|
||||
async save(order: SalesOrder): Promise<void> {
|
||||
await this.salesDb.query('INSERT INTO sales_orders...');
|
||||
|
||||
// Publish event for other contexts
|
||||
await this.eventPublisher.publish(
|
||||
new OrderPlaced(order.id, order.customerId, order.items)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Shipping context - owns its projection
|
||||
class ShippingOrderProjection {
|
||||
// Handles events to build local projection
|
||||
async handleOrderPlaced(event: OrderPlaced): Promise<void> {
|
||||
await this.shippingDb.query(`
|
||||
INSERT INTO shipments (order_id, customer_id, status)
|
||||
VALUES (?, ?, 'PENDING')
|
||||
`, [event.orderId, event.customerId]);
|
||||
}
|
||||
}
|
||||
|
||||
class ShipmentRepository {
|
||||
async findPendingShipments(): Promise<Shipment[]> {
|
||||
// Queries only shipping context's data
|
||||
return this.shippingDb.query(
|
||||
"SELECT * FROM shipments WHERE status = 'PENDING'"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Premature Abstraction
|
||||
|
||||
### Description
|
||||
|
||||
Creating abstractions, interfaces, and frameworks before understanding the problem space. Often justified as "flexibility for the future."
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Interfaces with single implementations
|
||||
- Generic frameworks solving hypothetical problems
|
||||
- Heavy use of design patterns without clear benefit
|
||||
- Configuration systems for things that never change
|
||||
- "We might need this someday"
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: Premature abstraction
|
||||
interface IOrderProcessor<TOrder, TResult> {
|
||||
process(order: TOrder): Promise<TResult>;
|
||||
}
|
||||
|
||||
interface IOrderValidator<TOrder> {
|
||||
validate(order: TOrder): ValidationResult;
|
||||
}
|
||||
|
||||
interface IOrderPersister<TOrder> {
|
||||
persist(order: TOrder): Promise<void>;
|
||||
}
|
||||
|
||||
abstract class AbstractOrderProcessor<TOrder, TResult>
|
||||
implements IOrderProcessor<TOrder, TResult> {
|
||||
|
||||
constructor(
|
||||
protected validator: IOrderValidator<TOrder>,
|
||||
protected persister: IOrderPersister<TOrder>,
|
||||
protected notifier: INotificationService,
|
||||
protected logger: ILogger,
|
||||
protected metrics: IMetricsCollector
|
||||
) {}
|
||||
|
||||
async process(order: TOrder): Promise<TResult> {
|
||||
this.logger.log('Processing order');
|
||||
this.metrics.increment('orders.processed');
|
||||
|
||||
const validation = this.validator.validate(order);
|
||||
if (!validation.isValid) {
|
||||
throw new ValidationException(validation.errors);
|
||||
}
|
||||
|
||||
const result = await this.doProcess(order);
|
||||
await this.persister.persist(order);
|
||||
await this.notifier.notify(order);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected abstract doProcess(order: TOrder): Promise<TResult>;
|
||||
}
|
||||
|
||||
// Only one concrete implementation ever created
|
||||
class StandardOrderProcessor extends AbstractOrderProcessor<Order, OrderResult> {
|
||||
protected async doProcess(order: Order): Promise<OrderResult> {
|
||||
// The actual logic is trivial
|
||||
return new OrderResult(order.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Concrete first, abstract when patterns emerge
|
||||
class OrderService {
|
||||
async placeOrder(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(command);
|
||||
|
||||
if (!order.isValid()) {
|
||||
throw new InvalidOrderError(order.validationErrors());
|
||||
}
|
||||
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add abstraction when you have multiple implementations
|
||||
// and understand the variation points
|
||||
```
|
||||
|
||||
### Heuristic
|
||||
|
||||
Wait until you have three similar implementations before abstracting. The right abstraction will be obvious then.
|
||||
|
||||
## Big Ball of Mud
|
||||
|
||||
### Description
|
||||
|
||||
A system without clear architectural boundaries. Everything depends on everything. Changes ripple unpredictably.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- No clear module boundaries
|
||||
- Circular dependencies
|
||||
- Any change might break anything
|
||||
- "Only Bob understands how this works"
|
||||
- Integration tests are the only reliable tests
|
||||
- Fear of refactoring
|
||||
|
||||
### Identification
|
||||
|
||||
```
|
||||
# Circular dependency example
|
||||
OrderService → CustomerService → PaymentService → OrderService
|
||||
```
|
||||
|
||||
### Remediation Strategy
|
||||
|
||||
1. **Identify implicit contexts** - Find clusters of related functionality
|
||||
2. **Define explicit boundaries** - Create modules/packages with clear interfaces
|
||||
3. **Break cycles** - Introduce events or shared kernel for circular dependencies
|
||||
4. **Enforce boundaries** - Use architectural tests, linting rules
|
||||
|
||||
```typescript
|
||||
// Step 1: Identify boundaries
|
||||
// sales/ - order creation, pricing
|
||||
// fulfillment/ - shipping, tracking
|
||||
// customer/ - customer management
|
||||
// shared/ - shared kernel (Money, Address)
|
||||
|
||||
// Step 2: Define public interfaces
|
||||
// sales/index.ts
|
||||
export { OrderService } from './application/OrderService';
|
||||
export { OrderPlaced, OrderCancelled } from './domain/events';
|
||||
// Internal types not exported
|
||||
|
||||
// Step 3: Break cycles with events
|
||||
class OrderService {
|
||||
async placeOrder(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(command);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// Instead of calling PaymentService directly
|
||||
await this.eventPublisher.publish(new OrderPlaced(order));
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentEventHandler {
|
||||
async handleOrderPlaced(event: OrderPlaced): Promise<void> {
|
||||
await this.paymentService.collectPayment(event.orderId, event.total);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CRUD-Driven Development
|
||||
|
||||
### Description
|
||||
|
||||
Treating all domain operations as Create, Read, Update, Delete operations. Loses domain intent and behavior.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Endpoints like `PUT /orders/{id}` that accept any field changes
|
||||
- Service methods like `updateOrder(orderId, updates)`
|
||||
- Domain events named `OrderUpdated` instead of `OrderShipped`
|
||||
- No validation of state transitions
|
||||
- Business operations hidden behind generic updates
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: CRUD-driven
|
||||
class OrderController {
|
||||
@Put('/orders/:id')
|
||||
async updateOrder(id: string, body: Partial<Order>) {
|
||||
// Any field can be updated!
|
||||
return this.orderService.update(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
async update(id: string, updates: Partial<Order>): Promise<Order> {
|
||||
const order = await this.repo.findById(id);
|
||||
Object.assign(order, updates); // Blindly apply updates
|
||||
return this.repo.save(order);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remediation
|
||||
|
||||
```typescript
|
||||
// CORRECT: Intent-revealing operations
|
||||
class OrderController {
|
||||
@Post('/orders/:id/ship')
|
||||
async shipOrder(id: string, body: ShipOrderRequest) {
|
||||
return this.orderService.ship(id, body.trackingNumber);
|
||||
}
|
||||
|
||||
@Post('/orders/:id/cancel')
|
||||
async cancelOrder(id: string, body: CancelOrderRequest) {
|
||||
return this.orderService.cancel(id, body.reason);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
async ship(orderId: OrderId, trackingNumber: TrackingNumber): Promise<void> {
|
||||
const order = await this.repo.findById(orderId);
|
||||
order.ship(trackingNumber); // Domain logic with validation
|
||||
await this.repo.save(order);
|
||||
await this.publish(new OrderShipped(orderId, trackingNumber));
|
||||
}
|
||||
|
||||
async cancel(orderId: OrderId, reason: CancellationReason): Promise<void> {
|
||||
const order = await this.repo.findById(orderId);
|
||||
order.cancel(reason); // Validates cancellation is allowed
|
||||
await this.repo.save(order);
|
||||
await this.publish(new OrderCancelled(orderId, reason));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary: Detection Checklist
|
||||
|
||||
| Anti-Pattern | Key Question |
|
||||
|--------------|--------------|
|
||||
| Anemic Domain Model | Do entities have behavior or just data? |
|
||||
| God Aggregate | Does everything need immediate consistency? |
|
||||
| Aggregate Reference Violation | Are aggregates holding other aggregates? |
|
||||
| Smart UI | Would changing UI framework lose business logic? |
|
||||
| Database-Driven Design | Does model match tables or domain concepts? |
|
||||
| Leaky Abstractions | Does domain code import infrastructure? |
|
||||
| Shared Database | Do multiple contexts write to same tables? |
|
||||
| Premature Abstraction | Are there interfaces with single implementations? |
|
||||
| Big Ball of Mud | Can any change break anything? |
|
||||
| CRUD-Driven Development | Are operations generic updates or domain intents? |
|
||||
@@ -0,0 +1,358 @@
|
||||
# Strategic DDD Patterns
|
||||
|
||||
Strategic DDD patterns address the large-scale structure of a system: how to divide it into bounded contexts, how those contexts relate, and how to prioritize investment across subdomains.
|
||||
|
||||
## Bounded Context
|
||||
|
||||
### Definition
|
||||
|
||||
A Bounded Context is an explicit boundary within which a domain model exists. Inside the boundary, all terms have specific, unambiguous meanings. The same term may mean different things in different bounded contexts.
|
||||
|
||||
### Why It Matters
|
||||
|
||||
- **Linguistic clarity** - "Customer" in Sales means something different than "Customer" in Shipping
|
||||
- **Model isolation** - Changes to one model don't cascade across the system
|
||||
- **Team autonomy** - Teams can work independently within their context
|
||||
- **Focused complexity** - Each context solves one set of problems well
|
||||
|
||||
### Identification Heuristics
|
||||
|
||||
1. **Language divergence** - When stakeholders use the same word differently, there's a context boundary
|
||||
2. **Department boundaries** - Organizational structure often mirrors domain structure
|
||||
3. **Process boundaries** - End-to-end business processes often define context edges
|
||||
4. **Data ownership** - Who is the authoritative source for this data?
|
||||
5. **Change frequency** - Parts that change together should stay together
|
||||
|
||||
### Example: E-Commerce Platform
|
||||
|
||||
| Context | "Order" means... | "Product" means... |
|
||||
|---------|------------------|-------------------|
|
||||
| **Catalog** | N/A | Displayable item with description, images, categories |
|
||||
| **Inventory** | N/A | Stock keeping unit with quantity and location |
|
||||
| **Sales** | Shopping cart ready for checkout | Line item with price |
|
||||
| **Fulfillment** | Shipment to be picked and packed | Physical item to ship |
|
||||
| **Billing** | Invoice to collect payment | Taxable good |
|
||||
|
||||
### Implementation Patterns
|
||||
|
||||
#### Separate Deployables
|
||||
Each bounded context as its own service/application.
|
||||
|
||||
```
|
||||
catalog-service/
|
||||
├── src/domain/Product.ts
|
||||
└── src/infrastructure/CatalogRepository.ts
|
||||
|
||||
sales-service/
|
||||
├── src/domain/Product.ts # Different model!
|
||||
└── src/domain/Order.ts
|
||||
```
|
||||
|
||||
#### Module Boundaries
|
||||
Bounded contexts as modules within a monolith.
|
||||
|
||||
```
|
||||
src/
|
||||
├── catalog/
|
||||
│ └── domain/Product.ts
|
||||
├── sales/
|
||||
│ └── domain/Product.ts # Different model!
|
||||
└── shared/
|
||||
└── kernel/Money.ts # Shared kernel
|
||||
```
|
||||
|
||||
## Context Map
|
||||
|
||||
### Definition
|
||||
|
||||
A Context Map is a visual and documented representation of how bounded contexts relate to each other. It makes integration patterns explicit.
|
||||
|
||||
### Integration Patterns
|
||||
|
||||
#### Partnership
|
||||
|
||||
Two contexts develop together with mutual dependencies. Changes are coordinated.
|
||||
|
||||
```
|
||||
┌─────────────┐ Partnership ┌─────────────┐
|
||||
│ Catalog │◄──────────────────►│ Inventory │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Use when**: Two teams must succeed or fail together.
|
||||
|
||||
#### Shared Kernel
|
||||
|
||||
A small, shared model that multiple contexts depend on. Changes require agreement from all consumers.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Sales │ │ Billing │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
└─────────► Money ◄──────────────┘
|
||||
(shared kernel)
|
||||
```
|
||||
|
||||
**Use when**: Core concepts genuinely need the same model.
|
||||
**Danger**: Creates coupling. Keep shared kernels minimal.
|
||||
|
||||
#### Customer-Supplier
|
||||
|
||||
Upstream context (supplier) provides data/services; downstream context (customer) consumes. Supplier considers customer needs.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Catalog │───── supplies ────►│ Sales │
|
||||
│ (upstream) │ │ (downstream)│
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Use when**: One context clearly serves another, and the supplier is responsive.
|
||||
|
||||
#### Conformist
|
||||
|
||||
Downstream adopts upstream's model without negotiation. Upstream doesn't accommodate downstream needs.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ External │───── dictates ────►│ Our App │
|
||||
│ API │ │ (conformist)│
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Use when**: Upstream won't change (third-party API), and their model is acceptable.
|
||||
|
||||
#### Anti-Corruption Layer (ACL)
|
||||
|
||||
Translation layer that protects a context from external models. Transforms data at the boundary.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌───────┐ ┌─────────────┐
|
||||
│ Legacy │───────►│ ACL │───────►│ New System │
|
||||
│ System │ └───────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Use when**: Upstream model would pollute downstream; translation is worth the cost.
|
||||
|
||||
```typescript
|
||||
// Anti-Corruption Layer example
|
||||
class LegacyOrderAdapter {
|
||||
constructor(private legacyApi: LegacyOrderApi) {}
|
||||
|
||||
translateOrder(legacyOrder: LegacyOrder): Order {
|
||||
return new Order({
|
||||
id: OrderId.from(legacyOrder.order_num),
|
||||
customer: this.translateCustomer(legacyOrder.cust_data),
|
||||
items: legacyOrder.line_items.map(this.translateLineItem),
|
||||
// Transform legacy status codes to domain concepts
|
||||
status: this.mapStatus(legacyOrder.stat_cd),
|
||||
});
|
||||
}
|
||||
|
||||
private mapStatus(legacyCode: string): OrderStatus {
|
||||
const mapping: Record<string, OrderStatus> = {
|
||||
'OP': OrderStatus.Open,
|
||||
'SH': OrderStatus.Shipped,
|
||||
'CL': OrderStatus.Closed,
|
||||
};
|
||||
return mapping[legacyCode] ?? OrderStatus.Unknown;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Open Host Service
|
||||
|
||||
A context provides a well-defined protocol/API for others to consume.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
┌──────────►│ Reports │
|
||||
│ └─────────────┘
|
||||
┌───────┴───────┐ ┌─────────────┐
|
||||
│ Catalog API │──►│ Search │
|
||||
│ (open host) │ └─────────────┘
|
||||
└───────┬───────┘ ┌─────────────┐
|
||||
└──────────►│ Partner │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**Use when**: Multiple downstream contexts need access; worth investing in a stable API.
|
||||
|
||||
#### Published Language
|
||||
|
||||
A shared language format (schema) for communication between contexts. Often combined with Open Host Service.
|
||||
|
||||
Examples: JSON schemas, Protocol Buffers, GraphQL schemas, industry standards (HL7 for healthcare).
|
||||
|
||||
#### Separate Ways
|
||||
|
||||
Contexts have no integration. Each solves its needs independently.
|
||||
|
||||
**Use when**: Integration cost exceeds benefit; duplication is acceptable.
|
||||
|
||||
### Context Map Notation
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ CONTEXT MAP │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ Partnership ┌─────────┐ │
|
||||
│ │ Sales │◄────────────────────────────►│Inventory│ │
|
||||
│ │ (U,D) │ │ (U,D) │ │
|
||||
│ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │
|
||||
│ │ Customer/Supplier │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────┐ │ │
|
||||
│ │ Billing │◄──────────────────────────────────┘ │
|
||||
│ │ (D) │ Conformist │
|
||||
│ └─────────┘ │
|
||||
│ │
|
||||
│ Legend: U = Upstream, D = Downstream │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Subdomain Classification
|
||||
|
||||
### Core Domain
|
||||
|
||||
The essential differentiator. This is where competitive advantage lives.
|
||||
|
||||
**Characteristics**:
|
||||
- Unique to this business
|
||||
- Complex, requires deep expertise
|
||||
- Frequently changing as business evolves
|
||||
- Worth significant investment
|
||||
|
||||
**Strategy**: Build in-house with best talent. Invest heavily in modeling.
|
||||
|
||||
### Supporting Subdomain
|
||||
|
||||
Necessary for the business but not a differentiator.
|
||||
|
||||
**Characteristics**:
|
||||
- Important but not unique
|
||||
- Moderate complexity
|
||||
- Changes less frequently
|
||||
- Custom implementation needed
|
||||
|
||||
**Strategy**: Build with adequate (not exceptional) investment. May outsource.
|
||||
|
||||
### Generic Subdomain
|
||||
|
||||
Solved problems with off-the-shelf solutions.
|
||||
|
||||
**Characteristics**:
|
||||
- Common across industries
|
||||
- Well-understood solutions exist
|
||||
- Rarely changes
|
||||
- Not a differentiator
|
||||
|
||||
**Strategy**: Buy or use open-source. Don't reinvent.
|
||||
|
||||
### Example: E-Commerce Platform
|
||||
|
||||
| Subdomain | Type | Strategy |
|
||||
|-----------|------|----------|
|
||||
| Product Recommendation Engine | Core | In-house, top talent |
|
||||
| Inventory Management | Supporting | Build, adequate investment |
|
||||
| Payment Processing | Generic | Third-party (Stripe, etc.) |
|
||||
| User Authentication | Generic | Third-party or standard library |
|
||||
| Shipping Logistics | Supporting | Build or integrate vendor |
|
||||
| Customer Analytics | Core | In-house, strategic investment |
|
||||
|
||||
## Ubiquitous Language
|
||||
|
||||
### Definition
|
||||
|
||||
A common language shared by developers and domain experts. It appears in conversations, documentation, and code.
|
||||
|
||||
### Building Ubiquitous Language
|
||||
|
||||
1. **Listen to experts** - Use their terminology, not technical jargon
|
||||
2. **Challenge vague terms** - "Process the order" → What exactly happens?
|
||||
3. **Document glossary** - Maintain a living dictionary
|
||||
4. **Enforce in code** - Class and method names use the language
|
||||
5. **Refine continuously** - Language evolves with understanding
|
||||
|
||||
### Language in Code
|
||||
|
||||
```typescript
|
||||
// Bad: Technical terms
|
||||
class OrderProcessor {
|
||||
handleOrderCreation(data: OrderData): void {
|
||||
this.validateData(data);
|
||||
this.persistToDatabase(data);
|
||||
this.sendNotification(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Good: Ubiquitous language
|
||||
class OrderTaker {
|
||||
placeOrder(cart: ShoppingCart): PlacedOrder {
|
||||
const order = cart.checkout();
|
||||
order.confirmWith(this.paymentGateway);
|
||||
this.orderRepository.save(order);
|
||||
this.domainEvents.publish(new OrderPlaced(order));
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Glossary Example
|
||||
|
||||
| Term | Definition | Context |
|
||||
|------|------------|---------|
|
||||
| **Order** | A confirmed purchase with payment collected | Sales |
|
||||
| **Shipment** | Physical package(s) sent to fulfill an order | Fulfillment |
|
||||
| **SKU** | Stock Keeping Unit; unique identifier for inventory | Inventory |
|
||||
| **Cart** | Uncommitted collection of items a customer intends to buy | Sales |
|
||||
| **Listing** | Product displayed for purchase in the catalog | Catalog |
|
||||
|
||||
### Anti-Pattern: Technical Language Leakage
|
||||
|
||||
```typescript
|
||||
// Bad: Database terminology leaks into domain
|
||||
order.setForeignKeyCustomerId(customerId);
|
||||
order.persist();
|
||||
|
||||
// Bad: HTTP concerns leak into domain
|
||||
order.deserializeFromJson(request.body);
|
||||
order.setHttpStatus(200);
|
||||
|
||||
// Good: Domain language only
|
||||
order.placeFor(customer);
|
||||
orderRepository.save(order);
|
||||
```
|
||||
|
||||
## Strategic Design Decisions
|
||||
|
||||
### When to Split a Bounded Context
|
||||
|
||||
Split when:
|
||||
- Different parts need to evolve at different speeds
|
||||
- Different teams need ownership
|
||||
- Model complexity is becoming unmanageable
|
||||
- Language conflicts are emerging within the context
|
||||
|
||||
Don't split when:
|
||||
- Transaction boundaries would become awkward
|
||||
- Integration cost outweighs isolation benefit
|
||||
- Single team can handle the complexity
|
||||
|
||||
### When to Merge Bounded Contexts
|
||||
|
||||
Merge when:
|
||||
- Integration overhead is excessive
|
||||
- Same team owns both
|
||||
- Models are converging naturally
|
||||
- Separate contexts create artificial complexity
|
||||
|
||||
### Dealing with Legacy Systems
|
||||
|
||||
1. **Bubble context** - New bounded context with ACL to legacy
|
||||
2. **Strangler fig** - Gradually replace legacy feature by feature
|
||||
3. **Conformist** - Accept legacy model if acceptable
|
||||
4. **Separate ways** - Rebuild independently, migrate data later
|
||||
@@ -0,0 +1,927 @@
|
||||
# Tactical DDD Patterns
|
||||
|
||||
Tactical DDD patterns are code-level building blocks for implementing a rich domain model. They help express domain concepts in code that mirrors how domain experts think.
|
||||
|
||||
## Entity
|
||||
|
||||
### Definition
|
||||
|
||||
An object defined by its identity rather than its attributes. Two entities with the same attribute values but different identities are different things.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Has a unique identifier that persists through state changes
|
||||
- Identity established at creation, immutable thereafter
|
||||
- Equality based on identity, not attribute values
|
||||
- Has a lifecycle (created, modified, potentially deleted)
|
||||
- Contains behavior relevant to the domain concept it represents
|
||||
|
||||
### When to Use
|
||||
|
||||
- The object represents something tracked over time
|
||||
- "Is this the same one?" is a meaningful question
|
||||
- The object needs to be referenced from other parts of the system
|
||||
- State changes are important to track
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// Entity with identity and behavior
|
||||
class Order {
|
||||
private readonly _id: OrderId;
|
||||
private _status: OrderStatus;
|
||||
private _items: OrderItem[];
|
||||
private _shippingAddress: Address;
|
||||
|
||||
constructor(id: OrderId, items: OrderItem[], shippingAddress: Address) {
|
||||
this._id = id;
|
||||
this._items = items;
|
||||
this._shippingAddress = shippingAddress;
|
||||
this._status = OrderStatus.Pending;
|
||||
}
|
||||
|
||||
get id(): OrderId {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
// Behavior, not just data access
|
||||
confirm(): void {
|
||||
if (this._items.length === 0) {
|
||||
throw new EmptyOrderError(this._id);
|
||||
}
|
||||
this._status = OrderStatus.Confirmed;
|
||||
}
|
||||
|
||||
ship(trackingNumber: TrackingNumber): void {
|
||||
if (this._status !== OrderStatus.Confirmed) {
|
||||
throw new InvalidOrderStateError(this._id, this._status, 'ship');
|
||||
}
|
||||
this._status = OrderStatus.Shipped;
|
||||
// Domain event raised
|
||||
}
|
||||
|
||||
addItem(item: OrderItem): void {
|
||||
if (this._status !== OrderStatus.Pending) {
|
||||
throw new OrderModificationError(this._id);
|
||||
}
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
// Identity-based equality
|
||||
equals(other: Order): boolean {
|
||||
return this._id.equals(other._id);
|
||||
}
|
||||
}
|
||||
|
||||
// Strongly-typed identity
|
||||
class OrderId {
|
||||
constructor(private readonly value: string) {
|
||||
if (!value || value.trim() === '') {
|
||||
throw new InvalidOrderIdError();
|
||||
}
|
||||
}
|
||||
|
||||
equals(other: OrderId): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entity vs Data Structure
|
||||
|
||||
```typescript
|
||||
// Bad: Anemic entity (data structure)
|
||||
class Order {
|
||||
id: string;
|
||||
status: string;
|
||||
items: Item[];
|
||||
|
||||
// Only getters/setters, no behavior
|
||||
}
|
||||
|
||||
// Good: Rich entity with behavior
|
||||
class Order {
|
||||
private _id: OrderId;
|
||||
private _status: OrderStatus;
|
||||
private _items: OrderItem[];
|
||||
|
||||
confirm(): void { /* enforces rules */ }
|
||||
cancel(reason: CancellationReason): void { /* enforces rules */ }
|
||||
addItem(item: OrderItem): void { /* enforces rules */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Value Object
|
||||
|
||||
### Definition
|
||||
|
||||
An object defined entirely by its attributes. Two value objects with the same attributes are interchangeable. Has no identity.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Immutable - once created, never changes
|
||||
- Equality based on attributes, not identity
|
||||
- Self-validating - always in a valid state
|
||||
- Side-effect free - methods return new instances
|
||||
- Conceptually whole - attributes form a complete concept
|
||||
|
||||
### When to Use
|
||||
|
||||
- The concept has no lifecycle or identity
|
||||
- "Are these the same?" means "do they have the same values?"
|
||||
- Measurement, description, or quantification
|
||||
- Combinations of attributes that belong together
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// Value Object: Money
|
||||
class Money {
|
||||
private constructor(
|
||||
private readonly amount: number,
|
||||
private readonly currency: Currency
|
||||
) {}
|
||||
|
||||
// Factory method with validation
|
||||
static of(amount: number, currency: Currency): Money {
|
||||
if (amount < 0) {
|
||||
throw new NegativeMoneyError(amount);
|
||||
}
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
// Immutable operations - return new instances
|
||||
add(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
return Money.of(this.amount + other.amount, this.currency);
|
||||
}
|
||||
|
||||
subtract(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
return Money.of(this.amount - other.amount, this.currency);
|
||||
}
|
||||
|
||||
multiply(factor: number): Money {
|
||||
return Money.of(this.amount * factor, this.currency);
|
||||
}
|
||||
|
||||
// Value-based equality
|
||||
equals(other: Money): boolean {
|
||||
return this.amount === other.amount &&
|
||||
this.currency.equals(other.currency);
|
||||
}
|
||||
|
||||
private ensureSameCurrency(other: Money): void {
|
||||
if (!this.currency.equals(other.currency)) {
|
||||
throw new CurrencyMismatchError(this.currency, other.currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Value Object: Address
|
||||
class Address {
|
||||
private constructor(
|
||||
readonly street: string,
|
||||
readonly city: string,
|
||||
readonly postalCode: string,
|
||||
readonly country: Country
|
||||
) {}
|
||||
|
||||
static create(street: string, city: string, postalCode: string, country: Country): Address {
|
||||
if (!street || !city || !postalCode) {
|
||||
throw new InvalidAddressError();
|
||||
}
|
||||
if (!country.validatePostalCode(postalCode)) {
|
||||
throw new InvalidPostalCodeError(postalCode, country);
|
||||
}
|
||||
return new Address(street, city, postalCode, country);
|
||||
}
|
||||
|
||||
// Returns new instance with modified value
|
||||
withStreet(newStreet: string): Address {
|
||||
return Address.create(newStreet, this.city, this.postalCode, this.country);
|
||||
}
|
||||
|
||||
equals(other: Address): boolean {
|
||||
return this.street === other.street &&
|
||||
this.city === other.city &&
|
||||
this.postalCode === other.postalCode &&
|
||||
this.country.equals(other.country);
|
||||
}
|
||||
}
|
||||
|
||||
// Value Object: DateRange
|
||||
class DateRange {
|
||||
private constructor(
|
||||
readonly start: Date,
|
||||
readonly end: Date
|
||||
) {}
|
||||
|
||||
static create(start: Date, end: Date): DateRange {
|
||||
if (end < start) {
|
||||
throw new InvalidDateRangeError(start, end);
|
||||
}
|
||||
return new DateRange(start, end);
|
||||
}
|
||||
|
||||
contains(date: Date): boolean {
|
||||
return date >= this.start && date <= this.end;
|
||||
}
|
||||
|
||||
overlaps(other: DateRange): boolean {
|
||||
return this.start <= other.end && this.end >= other.start;
|
||||
}
|
||||
|
||||
durationInDays(): number {
|
||||
return Math.floor((this.end.getTime() - this.start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Value Objects
|
||||
|
||||
| Domain | Value Objects |
|
||||
|--------|--------------|
|
||||
| **E-commerce** | Money, Price, Quantity, SKU, Address, PhoneNumber |
|
||||
| **Healthcare** | BloodPressure, Dosage, DateRange, PatientId |
|
||||
| **Finance** | AccountNumber, IBAN, TaxId, Percentage |
|
||||
| **Shipping** | Weight, Dimensions, TrackingNumber, PostalCode |
|
||||
| **General** | Email, URL, PhoneNumber, Name, Coordinates |
|
||||
|
||||
## Aggregate
|
||||
|
||||
### Definition
|
||||
|
||||
A cluster of entities and value objects with defined boundaries. Has an aggregate root entity that serves as the single entry point. External objects can only reference the root.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Defines a transactional consistency boundary
|
||||
- Aggregate root is the only externally accessible object
|
||||
- Enforces invariants across the cluster
|
||||
- Loaded and saved as a unit
|
||||
- Other aggregates referenced by identity only
|
||||
|
||||
### Design Rules
|
||||
|
||||
1. **Protect invariants** - All rules that must be consistent are inside the boundary
|
||||
2. **Small aggregates** - Prefer single-entity aggregates; add children only when invariants require
|
||||
3. **Reference by identity** - Never hold direct references to other aggregates
|
||||
4. **Update one per transaction** - Eventual consistency between aggregates
|
||||
5. **Design around invariants** - Identify what must be immediately consistent
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// Aggregate: Order (root) with OrderItems (child entities)
|
||||
class Order {
|
||||
private readonly _id: OrderId;
|
||||
private _items: Map<ProductId, OrderItem>;
|
||||
private _status: OrderStatus;
|
||||
|
||||
// Invariant: Order total cannot exceed credit limit
|
||||
private _creditLimit: Money;
|
||||
|
||||
private constructor(
|
||||
id: OrderId,
|
||||
creditLimit: Money
|
||||
) {
|
||||
this._id = id;
|
||||
this._items = new Map();
|
||||
this._status = OrderStatus.Draft;
|
||||
this._creditLimit = creditLimit;
|
||||
}
|
||||
|
||||
static create(id: OrderId, creditLimit: Money): Order {
|
||||
return new Order(id, creditLimit);
|
||||
}
|
||||
|
||||
// All modifications go through aggregate root
|
||||
addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
|
||||
this.ensureCanModify();
|
||||
|
||||
const newItem = OrderItem.create(productId, quantity, unitPrice);
|
||||
const projectedTotal = this.calculateTotalWith(newItem);
|
||||
|
||||
// Invariant enforcement
|
||||
if (projectedTotal.isGreaterThan(this._creditLimit)) {
|
||||
throw new CreditLimitExceededError(projectedTotal, this._creditLimit);
|
||||
}
|
||||
|
||||
this._items.set(productId, newItem);
|
||||
}
|
||||
|
||||
removeItem(productId: ProductId): void {
|
||||
this.ensureCanModify();
|
||||
this._items.delete(productId);
|
||||
}
|
||||
|
||||
updateItemQuantity(productId: ProductId, newQuantity: Quantity): void {
|
||||
this.ensureCanModify();
|
||||
|
||||
const item = this._items.get(productId);
|
||||
if (!item) {
|
||||
throw new ItemNotFoundError(productId);
|
||||
}
|
||||
|
||||
const updatedItem = item.withQuantity(newQuantity);
|
||||
const projectedTotal = this.calculateTotalWithUpdate(productId, updatedItem);
|
||||
|
||||
if (projectedTotal.isGreaterThan(this._creditLimit)) {
|
||||
throw new CreditLimitExceededError(projectedTotal, this._creditLimit);
|
||||
}
|
||||
|
||||
this._items.set(productId, updatedItem);
|
||||
}
|
||||
|
||||
submit(): OrderSubmitted {
|
||||
if (this._items.size === 0) {
|
||||
throw new EmptyOrderError();
|
||||
}
|
||||
this._status = OrderStatus.Submitted;
|
||||
|
||||
return new OrderSubmitted(this._id, this.total(), new Date());
|
||||
}
|
||||
|
||||
// Read-only access to child entities
|
||||
get items(): ReadonlyArray<OrderItem> {
|
||||
return Array.from(this._items.values());
|
||||
}
|
||||
|
||||
total(): Money {
|
||||
return this.items.reduce(
|
||||
(sum, item) => sum.add(item.subtotal()),
|
||||
Money.zero(Currency.USD)
|
||||
);
|
||||
}
|
||||
|
||||
private ensureCanModify(): void {
|
||||
if (this._status !== OrderStatus.Draft) {
|
||||
throw new OrderNotModifiableError(this._id, this._status);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateTotalWith(newItem: OrderItem): Money {
|
||||
return this.total().add(newItem.subtotal());
|
||||
}
|
||||
|
||||
private calculateTotalWithUpdate(productId: ProductId, updatedItem: OrderItem): Money {
|
||||
const currentItem = this._items.get(productId)!;
|
||||
return this.total().subtract(currentItem.subtotal()).add(updatedItem.subtotal());
|
||||
}
|
||||
}
|
||||
|
||||
// Child entity - only accessible through aggregate root
|
||||
class OrderItem {
|
||||
private constructor(
|
||||
private readonly _productId: ProductId,
|
||||
private _quantity: Quantity,
|
||||
private readonly _unitPrice: Money
|
||||
) {}
|
||||
|
||||
static create(productId: ProductId, quantity: Quantity, unitPrice: Money): OrderItem {
|
||||
return new OrderItem(productId, quantity, unitPrice);
|
||||
}
|
||||
|
||||
get productId(): ProductId { return this._productId; }
|
||||
get quantity(): Quantity { return this._quantity; }
|
||||
get unitPrice(): Money { return this._unitPrice; }
|
||||
|
||||
subtotal(): Money {
|
||||
return this._unitPrice.multiply(this._quantity.value);
|
||||
}
|
||||
|
||||
withQuantity(newQuantity: Quantity): OrderItem {
|
||||
return new OrderItem(this._productId, newQuantity, this._unitPrice);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregate Reference Patterns
|
||||
|
||||
```typescript
|
||||
// Bad: Direct object reference across aggregates
|
||||
class Order {
|
||||
private customer: Customer; // Holds the entire aggregate!
|
||||
}
|
||||
|
||||
// Good: Reference by identity
|
||||
class Order {
|
||||
private customerId: CustomerId;
|
||||
|
||||
// If customer data needed, load separately
|
||||
getCustomerAddress(customerRepository: CustomerRepository): Address {
|
||||
const customer = customerRepository.findById(this.customerId);
|
||||
return customer.shippingAddress;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Event
|
||||
|
||||
### Definition
|
||||
|
||||
A record of something significant that happened in the domain. Captures state changes that domain experts care about.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Named in past tense (OrderPlaced, PaymentReceived)
|
||||
- Immutable - records historical fact
|
||||
- Contains all relevant data about what happened
|
||||
- Published after state change is committed
|
||||
- May trigger reactions in same or different bounded contexts
|
||||
|
||||
### When to Use
|
||||
|
||||
- Domain experts talk about "when X happens, Y should happen"
|
||||
- Need to communicate changes across aggregate boundaries
|
||||
- Maintaining an audit trail
|
||||
- Implementing eventual consistency
|
||||
- Integration with other bounded contexts
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// Base domain event
|
||||
abstract class DomainEvent {
|
||||
readonly occurredAt: Date;
|
||||
readonly eventId: string;
|
||||
|
||||
constructor() {
|
||||
this.occurredAt = new Date();
|
||||
this.eventId = generateUUID();
|
||||
}
|
||||
|
||||
abstract get eventType(): string;
|
||||
}
|
||||
|
||||
// Specific domain events
|
||||
class OrderPlaced extends DomainEvent {
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly customerId: CustomerId,
|
||||
readonly totalAmount: Money,
|
||||
readonly items: ReadonlyArray<OrderItemSnapshot>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'order.placed';
|
||||
}
|
||||
}
|
||||
|
||||
class OrderShipped extends DomainEvent {
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly trackingNumber: TrackingNumber,
|
||||
readonly carrier: string,
|
||||
readonly estimatedDelivery: Date
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'order.shipped';
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentReceived extends DomainEvent {
|
||||
constructor(
|
||||
readonly orderId: OrderId,
|
||||
readonly amount: Money,
|
||||
readonly paymentMethod: PaymentMethod,
|
||||
readonly transactionId: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get eventType(): string {
|
||||
return 'payment.received';
|
||||
}
|
||||
}
|
||||
|
||||
// Entity raising events
|
||||
class Order {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
submit(): void {
|
||||
// State change
|
||||
this._status = OrderStatus.Submitted;
|
||||
|
||||
// Raise event
|
||||
this._domainEvents.push(
|
||||
new OrderPlaced(
|
||||
this._id,
|
||||
this._customerId,
|
||||
this.total(),
|
||||
this.itemSnapshots()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
pullDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents];
|
||||
this._domainEvents = [];
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
// Event handler
|
||||
class OrderPlacedHandler {
|
||||
constructor(
|
||||
private inventoryService: InventoryService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async handle(event: OrderPlaced): Promise<void> {
|
||||
// Reserve inventory (different aggregate)
|
||||
await this.inventoryService.reserveItems(event.items);
|
||||
|
||||
// Send confirmation email
|
||||
await this.emailService.sendOrderConfirmation(
|
||||
event.customerId,
|
||||
event.orderId,
|
||||
event.totalAmount
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Publishing Patterns
|
||||
|
||||
```typescript
|
||||
// Pattern 1: Collect and dispatch after save
|
||||
class OrderApplicationService {
|
||||
async placeOrder(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
const order = Order.create(command);
|
||||
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// Dispatch events after successful save
|
||||
const events = order.pullDomainEvents();
|
||||
await this.eventDispatcher.dispatchAll(events);
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Outbox pattern (reliable publishing)
|
||||
class OrderApplicationService {
|
||||
async placeOrder(command: PlaceOrderCommand): Promise<OrderId> {
|
||||
await this.unitOfWork.transaction(async () => {
|
||||
const order = Order.create(command);
|
||||
await this.orderRepository.save(order);
|
||||
|
||||
// Save events to outbox in same transaction
|
||||
const events = order.pullDomainEvents();
|
||||
await this.outbox.saveEvents(events);
|
||||
});
|
||||
|
||||
// Separate process publishes from outbox
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repository
|
||||
|
||||
### Definition
|
||||
|
||||
Mediates between the domain and data mapping layers. Provides collection-like interface for accessing aggregates.
|
||||
|
||||
### Characteristics
|
||||
|
||||
- One repository per aggregate root
|
||||
- Interface defined in domain layer, implementation in infrastructure
|
||||
- Returns fully reconstituted aggregates
|
||||
- Abstracts persistence concerns from domain
|
||||
|
||||
### Interface Design
|
||||
|
||||
```typescript
|
||||
// Domain layer interface
|
||||
interface OrderRepository {
|
||||
findById(id: OrderId): Promise<Order | null>;
|
||||
save(order: Order): Promise<void>;
|
||||
delete(order: Order): Promise<void>;
|
||||
|
||||
// Domain-specific queries
|
||||
findPendingOrdersFor(customerId: CustomerId): Promise<Order[]>;
|
||||
findOrdersToShipBefore(deadline: Date): Promise<Order[]>;
|
||||
}
|
||||
|
||||
// Infrastructure implementation
|
||||
class PostgresOrderRepository implements OrderRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async findById(id: OrderId): Promise<Order | null> {
|
||||
const row = await this.db.query(
|
||||
'SELECT * FROM orders WHERE id = $1',
|
||||
[id.toString()]
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const items = await this.db.query(
|
||||
'SELECT * FROM order_items WHERE order_id = $1',
|
||||
[id.toString()]
|
||||
);
|
||||
|
||||
return this.reconstitute(row, items);
|
||||
}
|
||||
|
||||
async save(order: Order): Promise<void> {
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx.query(
|
||||
'INSERT INTO orders (id, status, customer_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET status = $2',
|
||||
[order.id.toString(), order.status, order.customerId.toString()]
|
||||
);
|
||||
|
||||
// Save items
|
||||
for (const item of order.items) {
|
||||
await tx.query(
|
||||
'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4) ON CONFLICT DO UPDATE...',
|
||||
[order.id.toString(), item.productId.toString(), item.quantity.value, item.unitPrice.amount]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private reconstitute(orderRow: any, itemRows: any[]): Order {
|
||||
// Rebuild aggregate from persistence data
|
||||
return Order.reconstitute({
|
||||
id: OrderId.from(orderRow.id),
|
||||
status: OrderStatus[orderRow.status],
|
||||
customerId: CustomerId.from(orderRow.customer_id),
|
||||
items: itemRows.map(row => OrderItem.reconstitute({
|
||||
productId: ProductId.from(row.product_id),
|
||||
quantity: Quantity.of(row.quantity),
|
||||
unitPrice: Money.of(row.unit_price, Currency.USD)
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository vs DAO
|
||||
|
||||
```typescript
|
||||
// DAO: Data-centric, returns raw data
|
||||
interface OrderDao {
|
||||
findById(id: string): Promise<OrderRow>;
|
||||
findItems(orderId: string): Promise<OrderItemRow[]>;
|
||||
insert(row: OrderRow): Promise<void>;
|
||||
}
|
||||
|
||||
// Repository: Domain-centric, returns aggregates
|
||||
interface OrderRepository {
|
||||
findById(id: OrderId): Promise<Order | null>;
|
||||
save(order: Order): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## Domain Service
|
||||
|
||||
### Definition
|
||||
|
||||
Stateless operations that represent domain concepts but don't naturally belong to any entity or value object.
|
||||
|
||||
### When to Use
|
||||
|
||||
- The operation involves multiple aggregates
|
||||
- The operation represents a domain concept
|
||||
- Putting the operation on an entity would create awkward dependencies
|
||||
- The operation is stateless
|
||||
|
||||
### Examples
|
||||
|
||||
```typescript
|
||||
// Domain Service: Transfer money between accounts
|
||||
class MoneyTransferService {
|
||||
transfer(
|
||||
from: Account,
|
||||
to: Account,
|
||||
amount: Money
|
||||
): TransferResult {
|
||||
// Involves two aggregates
|
||||
// Neither account should "own" this operation
|
||||
|
||||
if (!from.canWithdraw(amount)) {
|
||||
return TransferResult.insufficientFunds();
|
||||
}
|
||||
|
||||
from.withdraw(amount);
|
||||
to.deposit(amount);
|
||||
|
||||
return TransferResult.success(
|
||||
new MoneyTransferred(from.id, to.id, amount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain Service: Calculate shipping cost
|
||||
class ShippingCostCalculator {
|
||||
constructor(
|
||||
private rateProvider: ShippingRateProvider
|
||||
) {}
|
||||
|
||||
calculate(
|
||||
items: OrderItem[],
|
||||
destination: Address,
|
||||
shippingMethod: ShippingMethod
|
||||
): Money {
|
||||
const totalWeight = items.reduce(
|
||||
(sum, item) => sum.add(item.weight),
|
||||
Weight.zero()
|
||||
);
|
||||
|
||||
const rate = this.rateProvider.getRate(
|
||||
destination.country,
|
||||
shippingMethod
|
||||
);
|
||||
|
||||
return rate.calculateFor(totalWeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Domain Service: Check inventory availability
|
||||
class InventoryAvailabilityService {
|
||||
constructor(
|
||||
private inventoryRepository: InventoryRepository
|
||||
) {}
|
||||
|
||||
checkAvailability(
|
||||
items: Array<{ productId: ProductId; quantity: Quantity }>
|
||||
): AvailabilityResult {
|
||||
const unavailable: ProductId[] = [];
|
||||
|
||||
for (const { productId, quantity } of items) {
|
||||
const inventory = this.inventoryRepository.findByProductId(productId);
|
||||
if (!inventory || !inventory.hasAvailable(quantity)) {
|
||||
unavailable.push(productId);
|
||||
}
|
||||
}
|
||||
|
||||
return unavailable.length === 0
|
||||
? AvailabilityResult.allAvailable()
|
||||
: AvailabilityResult.someUnavailable(unavailable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Service vs Application Service
|
||||
|
||||
```typescript
|
||||
// Domain Service: Domain logic, domain types, stateless
|
||||
class PricingService {
|
||||
calculateDiscountedPrice(product: Product, customer: Customer): Money {
|
||||
const basePrice = product.price;
|
||||
const discount = customer.membershipLevel.discountPercentage;
|
||||
return basePrice.applyDiscount(discount);
|
||||
}
|
||||
}
|
||||
|
||||
// Application Service: Orchestration, use cases, transaction boundary
|
||||
class OrderApplicationService {
|
||||
constructor(
|
||||
private orderRepository: OrderRepository,
|
||||
private pricingService: PricingService,
|
||||
private eventPublisher: EventPublisher
|
||||
) {}
|
||||
|
||||
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
|
||||
const customer = await this.customerRepository.findById(command.customerId);
|
||||
const order = Order.create(command.orderId, customer.id);
|
||||
|
||||
for (const item of command.items) {
|
||||
const product = await this.productRepository.findById(item.productId);
|
||||
const price = this.pricingService.calculateDiscountedPrice(product, customer);
|
||||
order.addItem(item.productId, item.quantity, price);
|
||||
}
|
||||
|
||||
await this.orderRepository.save(order);
|
||||
await this.eventPublisher.publish(order.pullDomainEvents());
|
||||
|
||||
return order.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Factory
|
||||
|
||||
### Definition
|
||||
|
||||
Encapsulates complex object or aggregate creation logic. Creates objects in a valid state.
|
||||
|
||||
### When to Use
|
||||
|
||||
- Construction logic is complex
|
||||
- Multiple ways to create the same type of object
|
||||
- Creation involves other objects or services
|
||||
- Need to enforce invariants at creation time
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// Factory as static method
|
||||
class Order {
|
||||
static create(customerId: CustomerId, creditLimit: Money): Order {
|
||||
return new Order(
|
||||
OrderId.generate(),
|
||||
customerId,
|
||||
creditLimit,
|
||||
OrderStatus.Draft,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(data: OrderData): Order {
|
||||
// For rebuilding from persistence
|
||||
return new Order(
|
||||
data.id,
|
||||
data.customerId,
|
||||
data.creditLimit,
|
||||
data.status,
|
||||
data.items
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Factory as separate class
|
||||
class OrderFactory {
|
||||
constructor(
|
||||
private creditLimitService: CreditLimitService,
|
||||
private idGenerator: IdGenerator
|
||||
) {}
|
||||
|
||||
async createForCustomer(customerId: CustomerId): Promise<Order> {
|
||||
const creditLimit = await this.creditLimitService.getLimit(customerId);
|
||||
const orderId = this.idGenerator.generate();
|
||||
|
||||
return Order.create(orderId, customerId, creditLimit);
|
||||
}
|
||||
|
||||
createFromQuote(quote: Quote): Order {
|
||||
const order = Order.create(
|
||||
this.idGenerator.generate(),
|
||||
quote.customerId,
|
||||
quote.creditLimit
|
||||
);
|
||||
|
||||
for (const item of quote.items) {
|
||||
order.addItem(item.productId, item.quantity, item.agreedPrice);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
// Builder pattern for complex construction
|
||||
class OrderBuilder {
|
||||
private customerId?: CustomerId;
|
||||
private items: OrderItemData[] = [];
|
||||
private shippingAddress?: Address;
|
||||
private billingAddress?: Address;
|
||||
|
||||
forCustomer(customerId: CustomerId): this {
|
||||
this.customerId = customerId;
|
||||
return this;
|
||||
}
|
||||
|
||||
withItem(productId: ProductId, quantity: Quantity, price: Money): this {
|
||||
this.items.push({ productId, quantity, price });
|
||||
return this;
|
||||
}
|
||||
|
||||
shippingTo(address: Address): this {
|
||||
this.shippingAddress = address;
|
||||
return this;
|
||||
}
|
||||
|
||||
billingTo(address: Address): this {
|
||||
this.billingAddress = address;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): Order {
|
||||
if (!this.customerId) throw new Error('Customer required');
|
||||
if (!this.shippingAddress) throw new Error('Shipping address required');
|
||||
if (this.items.length === 0) throw new Error('At least one item required');
|
||||
|
||||
const order = Order.create(this.customerId);
|
||||
order.setShippingAddress(this.shippingAddress);
|
||||
order.setBillingAddress(this.billingAddress ?? this.shippingAddress);
|
||||
|
||||
for (const item of this.items) {
|
||||
order.addItem(item.productId, item.quantity, item.price);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user