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