Browse Source
- 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>main
13 changed files with 4196 additions and 363 deletions
@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
|
||||
--- |
||||
name: domain-driven-design |
||||
description: This skill should be used when designing software architecture, modeling domains, reviewing code for DDD compliance, identifying bounded contexts, designing aggregates, or discussing strategic and tactical DDD patterns. Provides comprehensive Domain-Driven Design principles, axioms, heuristics, and anti-patterns for building maintainable, domain-centric software systems. |
||||
--- |
||||
|
||||
# Domain-Driven Design |
||||
|
||||
## Overview |
||||
|
||||
Domain-Driven Design (DDD) is an approach to software development that centers the design on the core business domain. This skill provides principles, patterns, and heuristics for both strategic design (system boundaries and relationships) and tactical design (code-level patterns). |
||||
|
||||
## When to Apply This Skill |
||||
|
||||
- Designing new systems or features with complex business logic |
||||
- Identifying and defining bounded contexts |
||||
- Modeling aggregates, entities, and value objects |
||||
- Reviewing code for DDD pattern compliance |
||||
- Decomposing monoliths into services |
||||
- Establishing ubiquitous language with domain experts |
||||
|
||||
## Core Axioms |
||||
|
||||
### Axiom 1: The Domain is Supreme |
||||
|
||||
Software exists to solve domain problems. Technical decisions serve the domain, not vice versa. When technical elegance conflicts with domain clarity, domain clarity wins. |
||||
|
||||
### Axiom 2: Language Creates Reality |
||||
|
||||
The ubiquitous language shapes how teams think about the domain. Ambiguous language creates ambiguous software. Invest heavily in precise terminology. |
||||
|
||||
### Axiom 3: Boundaries Enable Autonomy |
||||
|
||||
Explicit boundaries (bounded contexts) allow teams to evolve independently. The cost of integration is worth the benefit of isolation. |
||||
|
||||
### Axiom 4: Models are Imperfect Approximations |
||||
|
||||
No model captures all domain complexity. Accept that models simplify reality. Refine models continuously as understanding deepens. |
||||
|
||||
## Strategic Design Quick Reference |
||||
|
||||
| Pattern | Purpose | Key Heuristic | |
||||
|---------|---------|---------------| |
||||
| **Bounded Context** | Define linguistic/model boundaries | One team, one language, one model | |
||||
| **Context Map** | Document context relationships | Make implicit integrations explicit | |
||||
| **Subdomain** | Classify domain areas by value | Core (invest), Supporting (adequate), Generic (outsource) | |
||||
| **Ubiquitous Language** | Shared vocabulary | If experts don't use the term, neither should code | |
||||
|
||||
For detailed strategic patterns, consult `references/strategic-patterns.md`. |
||||
|
||||
## Tactical Design Quick Reference |
||||
|
||||
| Pattern | Purpose | Key Heuristic | |
||||
|---------|---------|---------------| |
||||
| **Entity** | Identity-tracked object | "Same identity = same thing" regardless of attributes | |
||||
| **Value Object** | Immutable, identity-less | Equality by value, always immutable, self-validating | |
||||
| **Aggregate** | Consistency boundary | Small aggregates, reference by ID, one transaction = one aggregate | |
||||
| **Domain Event** | Record state changes | Past tense naming, immutable, contains all relevant data | |
||||
| **Repository** | Collection abstraction | One per aggregate root, domain-focused interface | |
||||
| **Domain Service** | Stateless operations | When logic doesn't belong to any single entity | |
||||
| **Factory** | Complex object creation | When construction logic is complex or variable | |
||||
|
||||
For detailed tactical patterns, consult `references/tactical-patterns.md`. |
||||
|
||||
## Essential Heuristics |
||||
|
||||
### Aggregate Design Heuristics |
||||
|
||||
1. **Protect business invariants inside aggregate boundaries** - If two pieces of data must be consistent, they belong in the same aggregate |
||||
2. **Design small aggregates** - Large aggregates cause concurrency issues and slow performance |
||||
3. **Reference other aggregates by identity only** - Never hold direct object references across aggregate boundaries |
||||
4. **Update one aggregate per transaction** - Eventual consistency across aggregates using domain events |
||||
5. **Aggregate roots are the only entry point** - External code never reaches inside to manipulate child entities |
||||
|
||||
### Bounded Context Heuristics |
||||
|
||||
1. **Linguistic boundaries** - When the same word means different things, you have different contexts |
||||
2. **Team boundaries** - One context per team enables autonomy |
||||
3. **Process boundaries** - Different business processes often indicate different contexts |
||||
4. **Data ownership** - Each context owns its data; no shared databases |
||||
|
||||
### Modeling Heuristics |
||||
|
||||
1. **Nouns → Entities or Value Objects** - Things with identity become entities; descriptive things become value objects |
||||
2. **Verbs → Domain Services or Methods** - Actions become methods on entities or stateless services |
||||
3. **Business rules → Invariants** - Rules the domain must always satisfy become aggregate invariants |
||||
4. **Events in domain expert language → Domain Events** - "When X happens" becomes a domain event |
||||
|
||||
## Decision Guides |
||||
|
||||
### Entity vs Value Object |
||||
|
||||
``` |
||||
Does this thing have a lifecycle and identity that matters? |
||||
├─ YES → Is identity based on an ID (not attributes)? |
||||
│ ├─ YES → Entity |
||||
│ └─ NO → Reconsider; might be Value Object with natural key |
||||
└─ NO → Value Object |
||||
``` |
||||
|
||||
### Where Does This Logic Belong? |
||||
|
||||
``` |
||||
Is this logic stateless? |
||||
├─ NO → Does it belong to a single aggregate? |
||||
│ ├─ YES → Method on the aggregate/entity |
||||
│ └─ NO → Reconsider aggregate boundaries |
||||
└─ YES → Does it coordinate multiple aggregates? |
||||
├─ YES → Application Service |
||||
└─ NO → Does it represent a domain concept? |
||||
├─ YES → Domain Service |
||||
└─ NO → Infrastructure Service |
||||
``` |
||||
|
||||
### Should This Be a Separate Bounded Context? |
||||
|
||||
``` |
||||
Do different stakeholders use different language for this? |
||||
├─ YES → Separate bounded context |
||||
└─ NO → Does a different team own this? |
||||
├─ YES → Separate bounded context |
||||
└─ NO → Would a separate model reduce complexity? |
||||
├─ YES → Consider separation (but weigh integration cost) |
||||
└─ NO → Keep in current context |
||||
``` |
||||
|
||||
## Anti-Patterns Overview |
||||
|
||||
| Anti-Pattern | Description | Fix | |
||||
|--------------|-------------|-----| |
||||
| **Anemic Domain Model** | Entities with only getters/setters | Move behavior into domain objects | |
||||
| **Big Ball of Mud** | No clear boundaries | Identify bounded contexts | |
||||
| **Smart UI** | Business logic in presentation layer | Extract domain layer | |
||||
| **Database-Driven Design** | Model follows database schema | Model follows domain, map to database | |
||||
| **Leaky Abstractions** | Infrastructure concerns in domain | Dependency inversion, ports and adapters | |
||||
| **God Aggregate** | One aggregate does everything | Split by invariant boundaries | |
||||
| **Premature Abstraction** | Abstracting before understanding | Concrete first, abstract when patterns emerge | |
||||
|
||||
For detailed anti-patterns and remediation, consult `references/anti-patterns.md`. |
||||
|
||||
## Implementation Checklist |
||||
|
||||
When implementing DDD in a codebase: |
||||
|
||||
- [ ] Ubiquitous language documented and used consistently in code |
||||
- [ ] Bounded contexts identified with clear boundaries |
||||
- [ ] Context map documenting integration patterns |
||||
- [ ] Aggregates designed small with clear invariants |
||||
- [ ] Entities have behavior, not just data |
||||
- [ ] Value objects are immutable and self-validating |
||||
- [ ] Domain events capture important state changes |
||||
- [ ] Repositories abstract persistence for aggregate roots |
||||
- [ ] No business logic in application services (orchestration only) |
||||
- [ ] No infrastructure concerns in domain layer |
||||
|
||||
## Resources |
||||
|
||||
### references/ |
||||
|
||||
- `strategic-patterns.md` - Detailed strategic DDD patterns including bounded contexts, context maps, subdomain classification, and ubiquitous language |
||||
- `tactical-patterns.md` - Detailed tactical DDD patterns including entities, value objects, aggregates, domain events, repositories, and services |
||||
- `anti-patterns.md` - Common DDD anti-patterns, how to identify them, and remediation strategies |
||||
|
||||
To search references for specific topics: |
||||
- Bounded contexts: `grep -i "bounded context" references/` |
||||
- Aggregate design: `grep -i "aggregate" references/` |
||||
- Value objects: `grep -i "value object" references/` |
||||
@ -0,0 +1,853 @@
@@ -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 @@
@@ -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 @@
@@ -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; |
||||
} |
||||
} |
||||
``` |
||||
@ -0,0 +1,766 @@
@@ -0,0 +1,766 @@
|
||||
# Domain-Driven Design Analysis: ORLY Relay |
||||
|
||||
This document provides a comprehensive Domain-Driven Design (DDD) analysis of the ORLY Nostr relay codebase, evaluating its alignment with DDD principles and identifying opportunities for improvement. |
||||
|
||||
--- |
||||
|
||||
## Key Recommendations Summary |
||||
|
||||
| # | Recommendation | Impact | Effort | |
||||
|---|----------------|--------|--------| |
||||
| 1 | [Formalize Domain Events](#1-formalize-domain-events) | High | Medium | |
||||
| 2 | [Strengthen Aggregate Boundaries](#2-strengthen-aggregate-boundaries) | High | Medium | |
||||
| 3 | [Extract Application Services](#3-extract-application-services) | Medium | High | |
||||
| 4 | [Establish Ubiquitous Language Glossary](#4-establish-ubiquitous-language-glossary) | Medium | Low | |
||||
| 5 | [Add Domain-Specific Error Types](#5-add-domain-specific-error-types) | Medium | Low | |
||||
| 6 | [Enforce Value Object Immutability](#6-enforce-value-object-immutability) | Low | Low | |
||||
| 7 | [Document Context Map](#7-document-context-map) | Medium | Low | |
||||
|
||||
--- |
||||
|
||||
## Table of Contents |
||||
|
||||
1. [Executive Summary](#executive-summary) |
||||
2. [Strategic Design Analysis](#strategic-design-analysis) |
||||
- [Bounded Contexts](#bounded-contexts) |
||||
- [Context Map](#context-map) |
||||
- [Subdomain Classification](#subdomain-classification) |
||||
3. [Tactical Design Analysis](#tactical-design-analysis) |
||||
- [Entities](#entities) |
||||
- [Value Objects](#value-objects) |
||||
- [Aggregates](#aggregates) |
||||
- [Repositories](#repositories) |
||||
- [Domain Services](#domain-services) |
||||
- [Domain Events](#domain-events) |
||||
4. [Anti-Patterns Identified](#anti-patterns-identified) |
||||
5. [Detailed Recommendations](#detailed-recommendations) |
||||
6. [Implementation Checklist](#implementation-checklist) |
||||
7. [Appendix: File References](#appendix-file-references) |
||||
|
||||
--- |
||||
|
||||
## Executive Summary |
||||
|
||||
ORLY demonstrates **mature DDD adoption** for a system of its complexity. The codebase exhibits clear bounded context separation, proper repository patterns with multiple backend implementations, and well-designed interface segregation that prevents circular dependencies. |
||||
|
||||
**Strengths:** |
||||
- Clear separation between `app/` (application layer) and `pkg/` (domain/infrastructure) |
||||
- Repository pattern with three interchangeable backends (Badger, Neo4j, WasmDB) |
||||
- Interface-based ACL system with pluggable implementations |
||||
- Per-connection aggregate isolation in `Listener` |
||||
- Strong use of Go interfaces for dependency inversion |
||||
|
||||
**Areas for Improvement:** |
||||
- Domain events are implicit rather than explicit types |
||||
- Some aggregates expose mutable state via public fields |
||||
- Handler methods mix application orchestration with domain logic |
||||
- Ubiquitous language is partially documented |
||||
|
||||
**Overall DDD Maturity Score: 7/10** |
||||
|
||||
--- |
||||
|
||||
## Strategic Design Analysis |
||||
|
||||
### Bounded Contexts |
||||
|
||||
ORLY organizes code into distinct bounded contexts, each with its own model and language: |
||||
|
||||
#### 1. Event Storage Context (`pkg/database/`) |
||||
- **Responsibility:** Persistent storage of Nostr events |
||||
- **Key Abstractions:** `Database` interface, `Subscription`, `Payment` |
||||
- **Implementations:** Badger (embedded), Neo4j (graph), WasmDB (browser) |
||||
- **File:** `pkg/database/interface.go:17-109` |
||||
|
||||
#### 2. Access Control Context (`pkg/acl/`) |
||||
- **Responsibility:** Authorization decisions for read/write operations |
||||
- **Key Abstractions:** `I` interface, `Registry`, access levels |
||||
- **Implementations:** `None`, `Follows`, `Managed` |
||||
- **Files:** `pkg/acl/acl.go`, `pkg/interfaces/acl/acl.go:21-34` |
||||
|
||||
#### 3. Event Policy Context (`pkg/policy/`) |
||||
- **Responsibility:** Event filtering, validation, and rate limiting rules |
||||
- **Key Abstractions:** `Rule`, `Kinds`, `PolicyManager` |
||||
- **Invariants:** Whitelist/blacklist precedence, size limits, tag requirements |
||||
- **File:** `pkg/policy/policy.go:58-180` |
||||
|
||||
#### 4. Connection Management Context (`app/`) |
||||
- **Responsibility:** WebSocket lifecycle, message routing, authentication |
||||
- **Key Abstractions:** `Listener`, `Server`, message handlers |
||||
- **File:** `app/listener.go:24-52` |
||||
|
||||
#### 5. Protocol Extensions Context (`pkg/protocol/`) |
||||
- **Responsibility:** NIP implementations beyond core protocol |
||||
- **Subcontexts:** |
||||
- NIP-43 Membership (`pkg/protocol/nip43/`) |
||||
- Graph queries (`pkg/protocol/graph/`) |
||||
- NWC payments (`pkg/protocol/nwc/`) |
||||
- Sync/replication (`pkg/sync/`) |
||||
|
||||
#### 6. Rate Limiting Context (`pkg/ratelimit/`) |
||||
- **Responsibility:** Adaptive throttling based on system load |
||||
- **Key Abstractions:** `Limiter`, `Monitor`, PID controller |
||||
- **Integration:** Memory pressure from database backends |
||||
|
||||
### Context Map |
||||
|
||||
``` |
||||
┌─────────────────────────────────────────────────────────────────────────┐ |
||||
│ Connection Management (app/) │ |
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ |
||||
│ │ Server │───▶│ Listener │───▶│ Handlers │ │ |
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │ |
||||
└────────┬────────────────────┬────────────────────┬──────────────────────┘ |
||||
│ │ │ |
||||
│ [Conformist] │ [Customer-Supplier]│ [Customer-Supplier] |
||||
▼ ▼ ▼ |
||||
┌────────────────┐ ┌────────────────┐ ┌────────────────┐ |
||||
│ Access Control│ │ Event Storage │ │ Event Policy │ |
||||
│ (pkg/acl/) │ │ (pkg/database/)│ │ (pkg/policy/) │ |
||||
│ │ │ │ │ │ |
||||
│ Registry ◀────┼───┼────Conformist──┼───┼─▶ Manager │ |
||||
└────────────────┘ └────────────────┘ └────────────────┘ |
||||
│ │ │ |
||||
│ │ [Shared Kernel] │ |
||||
│ ▼ │ |
||||
│ ┌────────────────┐ │ |
||||
│ │ Event Entity │ │ |
||||
│ │(git.mleku.dev/ │◀───────────┘ |
||||
│ │ mleku/nostr) │ |
||||
│ └────────────────┘ |
||||
│ │ |
||||
│ [Anti-Corruption] │ [Customer-Supplier] |
||||
▼ ▼ |
||||
┌────────────────┐ ┌────────────────┐ |
||||
│ Rate Limiting │ │ Protocol │ |
||||
│ (pkg/ratelimit)│ │ Extensions │ |
||||
│ │ │ (pkg/protocol/)│ |
||||
└────────────────┘ └────────────────┘ |
||||
``` |
||||
|
||||
**Integration Patterns Identified:** |
||||
|
||||
| Upstream | Downstream | Pattern | Notes | |
||||
|----------|------------|---------|-------| |
||||
| nostr library | All contexts | Shared Kernel | Event, Filter, Tag types | |
||||
| Database | ACL, Policy | Customer-Supplier | Query for follow lists, permissions | |
||||
| Policy | Handlers | Conformist | Handlers respect policy decisions | |
||||
| ACL | Handlers | Conformist | Handlers respect access levels | |
||||
| Rate Limit | Database | Anti-Corruption | Load monitor abstraction | |
||||
|
||||
### Subdomain Classification |
||||
|
||||
| Subdomain | Type | Justification | |
||||
|-----------|------|---------------| |
||||
| Event Storage | **Core** | Central to relay's value proposition | |
||||
| Access Control | **Core** | Key differentiator (WoT, follows-based) | |
||||
| Event Policy | **Core** | Enables complex filtering rules | |
||||
| Connection Management | **Supporting** | Standard WebSocket infrastructure | |
||||
| Rate Limiting | **Supporting** | Operational concern, not domain-specific | |
||||
| NIP-43 Membership | **Core** | Unique invite-based access model | |
||||
| Sync/Replication | **Supporting** | Infrastructure for federation | |
||||
|
||||
--- |
||||
|
||||
## Tactical Design Analysis |
||||
|
||||
### Entities |
||||
|
||||
Entities are objects with identity that persists across state changes. |
||||
|
||||
#### Listener (Connection Entity) |
||||
```go |
||||
// app/listener.go:24-52 |
||||
type Listener struct { |
||||
conn *websocket.Conn // Identity: connection handle |
||||
challenge atomicutils.Bytes // Auth challenge state |
||||
authedPubkey atomicutils.Bytes // Authenticated identity |
||||
subscriptions map[string]context.CancelFunc |
||||
// ... more fields |
||||
} |
||||
``` |
||||
- **Identity:** WebSocket connection pointer |
||||
- **Lifecycle:** Created on connect, destroyed on disconnect |
||||
- **Invariants:** Only one authenticated pubkey per connection |
||||
|
||||
#### InviteCode (NIP-43 Entity) |
||||
```go |
||||
// pkg/protocol/nip43/types.go:26-31 |
||||
type InviteCode struct { |
||||
Code string // Identity: unique code |
||||
ExpiresAt time.Time |
||||
UsedBy []byte // Tracks consumption |
||||
CreatedAt time.Time |
||||
} |
||||
``` |
||||
- **Identity:** Unique code string |
||||
- **Lifecycle:** Created → Valid → Used/Expired |
||||
- **Invariants:** Cannot be reused once consumed |
||||
|
||||
#### Subscription (Payment Entity) |
||||
```go |
||||
// pkg/database/interface.go (implied by methods) |
||||
// GetSubscription, ExtendSubscription, RecordPayment |
||||
``` |
||||
- **Identity:** Pubkey |
||||
- **Lifecycle:** Trial → Active → Expired |
||||
- **Invariants:** Can only extend if not expired |
||||
|
||||
### Value Objects |
||||
|
||||
Value objects are immutable and defined by their attributes, not identity. |
||||
|
||||
#### IdPkTs (Event Reference) |
||||
```go |
||||
// pkg/interfaces/store/store_interface.go:63-68 |
||||
type IdPkTs struct { |
||||
Id []byte // Event ID |
||||
Pub []byte // Pubkey |
||||
Ts int64 // Timestamp |
||||
Ser uint64 // Serial number |
||||
} |
||||
``` |
||||
- **Equality:** By all fields |
||||
- **Issue:** Should be immutable but uses mutable slices |
||||
|
||||
#### Kinds (Policy Specification) |
||||
```go |
||||
// pkg/policy/policy.go:55-63 |
||||
type Kinds struct { |
||||
Whitelist []int `json:"whitelist,omitempty"` |
||||
Blacklist []int `json:"blacklist,omitempty"` |
||||
} |
||||
``` |
||||
- **Equality:** By whitelist/blacklist contents |
||||
- **Semantics:** Whitelist takes precedence over blacklist |
||||
|
||||
#### Rule (Policy Rule) |
||||
```go |
||||
// pkg/policy/policy.go:75-180 |
||||
type Rule struct { |
||||
Description string |
||||
WriteAllow []string |
||||
WriteDeny []string |
||||
MaxExpiry *int64 |
||||
SizeLimit *int64 |
||||
// ... 25+ fields |
||||
} |
||||
``` |
||||
- **Issue:** Very large, could benefit from decomposition |
||||
- **Binary caches:** Performance optimization for hex→binary conversion |
||||
|
||||
#### WriteRequest (Message Value) |
||||
```go |
||||
// pkg/protocol/publish/types.go (implied) |
||||
type WriteRequest struct { |
||||
Data []byte |
||||
MsgType int |
||||
IsControl bool |
||||
Deadline time.Time |
||||
} |
||||
``` |
||||
|
||||
### Aggregates |
||||
|
||||
Aggregates are clusters of entities/value objects with consistency boundaries. |
||||
|
||||
#### Listener Aggregate |
||||
- **Root:** `Listener` |
||||
- **Members:** Subscriptions map, auth state, write channel |
||||
- **Boundary:** Per-connection isolation |
||||
- **Invariants:** |
||||
- Subscriptions must exist before receiving matching events |
||||
- AUTH must complete before other messages check authentication |
||||
- Message processing is serialized within connection |
||||
|
||||
```go |
||||
// app/listener.go:226-238 - Aggregate consistency enforcement |
||||
l.authProcessing.Lock() |
||||
if isAuthMessage { |
||||
// Process AUTH synchronously while holding lock |
||||
l.HandleMessage(req.data, req.remote) |
||||
l.authProcessing.Unlock() |
||||
} else { |
||||
l.authProcessing.Unlock() |
||||
// Process concurrently |
||||
} |
||||
``` |
||||
|
||||
#### Event Aggregate (External) |
||||
- **Root:** `event.E` (from nostr library) |
||||
- **Members:** Tags, signature, content |
||||
- **Invariants:** |
||||
- ID must match computed hash |
||||
- Signature must be valid |
||||
- Timestamp must be within bounds |
||||
- **Validation:** `app/handle-event.go:348-390` |
||||
|
||||
#### InviteCode Aggregate |
||||
- **Root:** `InviteCode` |
||||
- **Members:** Code, expiry, usage tracking |
||||
- **Invariants:** |
||||
- Code uniqueness |
||||
- Single-use enforcement |
||||
- Expiry validation |
||||
|
||||
### Repositories |
||||
|
||||
The Repository pattern abstracts persistence for aggregate roots. |
||||
|
||||
#### Database Interface (Primary Repository) |
||||
```go |
||||
// pkg/database/interface.go:17-109 |
||||
type Database interface { |
||||
// Event persistence |
||||
SaveEvent(c context.Context, ev *event.E) (exists bool, err error) |
||||
QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) |
||||
DeleteEvent(c context.Context, eid []byte) error |
||||
|
||||
// Subscription management |
||||
GetSubscription(pubkey []byte) (*Subscription, error) |
||||
ExtendSubscription(pubkey []byte, days int) error |
||||
|
||||
// NIP-43 membership |
||||
AddNIP43Member(pubkey []byte, inviteCode string) error |
||||
IsNIP43Member(pubkey []byte) (isMember bool, err error) |
||||
|
||||
// ... 50+ methods |
||||
} |
||||
``` |
||||
|
||||
**Repository Implementations:** |
||||
1. **Badger** (`pkg/database/database.go`): Embedded key-value store |
||||
2. **Neo4j** (`pkg/neo4j/`): Graph database for social queries |
||||
3. **WasmDB** (`pkg/wasmdb/`): Browser IndexedDB for WASM builds |
||||
|
||||
**Interface Segregation:** |
||||
```go |
||||
// pkg/interfaces/store/store_interface.go:21-37 |
||||
type I interface { |
||||
Pather |
||||
io.Closer |
||||
Wiper |
||||
Querier // QueryForIds |
||||
Querent // QueryEvents |
||||
Deleter // DeleteEvent |
||||
Saver // SaveEvent |
||||
Importer |
||||
Exporter |
||||
Syncer |
||||
// ... |
||||
} |
||||
``` |
||||
|
||||
### Domain Services |
||||
|
||||
Domain services encapsulate logic that doesn't belong to any single entity. |
||||
|
||||
#### ACL Registry (Access Decision Service) |
||||
```go |
||||
// pkg/acl/acl.go:40-48 |
||||
func (s *S) GetAccessLevel(pub []byte, address string) (level string) { |
||||
for _, i := range s.ACL { |
||||
if i.Type() == s.Active.Load() { |
||||
level = i.GetAccessLevel(pub, address) |
||||
break |
||||
} |
||||
} |
||||
return |
||||
} |
||||
``` |
||||
- Delegates to active ACL implementation |
||||
- Stateless decision based on pubkey and IP |
||||
|
||||
#### Policy Manager (Event Validation Service) |
||||
```go |
||||
// pkg/policy/policy.go (P type, CheckPolicy method) |
||||
// Evaluates rule chains, scripts, whitelist/blacklist logic |
||||
``` |
||||
- Complex rule evaluation logic |
||||
- Script execution for custom validation |
||||
|
||||
#### InviteManager (Invite Lifecycle Service) |
||||
```go |
||||
// pkg/protocol/nip43/types.go:34-109 |
||||
type InviteManager struct { |
||||
codes map[string]*InviteCode |
||||
expiry time.Duration |
||||
} |
||||
func (im *InviteManager) GenerateCode() (code string, err error) |
||||
func (im *InviteManager) ValidateAndConsume(code string, pubkey []byte) (bool, string) |
||||
``` |
||||
- Manages invite code lifecycle |
||||
- Thread-safe with mutex protection |
||||
|
||||
### Domain Events |
||||
|
||||
**Current State:** Domain events are implicit in message flow, not explicit types. |
||||
|
||||
**Implicit Events Identified:** |
||||
|
||||
| Event | Trigger | Effect | |
||||
|-------|---------|--------| |
||||
| EventPublished | `SaveEvent()` success | `publishers.Deliver()` | |
||||
| EventDeleted | Kind 5 processing | Cascade delete targets | |
||||
| UserAuthenticated | AUTH envelope accepted | `authedPubkey` set | |
||||
| SubscriptionCreated | REQ envelope | Query + stream setup | |
||||
| MembershipAdded | NIP-43 join request | ACL update | |
||||
| PolicyUpdated | Policy config event | `messagePauseMutex.Lock()` | |
||||
|
||||
--- |
||||
|
||||
## Anti-Patterns Identified |
||||
|
||||
### 1. Large Handler Methods (Partial Anemic Domain Model) |
||||
|
||||
**Location:** `app/handle-event.go:183-783` (600+ lines) |
||||
|
||||
**Issue:** The `HandleEvent` method contains: |
||||
- Input validation |
||||
- Policy checking |
||||
- ACL verification |
||||
- Signature verification |
||||
- Persistence |
||||
- Event delivery |
||||
- Special case handling (delete, ephemeral, NIP-43) |
||||
|
||||
**Impact:** Difficult to test, maintain, and understand. Business rules are embedded in orchestration code. |
||||
|
||||
### 2. Mutable Value Object Fields |
||||
|
||||
**Location:** `pkg/interfaces/store/store_interface.go:63-68` |
||||
|
||||
```go |
||||
type IdPkTs struct { |
||||
Id []byte // Mutable slice |
||||
Pub []byte // Mutable slice |
||||
Ts int64 |
||||
Ser uint64 |
||||
} |
||||
``` |
||||
|
||||
**Impact:** Value objects should be immutable. Callers could accidentally mutate shared state. |
||||
|
||||
### 3. Global Singleton Registry |
||||
|
||||
**Location:** `pkg/acl/acl.go:10` |
||||
|
||||
```go |
||||
var Registry = &S{} |
||||
``` |
||||
|
||||
**Impact:** Global state makes testing difficult and hides dependencies. Should be injected. |
||||
|
||||
### 4. Missing Domain Events |
||||
|
||||
**Impact:** Side effects are coupled to primary operations. Adding new behaviors (logging, analytics, notifications) requires modifying core handlers. |
||||
|
||||
### 5. Oversized Rule Value Object |
||||
|
||||
**Location:** `pkg/policy/policy.go:75-180` |
||||
|
||||
The `Rule` struct has 25+ fields, suggesting it might benefit from decomposition into smaller, focused value objects: |
||||
- `AccessRule` (allow/deny lists) |
||||
- `SizeRule` (limits) |
||||
- `TimeRule` (expiry, age) |
||||
- `ValidationRule` (tags, regex) |
||||
|
||||
--- |
||||
|
||||
## Detailed Recommendations |
||||
|
||||
### 1. Formalize Domain Events |
||||
|
||||
**Problem:** Side effects are tightly coupled to primary operations. |
||||
|
||||
**Solution:** Create explicit domain event types and a simple event dispatcher. |
||||
|
||||
```go |
||||
// pkg/domain/events/events.go |
||||
package events |
||||
|
||||
type DomainEvent interface { |
||||
OccurredAt() time.Time |
||||
AggregateID() []byte |
||||
} |
||||
|
||||
type EventPublished struct { |
||||
EventID []byte |
||||
Pubkey []byte |
||||
Kind int |
||||
Timestamp time.Time |
||||
} |
||||
|
||||
type MembershipGranted struct { |
||||
Pubkey []byte |
||||
InviteCode string |
||||
Timestamp time.Time |
||||
} |
||||
|
||||
// Simple dispatcher |
||||
type Dispatcher struct { |
||||
handlers map[reflect.Type][]func(DomainEvent) |
||||
} |
||||
``` |
||||
|
||||
**Benefits:** |
||||
- Decoupled side effects |
||||
- Easier testing |
||||
- Audit trail capability |
||||
- Foundation for event sourcing if needed |
||||
|
||||
**Files to Modify:** |
||||
- Create `pkg/domain/events/` |
||||
- Update `app/handle-event.go` to emit events |
||||
- Update `app/handle-nip43.go` for membership events |
||||
|
||||
### 2. Strengthen Aggregate Boundaries |
||||
|
||||
**Problem:** Aggregate internals are exposed via public fields. |
||||
|
||||
**Solution:** Use unexported fields with behavior methods. |
||||
|
||||
```go |
||||
// Before (current) |
||||
type Listener struct { |
||||
authedPubkey atomicutils.Bytes // Accessible from outside |
||||
} |
||||
|
||||
// After (recommended) |
||||
type Listener struct { |
||||
authedPubkey atomicutils.Bytes // Keep as is (already using atomic wrapper) |
||||
} |
||||
|
||||
// Add behavior methods |
||||
func (l *Listener) IsAuthenticated() bool { |
||||
return len(l.authedPubkey.Load()) > 0 |
||||
} |
||||
|
||||
func (l *Listener) AuthenticatedPubkey() []byte { |
||||
return l.authedPubkey.Load() |
||||
} |
||||
|
||||
func (l *Listener) Authenticate(pubkey []byte) error { |
||||
if l.IsAuthenticated() { |
||||
return ErrAlreadyAuthenticated |
||||
} |
||||
l.authedPubkey.Store(pubkey) |
||||
return nil |
||||
} |
||||
``` |
||||
|
||||
**Benefits:** |
||||
- Enforces invariants |
||||
- Clear API surface |
||||
- Easier refactoring |
||||
|
||||
### 3. Extract Application Services |
||||
|
||||
**Problem:** Handler methods contain mixed concerns. |
||||
|
||||
**Solution:** Extract domain logic into focused application services. |
||||
|
||||
```go |
||||
// pkg/application/event_service.go |
||||
type EventService struct { |
||||
db database.Database |
||||
policyMgr *policy.P |
||||
aclRegistry *acl.S |
||||
eventPublisher EventPublisher |
||||
} |
||||
|
||||
func (s *EventService) ProcessIncomingEvent(ctx context.Context, ev *event.E, authedPubkey []byte) (*EventResult, error) { |
||||
// 1. Validate event structure |
||||
if err := s.validateEventStructure(ev); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// 2. Check policy |
||||
if !s.policyMgr.IsAllowed("write", ev, authedPubkey) { |
||||
return &EventResult{Blocked: true, Reason: "policy"}, nil |
||||
} |
||||
|
||||
// 3. Check ACL |
||||
if !s.aclRegistry.CanWrite(authedPubkey) { |
||||
return &EventResult{Blocked: true, Reason: "acl"}, nil |
||||
} |
||||
|
||||
// 4. Persist |
||||
exists, err := s.db.SaveEvent(ctx, ev) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// 5. Publish domain event |
||||
s.eventPublisher.Publish(events.EventPublished{...}) |
||||
|
||||
return &EventResult{Saved: !exists}, nil |
||||
} |
||||
``` |
||||
|
||||
**Benefits:** |
||||
- Testable business logic |
||||
- Handlers become thin orchestrators |
||||
- Reusable across different entry points (WebSocket, HTTP API, CLI) |
||||
|
||||
### 4. Establish Ubiquitous Language Glossary |
||||
|
||||
**Problem:** Terminology is inconsistent across the codebase. |
||||
|
||||
**Current Inconsistencies:** |
||||
- "subscription" (payment) vs "subscription" (REQ filter) |
||||
- "monitor" (rate limit) vs "spider" (sync) |
||||
- "pub" vs "pubkey" vs "author" |
||||
|
||||
**Solution:** Add a `GLOSSARY.md` and enforce terms in code reviews. |
||||
|
||||
```markdown |
||||
# ORLY Ubiquitous Language |
||||
|
||||
## Core Domain Terms |
||||
|
||||
| Term | Definition | Code Symbol | |
||||
|------|------------|-------------| |
||||
| Event | A signed Nostr message | `event.E` | |
||||
| Relay | This server | `Server` | |
||||
| Connection | WebSocket session | `Listener` | |
||||
| Filter | Query criteria for events | `filter.F` | |
||||
| **Event Subscription** | Active filter receiving events | `subscriptions map` | |
||||
| **Payment Subscription** | Paid access tier | `database.Subscription` | |
||||
| Access Level | Permission tier (none/read/write/admin/owner) | `acl.Level` | |
||||
| Policy | Event validation rules | `policy.Rule` | |
||||
``` |
||||
|
||||
### 5. Add Domain-Specific Error Types |
||||
|
||||
**Problem:** Errors are strings or generic types, making error handling imprecise. |
||||
|
||||
**Solution:** Create typed domain errors. |
||||
|
||||
```go |
||||
// pkg/domain/errors/errors.go |
||||
package errors |
||||
|
||||
type DomainError struct { |
||||
Code string |
||||
Message string |
||||
Cause error |
||||
} |
||||
|
||||
var ( |
||||
ErrEventInvalid = &DomainError{Code: "EVENT_INVALID"} |
||||
ErrEventBlocked = &DomainError{Code: "EVENT_BLOCKED"} |
||||
ErrAuthRequired = &DomainError{Code: "AUTH_REQUIRED"} |
||||
ErrQuotaExceeded = &DomainError{Code: "QUOTA_EXCEEDED"} |
||||
ErrInviteCodeInvalid = &DomainError{Code: "INVITE_INVALID"} |
||||
ErrInviteCodeExpired = &DomainError{Code: "INVITE_EXPIRED"} |
||||
ErrInviteCodeUsed = &DomainError{Code: "INVITE_USED"} |
||||
) |
||||
``` |
||||
|
||||
**Benefits:** |
||||
- Precise error handling in handlers |
||||
- Better error messages to clients |
||||
- Easier testing |
||||
|
||||
### 6. Enforce Value Object Immutability |
||||
|
||||
**Problem:** Value objects use mutable slices. |
||||
|
||||
**Solution:** Return copies from accessors. |
||||
|
||||
```go |
||||
// pkg/interfaces/store/store_interface.go |
||||
type IdPkTs struct { |
||||
id []byte // unexported |
||||
pub []byte // unexported |
||||
ts int64 |
||||
ser uint64 |
||||
} |
||||
|
||||
func NewIdPkTs(id, pub []byte, ts int64, ser uint64) *IdPkTs { |
||||
return &IdPkTs{ |
||||
id: append([]byte(nil), id...), // Copy |
||||
pub: append([]byte(nil), pub...), // Copy |
||||
ts: ts, |
||||
ser: ser, |
||||
} |
||||
} |
||||
|
||||
func (i *IdPkTs) ID() []byte { return append([]byte(nil), i.id...) } |
||||
func (i *IdPkTs) Pub() []byte { return append([]byte(nil), i.pub...) } |
||||
func (i *IdPkTs) Ts() int64 { return i.ts } |
||||
func (i *IdPkTs) Ser() uint64 { return i.ser } |
||||
``` |
||||
|
||||
### 7. Document Context Map |
||||
|
||||
**Problem:** Context relationships are implicit. |
||||
|
||||
**Solution:** Add a `CONTEXT_MAP.md` documenting boundaries and integration patterns. |
||||
|
||||
The diagram in the [Context Map](#context-map) section above should be maintained as living documentation. |
||||
|
||||
--- |
||||
|
||||
## Implementation Checklist |
||||
|
||||
### Currently Satisfied |
||||
|
||||
- [x] Bounded contexts identified with clear boundaries |
||||
- [x] Repositories abstract persistence for aggregate roots |
||||
- [x] Multiple repository implementations (Badger/Neo4j/WasmDB) |
||||
- [x] Interface segregation prevents circular dependencies |
||||
- [x] Configuration centralized (`app/config/config.go`) |
||||
- [x] Per-connection aggregate isolation |
||||
- [x] Access control as pluggable strategy pattern |
||||
|
||||
### Needs Attention |
||||
|
||||
- [ ] Ubiquitous language documented and used consistently |
||||
- [ ] Context map documenting integration patterns |
||||
- [ ] Domain events capture important state changes |
||||
- [ ] Entities have behavior, not just data |
||||
- [ ] Value objects are fully immutable |
||||
- [ ] No business logic in application services (orchestration only) |
||||
- [ ] No infrastructure concerns in domain layer |
||||
|
||||
--- |
||||
|
||||
## Appendix: File References |
||||
|
||||
### Core Domain Files |
||||
|
||||
| File | Purpose | |
||||
|------|---------| |
||||
| `pkg/database/interface.go` | Repository interface (50+ methods) | |
||||
| `pkg/interfaces/acl/acl.go` | ACL interface definition | |
||||
| `pkg/interfaces/store/store_interface.go` | Store sub-interfaces | |
||||
| `pkg/policy/policy.go` | Policy rules and evaluation | |
||||
| `pkg/protocol/nip43/types.go` | NIP-43 invite management | |
||||
|
||||
### Application Layer Files |
||||
|
||||
| File | Purpose | |
||||
|------|---------| |
||||
| `app/server.go` | HTTP/WebSocket server setup | |
||||
| `app/listener.go` | Connection aggregate | |
||||
| `app/handle-event.go` | EVENT message handler | |
||||
| `app/handle-req.go` | REQ message handler | |
||||
| `app/handle-auth.go` | AUTH message handler | |
||||
| `app/handle-nip43.go` | NIP-43 membership handlers | |
||||
|
||||
### Infrastructure Files |
||||
|
||||
| File | Purpose | |
||||
|------|---------| |
||||
| `pkg/database/database.go` | Badger implementation | |
||||
| `pkg/neo4j/` | Neo4j implementation | |
||||
| `pkg/wasmdb/` | WasmDB implementation | |
||||
| `pkg/ratelimit/limiter.go` | Rate limiting | |
||||
| `pkg/sync/manager.go` | Distributed sync | |
||||
|
||||
--- |
||||
|
||||
*Generated: 2025-12-23* |
||||
*Analysis based on ORLY codebase v0.36.10* |
||||
@ -0,0 +1,964 @@
@@ -0,0 +1,964 @@
|
||||
<script> |
||||
import { createEventDispatcher, onMount } from "svelte"; |
||||
|
||||
export let isLoggedIn = false; |
||||
export let userPubkey = ""; |
||||
export let userSigner = null; |
||||
export let currentEffectiveRole = ""; |
||||
|
||||
const dispatch = createEventDispatcher(); |
||||
|
||||
let blobs = []; |
||||
let isLoading = false; |
||||
let error = ""; |
||||
|
||||
// Upload state |
||||
let selectedFiles = []; |
||||
let isUploading = false; |
||||
let uploadProgress = ""; |
||||
let fileInput; |
||||
|
||||
// Modal state |
||||
let showModal = false; |
||||
let selectedBlob = null; |
||||
let zoomLevel = 1; |
||||
const MIN_ZOOM = 0.25; |
||||
const MAX_ZOOM = 4; |
||||
const ZOOM_STEP = 0.25; |
||||
|
||||
$: canAccess = isLoggedIn && userPubkey; |
||||
|
||||
// Track if we've loaded once to prevent repeated loads |
||||
let hasLoadedOnce = false; |
||||
|
||||
/** |
||||
* Create Blossom auth header (kind 24242) per BUD-01 spec |
||||
* @param {object} signer - The signer instance |
||||
* @param {string} verb - The action verb (list, get, upload, delete) |
||||
* @param {string} sha256Hex - Optional SHA256 hash for x tag |
||||
* @returns {Promise<string|null>} Base64 encoded auth header or null |
||||
*/ |
||||
async function createBlossomAuth(signer, verb, sha256Hex = null) { |
||||
if (!signer) { |
||||
console.log("No signer available for Blossom auth"); |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
const now = Math.floor(Date.now() / 1000); |
||||
const expiration = now + 60; // 60 seconds from now |
||||
|
||||
const tags = [ |
||||
["t", verb], |
||||
["expiration", expiration.toString()], |
||||
]; |
||||
|
||||
// Add x tag for blob-specific operations |
||||
if (sha256Hex) { |
||||
tags.push(["x", sha256Hex]); |
||||
} |
||||
|
||||
const authEvent = { |
||||
kind: 24242, |
||||
created_at: now, |
||||
tags: tags, |
||||
content: `Blossom ${verb} operation`, |
||||
}; |
||||
|
||||
const signedEvent = await signer.signEvent(authEvent); |
||||
return btoa(JSON.stringify(signedEvent)); |
||||
} catch (err) { |
||||
console.error("Error creating Blossom auth:", err); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
onMount(() => { |
||||
if (canAccess && !hasLoadedOnce) { |
||||
hasLoadedOnce = true; |
||||
loadBlobs(); |
||||
} |
||||
}); |
||||
|
||||
// Load once when canAccess becomes true (for when user logs in after mount) |
||||
$: if (canAccess && !hasLoadedOnce && !isLoading) { |
||||
hasLoadedOnce = true; |
||||
loadBlobs(); |
||||
} |
||||
|
||||
async function loadBlobs() { |
||||
if (!userPubkey) return; |
||||
|
||||
isLoading = true; |
||||
error = ""; |
||||
|
||||
try { |
||||
const url = `${window.location.origin}/blossom/list/${userPubkey}`; |
||||
const authHeader = await createBlossomAuth(userSigner, "list"); |
||||
const response = await fetch(url, { |
||||
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`Failed to load blobs: ${response.statusText}`); |
||||
} |
||||
|
||||
const data = await response.json(); |
||||
// API returns 'uploaded' timestamp per BUD-02 spec |
||||
blobs = Array.isArray(data) ? data : []; |
||||
blobs.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0)); |
||||
console.log("Loaded blobs:", blobs); |
||||
} catch (err) { |
||||
console.error("Error loading blobs:", err); |
||||
error = err.message || "Failed to load blobs"; |
||||
} finally { |
||||
isLoading = false; |
||||
} |
||||
} |
||||
|
||||
function formatSize(bytes) { |
||||
if (!bytes) return "0 B"; |
||||
const units = ["B", "KB", "MB", "GB"]; |
||||
let i = 0; |
||||
let size = bytes; |
||||
while (size >= 1024 && i < units.length - 1) { |
||||
size /= 1024; |
||||
i++; |
||||
} |
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; |
||||
} |
||||
|
||||
function formatDate(timestamp) { |
||||
if (!timestamp) return "Unknown"; |
||||
return new Date(timestamp * 1000).toLocaleString(); |
||||
} |
||||
|
||||
function truncateHash(hash) { |
||||
if (!hash) return ""; |
||||
return `${hash.slice(0, 8)}...${hash.slice(-8)}`; |
||||
} |
||||
|
||||
function getMimeCategory(mimeType) { |
||||
if (!mimeType) return "unknown"; |
||||
if (mimeType.startsWith("image/")) return "image"; |
||||
if (mimeType.startsWith("video/")) return "video"; |
||||
if (mimeType.startsWith("audio/")) return "audio"; |
||||
return "file"; |
||||
} |
||||
|
||||
function getMimeIcon(mimeType) { |
||||
const category = getMimeCategory(mimeType); |
||||
switch (category) { |
||||
case "image": return ""; |
||||
case "video": return ""; |
||||
case "audio": return ""; |
||||
default: return ""; |
||||
} |
||||
} |
||||
|
||||
function openModal(blob) { |
||||
selectedBlob = blob; |
||||
zoomLevel = 1; |
||||
showModal = true; |
||||
} |
||||
|
||||
function closeModal() { |
||||
showModal = false; |
||||
selectedBlob = null; |
||||
zoomLevel = 1; |
||||
} |
||||
|
||||
function zoomIn() { |
||||
if (zoomLevel < MAX_ZOOM) { |
||||
zoomLevel = Math.min(MAX_ZOOM, zoomLevel + ZOOM_STEP); |
||||
} |
||||
} |
||||
|
||||
function zoomOut() { |
||||
if (zoomLevel > MIN_ZOOM) { |
||||
zoomLevel = Math.max(MIN_ZOOM, zoomLevel - ZOOM_STEP); |
||||
} |
||||
} |
||||
|
||||
function handleKeydown(event) { |
||||
if (!showModal) return; |
||||
if (event.key === "Escape") { |
||||
closeModal(); |
||||
} else if (event.key === "+" || event.key === "=") { |
||||
zoomIn(); |
||||
} else if (event.key === "-") { |
||||
zoomOut(); |
||||
} |
||||
} |
||||
|
||||
function getBlobUrl(blob) { |
||||
// Prefer the URL from the API response (includes extension for proper MIME handling) |
||||
if (blob.url) { |
||||
// Already an absolute URL - return as-is |
||||
if (blob.url.startsWith("http://") || blob.url.startsWith("https://")) { |
||||
return blob.url; |
||||
} |
||||
// Starts with / - it's a path, prepend origin |
||||
if (blob.url.startsWith("/")) { |
||||
return `${window.location.origin}${blob.url}`; |
||||
} |
||||
// No protocol - looks like host:port/path, add http:// |
||||
// This handles cases like "localhost:3334/blossom/..." |
||||
return `http://${blob.url}`; |
||||
} |
||||
// Fallback: construct URL with sha256 only |
||||
return `${window.location.origin}/blossom/${blob.sha256}`; |
||||
} |
||||
|
||||
function openLoginModal() { |
||||
dispatch("openLoginModal"); |
||||
} |
||||
|
||||
async function deleteBlob(blob) { |
||||
if (!confirm(`Delete blob ${truncateHash(blob.sha256)}?`)) return; |
||||
|
||||
try { |
||||
const url = `${window.location.origin}/blossom/${blob.sha256}`; |
||||
const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256); |
||||
const response = await fetch(url, { |
||||
method: "DELETE", |
||||
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`Failed to delete: ${response.statusText}`); |
||||
} |
||||
|
||||
blobs = blobs.filter(b => b.sha256 !== blob.sha256); |
||||
if (selectedBlob?.sha256 === blob.sha256) { |
||||
closeModal(); |
||||
} |
||||
} catch (err) { |
||||
console.error("Error deleting blob:", err); |
||||
alert(`Failed to delete blob: ${err.message}`); |
||||
} |
||||
} |
||||
|
||||
function handleFileSelect(event) { |
||||
selectedFiles = Array.from(event.target.files); |
||||
} |
||||
|
||||
function triggerFileInput() { |
||||
fileInput?.click(); |
||||
} |
||||
|
||||
async function uploadFiles() { |
||||
if (selectedFiles.length === 0) return; |
||||
|
||||
isUploading = true; |
||||
error = ""; |
||||
const uploaded = []; |
||||
const failed = []; |
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) { |
||||
const file = selectedFiles[i]; |
||||
uploadProgress = `Uploading ${i + 1}/${selectedFiles.length}: ${file.name}`; |
||||
|
||||
try { |
||||
const url = `${window.location.origin}/blossom/upload`; |
||||
const authHeader = await createBlossomAuth(userSigner, "upload"); |
||||
|
||||
const response = await fetch(url, { |
||||
method: "PUT", |
||||
headers: { |
||||
"Content-Type": file.type || "application/octet-stream", |
||||
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}), |
||||
}, |
||||
body: file, |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
const reason = response.headers.get("X-Reason") || response.statusText; |
||||
throw new Error(reason); |
||||
} |
||||
|
||||
const descriptor = await response.json(); |
||||
console.log("Upload response:", descriptor); |
||||
uploaded.push(descriptor); |
||||
} catch (err) { |
||||
console.error(`Error uploading ${file.name}:`, err); |
||||
failed.push({ name: file.name, error: err.message }); |
||||
} |
||||
} |
||||
|
||||
isUploading = false; |
||||
uploadProgress = ""; |
||||
selectedFiles = []; |
||||
if (fileInput) fileInput.value = ""; |
||||
|
||||
if (uploaded.length > 0) { |
||||
await loadBlobs(); |
||||
} |
||||
|
||||
if (failed.length > 0) { |
||||
error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<svelte:window on:keydown={handleKeydown} /> |
||||
|
||||
{#if canAccess} |
||||
<div class="blossom-view"> |
||||
<div class="header-section"> |
||||
<h3>Blossom Media Storage</h3> |
||||
<button class="refresh-btn" on:click={loadBlobs} disabled={isLoading}> |
||||
{isLoading ? "Loading..." : "Refresh"} |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="upload-section"> |
||||
<input |
||||
type="file" |
||||
multiple |
||||
bind:this={fileInput} |
||||
on:change={handleFileSelect} |
||||
class="file-input-hidden" |
||||
/> |
||||
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}> |
||||
Select Files |
||||
</button> |
||||
{#if selectedFiles.length > 0} |
||||
<span class="selected-count">{selectedFiles.length} file(s) selected</span> |
||||
<button |
||||
class="upload-btn" |
||||
on:click={uploadFiles} |
||||
disabled={isUploading} |
||||
> |
||||
{isUploading ? uploadProgress : "Upload"} |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error-message"> |
||||
{error} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if isLoading && blobs.length === 0} |
||||
<div class="loading">Loading blobs...</div> |
||||
{:else if blobs.length === 0} |
||||
<div class="empty-state"> |
||||
<p>No files found in your Blossom storage.</p> |
||||
</div> |
||||
{:else} |
||||
<div class="blob-list"> |
||||
{#each blobs as blob} |
||||
<div |
||||
class="blob-item" |
||||
on:click={() => openModal(blob)} |
||||
on:keypress={(e) => e.key === "Enter" && openModal(blob)} |
||||
role="button" |
||||
tabindex="0" |
||||
> |
||||
<div class="blob-icon"> |
||||
{getMimeIcon(blob.type)} |
||||
</div> |
||||
<div class="blob-info"> |
||||
<div class="blob-hash" title={blob.sha256}> |
||||
{truncateHash(blob.sha256)} |
||||
</div> |
||||
<div class="blob-meta"> |
||||
<span class="blob-size">{formatSize(blob.size)}</span> |
||||
<span class="blob-type">{blob.type || "unknown"}</span> |
||||
</div> |
||||
</div> |
||||
<div class="blob-date"> |
||||
{formatDate(blob.uploaded)} |
||||
</div> |
||||
<button |
||||
class="delete-btn" |
||||
on:click|stopPropagation={() => deleteBlob(blob)} |
||||
title="Delete" |
||||
> |
||||
X |
||||
</button> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<div class="login-prompt"> |
||||
<p>Please log in to view your Blossom storage.</p> |
||||
<button class="login-btn" on:click={openLoginModal}>Log In</button> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if showModal && selectedBlob} |
||||
<div |
||||
class="modal-overlay" |
||||
on:click={closeModal} |
||||
on:keypress={(e) => e.key === "Enter" && closeModal()} |
||||
role="button" |
||||
tabindex="0" |
||||
> |
||||
<div |
||||
class="modal-content" |
||||
on:click|stopPropagation |
||||
on:keypress|stopPropagation |
||||
role="dialog" |
||||
> |
||||
<div class="modal-header"> |
||||
<div class="modal-title"> |
||||
<span class="modal-hash">{truncateHash(selectedBlob.sha256)}</span> |
||||
<span class="modal-type">{selectedBlob.type || "unknown"}</span> |
||||
</div> |
||||
<div class="modal-controls"> |
||||
{#if getMimeCategory(selectedBlob.type) === "image"} |
||||
<button class="zoom-btn" on:click={zoomOut} disabled={zoomLevel <= MIN_ZOOM}>-</button> |
||||
<span class="zoom-level">{Math.round(zoomLevel * 100)}%</span> |
||||
<button class="zoom-btn" on:click={zoomIn} disabled={zoomLevel >= MAX_ZOOM}>+</button> |
||||
{/if} |
||||
<button class="close-btn" on:click={closeModal}>X</button> |
||||
</div> |
||||
</div> |
||||
<div class="modal-body"> |
||||
{#if getMimeCategory(selectedBlob.type) === "image"} |
||||
<div class="media-container" style="transform: scale({zoomLevel});"> |
||||
<img src={getBlobUrl(selectedBlob)} alt="Blob content" /> |
||||
</div> |
||||
{:else if getMimeCategory(selectedBlob.type) === "video"} |
||||
<div class="media-container"> |
||||
<video controls src={getBlobUrl(selectedBlob)}> |
||||
<track kind="captions" /> |
||||
</video> |
||||
</div> |
||||
{:else if getMimeCategory(selectedBlob.type) === "audio"} |
||||
<div class="media-container audio"> |
||||
<audio controls src={getBlobUrl(selectedBlob)}></audio> |
||||
</div> |
||||
{:else} |
||||
<div class="file-preview"> |
||||
<div class="file-icon">{getMimeIcon(selectedBlob.type)}</div> |
||||
<p>Preview not available for this file type.</p> |
||||
<a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="download-link"> |
||||
Download File |
||||
</a> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
<div class="modal-footer"> |
||||
<div class="blob-details"> |
||||
<span>Size: {formatSize(selectedBlob.size)}</span> |
||||
<span>Uploaded: {formatDate(selectedBlob.uploaded)}</span> |
||||
</div> |
||||
<div class="blob-url-section"> |
||||
<input |
||||
type="text" |
||||
readonly |
||||
value={getBlobUrl(selectedBlob)} |
||||
class="blob-url-input" |
||||
on:click={(e) => e.target.select()} |
||||
/> |
||||
<button |
||||
class="copy-btn" |
||||
on:click={() => { |
||||
navigator.clipboard.writeText(getBlobUrl(selectedBlob)); |
||||
}} |
||||
> |
||||
Copy |
||||
</button> |
||||
</div> |
||||
<div class="modal-actions"> |
||||
<a href={getBlobUrl(selectedBlob)} target="_blank" rel="noopener noreferrer" class="action-btn"> |
||||
Open in New Tab |
||||
</a> |
||||
<button class="action-btn danger" on:click={() => deleteBlob(selectedBlob)}> |
||||
Delete |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.blossom-view { |
||||
padding: 1em; |
||||
max-width: 900px; |
||||
} |
||||
|
||||
.header-section { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
.header-section h3 { |
||||
margin: 0; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.refresh-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.refresh-btn:hover:not(:disabled) { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.refresh-btn:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.upload-section { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.75em; |
||||
padding: 0.75em 1em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 6px; |
||||
margin-bottom: 1em; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.file-input-hidden { |
||||
display: none; |
||||
} |
||||
|
||||
.select-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.select-btn:hover:not(:disabled) { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.select-btn:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.selected-count { |
||||
color: var(--text-color); |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.upload-btn { |
||||
background-color: var(--success, #28a745); |
||||
color: white; |
||||
border: none; |
||||
padding: 0.5em 1em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.9em; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.upload-btn:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.upload-btn:disabled { |
||||
opacity: 0.7; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.error-message { |
||||
background-color: var(--warning); |
||||
color: var(--text-color); |
||||
padding: 0.75em 1em; |
||||
border-radius: 4px; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
.loading, .empty-state { |
||||
text-align: center; |
||||
padding: 2em; |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.blob-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5em; |
||||
} |
||||
|
||||
.blob-item { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 1em; |
||||
padding: 0.75em 1em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 6px; |
||||
cursor: pointer; |
||||
transition: background-color 0.2s; |
||||
} |
||||
|
||||
.blob-item:hover { |
||||
background-color: var(--sidebar-bg); |
||||
} |
||||
|
||||
.blob-icon { |
||||
font-size: 1.5em; |
||||
width: 2em; |
||||
text-align: center; |
||||
} |
||||
|
||||
.blob-info { |
||||
flex: 1; |
||||
min-width: 0; |
||||
} |
||||
|
||||
.blob-hash { |
||||
font-family: monospace; |
||||
font-size: 0.9em; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.blob-meta { |
||||
display: flex; |
||||
gap: 1em; |
||||
font-size: 0.8em; |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
margin-top: 0.25em; |
||||
} |
||||
|
||||
.blob-date { |
||||
font-size: 0.85em; |
||||
color: var(--text-color); |
||||
opacity: 0.6; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.delete-btn { |
||||
background: transparent; |
||||
border: 1px solid var(--warning); |
||||
color: var(--warning); |
||||
width: 1.75em; |
||||
height: 1.75em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.85em; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.delete-btn:hover { |
||||
background-color: var(--warning); |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.login-prompt { |
||||
text-align: center; |
||||
padding: 2em; |
||||
background-color: var(--card-bg); |
||||
border-radius: 8px; |
||||
border: 1px solid var(--border-color); |
||||
max-width: 32em; |
||||
margin: 1em; |
||||
} |
||||
|
||||
.login-prompt p { |
||||
margin: 0 0 1.5rem 0; |
||||
color: var(--text-color); |
||||
font-size: 1.1rem; |
||||
} |
||||
|
||||
.login-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
padding: 0.75em 1.5em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-weight: bold; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.login-btn:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
/* Modal styles */ |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background-color: rgba(0, 0, 0, 0.8); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
z-index: 1000; |
||||
} |
||||
|
||||
.modal-content { |
||||
background-color: var(--bg-color); |
||||
border-radius: 8px; |
||||
max-width: 90vw; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.modal-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 0.75em 1em; |
||||
border-bottom: 1px solid var(--border-color); |
||||
background-color: var(--card-bg); |
||||
} |
||||
|
||||
.modal-title { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 1em; |
||||
} |
||||
|
||||
.modal-hash { |
||||
font-family: monospace; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.modal-type { |
||||
font-size: 0.85em; |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.modal-controls { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5em; |
||||
} |
||||
|
||||
.zoom-btn { |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
width: 2em; |
||||
height: 2em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 1em; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.zoom-btn:hover:not(:disabled) { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.zoom-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.zoom-level { |
||||
font-size: 0.85em; |
||||
color: var(--text-color); |
||||
min-width: 3em; |
||||
text-align: center; |
||||
} |
||||
|
||||
.close-btn { |
||||
background: transparent; |
||||
border: 1px solid var(--border-color); |
||||
color: var(--text-color); |
||||
width: 2em; |
||||
height: 2em; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 1em; |
||||
margin-left: 0.5em; |
||||
} |
||||
|
||||
.close-btn:hover { |
||||
background-color: var(--warning); |
||||
border-color: var(--warning); |
||||
} |
||||
|
||||
.modal-body { |
||||
flex: 1; |
||||
overflow: auto; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1em; |
||||
min-height: 200px; |
||||
} |
||||
|
||||
.media-container { |
||||
transition: transform 0.2s ease; |
||||
transform-origin: center center; |
||||
} |
||||
|
||||
.media-container img { |
||||
max-width: 80vw; |
||||
max-height: 70vh; |
||||
object-fit: contain; |
||||
} |
||||
|
||||
.media-container video { |
||||
max-width: 80vw; |
||||
max-height: 70vh; |
||||
} |
||||
|
||||
.media-container.audio { |
||||
width: 100%; |
||||
padding: 2em; |
||||
} |
||||
|
||||
.media-container audio { |
||||
width: 100%; |
||||
} |
||||
|
||||
.file-preview { |
||||
text-align: center; |
||||
padding: 2em; |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
.file-icon { |
||||
font-size: 4em; |
||||
margin-bottom: 0.5em; |
||||
} |
||||
|
||||
.download-link { |
||||
display: inline-block; |
||||
margin-top: 1em; |
||||
padding: 0.75em 1.5em; |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
text-decoration: none; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.download-link:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.modal-footer { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5em; |
||||
padding: 0.75em 1em; |
||||
border-top: 1px solid var(--border-color); |
||||
background-color: var(--card-bg); |
||||
} |
||||
|
||||
.blob-details { |
||||
display: flex; |
||||
gap: 1.5em; |
||||
font-size: 0.85em; |
||||
color: var(--text-color); |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.blob-url-section { |
||||
display: flex; |
||||
gap: 0.5em; |
||||
width: 100%; |
||||
} |
||||
|
||||
.blob-url-input { |
||||
flex: 1; |
||||
padding: 0.4em 0.6em; |
||||
font-family: monospace; |
||||
font-size: 0.85em; |
||||
background-color: var(--bg-color); |
||||
color: var(--text-color); |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 4px; |
||||
cursor: text; |
||||
} |
||||
|
||||
.blob-url-input:focus { |
||||
outline: none; |
||||
border-color: var(--primary); |
||||
} |
||||
|
||||
.copy-btn { |
||||
padding: 0.4em 0.8em; |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 0.85em; |
||||
} |
||||
|
||||
.copy-btn:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.modal-actions { |
||||
display: flex; |
||||
gap: 0.5em; |
||||
} |
||||
|
||||
.action-btn { |
||||
padding: 0.5em 1em; |
||||
background-color: var(--primary); |
||||
color: var(--text-color); |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
text-decoration: none; |
||||
font-size: 0.9em; |
||||
} |
||||
|
||||
.action-btn:hover { |
||||
background-color: var(--accent-hover-color); |
||||
} |
||||
|
||||
.action-btn.danger { |
||||
background-color: transparent; |
||||
border: 1px solid var(--warning); |
||||
color: var(--warning); |
||||
} |
||||
|
||||
.action-btn.danger:hover { |
||||
background-color: var(--warning); |
||||
color: var(--text-color); |
||||
} |
||||
|
||||
@media (max-width: 600px) { |
||||
.blob-item { |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.blob-date { |
||||
width: 100%; |
||||
margin-top: 0.5em; |
||||
padding-left: 3em; |
||||
} |
||||
|
||||
.modal-footer { |
||||
flex-direction: column; |
||||
gap: 0.75em; |
||||
} |
||||
|
||||
.blob-details { |
||||
flex-direction: column; |
||||
gap: 0.25em; |
||||
} |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue