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:
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