diff --git a/.claude/skills/domain-driven-design/SKILL.md b/.claude/skills/domain-driven-design/SKILL.md new file mode 100644 index 0000000..6b7534e --- /dev/null +++ b/.claude/skills/domain-driven-design/SKILL.md @@ -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/` diff --git a/.claude/skills/domain-driven-design/references/anti-patterns.md b/.claude/skills/domain-driven-design/references/anti-patterns.md new file mode 100644 index 0000000..62a45b3c --- /dev/null +++ b/.claude/skills/domain-driven-design/references/anti-patterns.md @@ -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 { + 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 { + 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 { + // 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 { + // 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; +} + +// Domain service uses port +class ShippingCostCalculator { + constructor(private rateProvider: ShippingRateProvider) {} + + async calculate(order: Order): Promise { + return this.rateProvider.getRate( + order.shippingAddress, + order.totalWeight() + ); + } +} + +// Adapter (infrastructure) implements port +class ShippingApiRateProvider implements ShippingRateProvider { + async getRate(destination: Address, weight: Weight): Promise { + 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 { + 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 { + await this.shippingDb.query(` + INSERT INTO shipments (order_id, customer_id, status) + VALUES (?, ?, 'PENDING') + `, [event.orderId, event.customerId]); + } +} + +class ShipmentRepository { + async findPendingShipments(): Promise { + // 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 { + process(order: TOrder): Promise; +} + +interface IOrderValidator { + validate(order: TOrder): ValidationResult; +} + +interface IOrderPersister { + persist(order: TOrder): Promise; +} + +abstract class AbstractOrderProcessor + implements IOrderProcessor { + + constructor( + protected validator: IOrderValidator, + protected persister: IOrderPersister, + protected notifier: INotificationService, + protected logger: ILogger, + protected metrics: IMetricsCollector + ) {} + + async process(order: TOrder): Promise { + 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; +} + +// Only one concrete implementation ever created +class StandardOrderProcessor extends AbstractOrderProcessor { + protected async doProcess(order: Order): Promise { + // 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 { + 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 { + 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 { + 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) { + // Any field can be updated! + return this.orderService.update(id, body); + } +} + +class OrderService { + async update(id: string, updates: Partial): Promise { + 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 { + 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 { + 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? | diff --git a/.claude/skills/domain-driven-design/references/strategic-patterns.md b/.claude/skills/domain-driven-design/references/strategic-patterns.md new file mode 100644 index 0000000..bcf132d --- /dev/null +++ b/.claude/skills/domain-driven-design/references/strategic-patterns.md @@ -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 = { + '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 diff --git a/.claude/skills/domain-driven-design/references/tactical-patterns.md b/.claude/skills/domain-driven-design/references/tactical-patterns.md new file mode 100644 index 0000000..ae543b1 --- /dev/null +++ b/.claude/skills/domain-driven-design/references/tactical-patterns.md @@ -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; + 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 { + 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 + ) { + 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 { + // 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 { + 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 { + 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; + save(order: Order): Promise; + delete(order: Order): Promise; + + // Domain-specific queries + findPendingOrdersFor(customerId: CustomerId): Promise; + findOrdersToShipBefore(deadline: Date): Promise; +} + +// Infrastructure implementation +class PostgresOrderRepository implements OrderRepository { + constructor(private db: Database) {} + + async findById(id: OrderId): Promise { + 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 { + 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; + findItems(orderId: string): Promise; + insert(row: OrderRow): Promise; +} + +// Repository: Domain-centric, returns aggregates +interface OrderRepository { + findById(id: OrderId): Promise; + save(order: Order): Promise; +} +``` + +## 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 { + 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 { + 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; + } +} +``` diff --git a/DDD_ANALYSIS.md b/DDD_ANALYSIS.md new file mode 100644 index 0000000..30b42eb --- /dev/null +++ b/DDD_ANALYSIS.md @@ -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* diff --git a/app/blossom.go b/app/blossom.go index 6aa7d3a..59cd50e 100644 --- a/app/blossom.go +++ b/app/blossom.go @@ -42,12 +42,23 @@ func (s *Server) blossomHandler(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/") { r.URL.Path = "/" + r.URL.Path } - + // Set baseURL in request context for blossom server to use + // Use the exported key type from the blossom package baseURL := s.ServiceURL(r) + "/blossom" - type baseURLKey struct{} - r = r.WithContext(context.WithValue(r.Context(), baseURLKey{}, baseURL)) - + r = r.WithContext(context.WithValue(r.Context(), blossom.BaseURLKey{}, baseURL)) + + s.blossomServer.Handler().ServeHTTP(w, r) +} + +// blossomRootHandler handles blossom requests at root level (for clients like Jumble) +// Note: Even though requests come to root-level paths like /upload, we return URLs +// with /blossom prefix because that's where the blob download handlers are registered. +func (s *Server) blossomRootHandler(w http.ResponseWriter, r *http.Request) { + // Set baseURL with /blossom prefix so returned blob URLs point to working handlers + baseURL := s.ServiceURL(r) + "/blossom" + r = r.WithContext(context.WithValue(r.Context(), blossom.BaseURLKey{}, baseURL)) + s.blossomServer.Handler().ServeHTTP(w, r) } diff --git a/app/server.go b/app/server.go index 038f1d5..0c07603 100644 --- a/app/server.go +++ b/app/server.go @@ -100,12 +100,33 @@ func (s *Server) isIPBlacklisted(remote string) bool { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // CORS headers should be handled by the reverse proxy (Caddy/nginx) - // to avoid duplicate headers. If running without a reverse proxy, - // uncomment the CORS configuration below or configure via environment variable. + // Check if this is a blossom-related path (needs CORS headers) + path := r.URL.Path + isBlossomPath := path == "/upload" || path == "/media" || + path == "/mirror" || path == "/report" || + strings.HasPrefix(path, "/list/") || + strings.HasPrefix(path, "/blossom/") || + (len(path) == 65 && path[0] == '/') // / blob downloads - // Handle preflight OPTIONS requests - if r.Method == "OPTIONS" { + // Set CORS headers for all blossom-related requests + if isBlossomPath { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, authorization, Content-Type, content-type, X-SHA-256, x-sha-256, X-Content-Length, x-content-length, X-Content-Type, x-content-type, Accept, accept") + w.Header().Set("Access-Control-Expose-Headers", "X-Reason, Content-Length, Content-Type, Accept-Ranges") + w.Header().Set("Access-Control-Max-Age", "86400") + + // Handle preflight OPTIONS requests for blossom paths + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + } else if r.Method == "OPTIONS" { + // Handle OPTIONS for non-blossom paths + if s.mux != nil { + s.mux.ServeHTTP(w, r) + return + } w.WriteHeader(http.StatusOK) return } @@ -140,6 +161,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Server) ServiceURL(req *http.Request) (url string) { + // Use configured RelayURL if available + if s.Config != nil && s.Config.RelayURL != "" { + relayURL := strings.TrimSuffix(s.Config.RelayURL, "/") + // Ensure it has a protocol + if !strings.HasPrefix(relayURL, "http://") && !strings.HasPrefix(relayURL, "https://") { + relayURL = "http://" + relayURL + } + return relayURL + } + proto := req.Header.Get("X-Forwarded-Proto") if proto == "" { if req.TLS != nil { @@ -271,8 +302,15 @@ func (s *Server) UserInterface() { // Blossom blob storage API endpoint if s.blossomServer != nil { + // Primary routes under /blossom/ s.mux.HandleFunc("/blossom/", s.blossomHandler) - log.Printf("Blossom blob storage API enabled at /blossom") + // Root-level routes for clients that expect blossom at root (like Jumble) + s.mux.HandleFunc("/upload", s.blossomRootHandler) + s.mux.HandleFunc("/list/", s.blossomRootHandler) + s.mux.HandleFunc("/media", s.blossomRootHandler) + s.mux.HandleFunc("/mirror", s.blossomRootHandler) + s.mux.HandleFunc("/report", s.blossomRootHandler) + log.Printf("Blossom blob storage API enabled at /blossom and root") } else { log.Printf("WARNING: Blossom server is nil, routes not registered") } diff --git a/app/web/src/App.svelte b/app/web/src/App.svelte index e26f556..4d83087 100644 --- a/app/web/src/App.svelte +++ b/app/web/src/App.svelte @@ -11,6 +11,7 @@ import RecoveryView from "./RecoveryView.svelte"; import SprocketView from "./SprocketView.svelte"; import PolicyView from "./PolicyView.svelte"; + import BlossomView from "./BlossomView.svelte"; import SearchResultsView from "./SearchResultsView.svelte"; import FilterDisplay from "./FilterDisplay.svelte"; @@ -1640,6 +1641,7 @@ { id: "export", icon: "📤", label: "Export" }, { id: "import", icon: "💾", label: "Import", requiresAdmin: true }, { id: "events", icon: "📡", label: "Events" }, + { id: "blossom", icon: "🌸", label: "Blossom" }, { id: "compose", icon: "✏️", label: "Compose", requiresWrite: true }, { id: "recovery", icon: "🔄", label: "Recovery" }, { @@ -2783,6 +2785,14 @@ on:filterApply={handleFilterApply} on:filterClear={handleFilterClear} /> + {:else if selectedTab === "blossom"} + {:else if selectedTab === "compose"} + 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} 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(", ")}`; + } + } + + + + +{#if canAccess} +
+
+

Blossom Media Storage

+ +
+ +
+ + + {#if selectedFiles.length > 0} + {selectedFiles.length} file(s) selected + + {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + + {#if isLoading && blobs.length === 0} +
Loading blobs...
+ {:else if blobs.length === 0} +
+

No files found in your Blossom storage.

+
+ {:else} +
+ {#each blobs as blob} +
openModal(blob)} + on:keypress={(e) => e.key === "Enter" && openModal(blob)} + role="button" + tabindex="0" + > +
+ {getMimeIcon(blob.type)} +
+
+
+ {truncateHash(blob.sha256)} +
+
+ {formatSize(blob.size)} + {blob.type || "unknown"} +
+
+
+ {formatDate(blob.uploaded)} +
+ +
+ {/each} +
+ {/if} +
+{:else} + +{/if} + +{#if showModal && selectedBlob} + +{/if} + + diff --git a/pkg/acl/follows.go b/pkg/acl/follows.go index ef94c97..0df50fb 100644 --- a/pkg/acl/follows.go +++ b/pkg/acl/follows.go @@ -26,7 +26,6 @@ import ( "git.mleku.dev/mleku/nostr/encoders/filter" "git.mleku.dev/mleku/nostr/encoders/kind" "git.mleku.dev/mleku/nostr/encoders/tag" - "git.mleku.dev/mleku/nostr/encoders/timestamp" "next.orly.dev/pkg/protocol/publish" "next.orly.dev/pkg/utils" "git.mleku.dev/mleku/nostr/utils/normalize" @@ -42,8 +41,6 @@ type Follows struct { admins [][]byte owners [][]byte follows [][]byte - updated chan struct{} - subsCancel context.CancelFunc // Track last follow list fetch time lastFollowListFetch time.Time // Callback for external notification of follow list changes @@ -134,11 +131,6 @@ func (f *Follows) Configure(cfg ...any) (err error) { } } } - if f.updated == nil { - f.updated = make(chan struct{}) - } else { - f.updated <- struct{}{} - } return } @@ -355,300 +347,12 @@ func (f *Follows) adminRelays() (urls []string) { return } -func (f *Follows) startEventSubscriptions(ctx context.Context) { - // build authors list: admins + follows - f.followsMx.RLock() - authors := make([][]byte, 0, len(f.admins)+len(f.follows)) - authors = append(authors, f.admins...) - authors = append(authors, f.follows...) - f.followsMx.RUnlock() - if len(authors) == 0 { - log.W.F("follows syncer: no authors (admins+follows) to subscribe to") - return - } - urls := f.adminRelays() - // log.I.S(urls) - if len(urls) == 0 { - log.W.F("follows syncer: no admin relays found in DB (kind 10002) and no bootstrap relays configured") - return - } - log.I.F( - "follows syncer: subscribing to %d relays for %d authors", len(urls), - len(authors), - ) - log.I.F("follows syncer: starting follow list fetching from relays: %v", urls) - for _, u := range urls { - u := u - go func() { - backoff := time.Second - for { - select { - case <-ctx.Done(): - return - default: - } - // Create a timeout context for the connection - connCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - - // Create proper headers for the WebSocket connection - headers := http.Header{} - headers.Set("User-Agent", "ORLY-Relay/0.9.2") - headers.Set("Origin", "https://orly.dev") - - // Use proper WebSocket dial options - dialer := websocket.Dialer{ - HandshakeTimeout: 10 * time.Second, - } - - c, resp, err := dialer.DialContext(connCtx, u, headers) - cancel() - if resp != nil { - resp.Body.Close() - } - if err != nil { - log.W.F("follows syncer: dial %s failed: %v", u, err) - - // Handle different types of errors - if strings.Contains( - err.Error(), "response status code 101 but got 403", - ) { - // 403 means the relay is not accepting connections from us - // Forbidden is the meaning, usually used to indicate either the IP or user is blocked - // But we should still retry after a longer delay - log.W.F( - "follows syncer: relay %s returned 403, will retry after longer delay", - u, - ) - timer := time.NewTimer(5 * time.Minute) // Wait 5 minutes before retrying 403 errors - select { - case <-ctx.Done(): - return - case <-timer.C: - } - continue - } else if strings.Contains( - err.Error(), "timeout", - ) || strings.Contains(err.Error(), "connection refused") { - // Network issues, retry with normal backoff - log.W.F( - "follows syncer: network issue with %s, retrying in %v", - u, backoff, - ) - } else { - // Other errors, retry with normal backoff - log.W.F( - "follows syncer: connection error with %s, retrying in %v", - u, backoff, - ) - } - - timer := time.NewTimer(backoff) - select { - case <-ctx.Done(): - return - case <-timer.C: - } - if backoff < 30*time.Second { - backoff *= 2 - } - continue - } - backoff = time.Second - log.T.F("follows syncer: successfully connected to %s", u) - log.I.F("follows syncer: subscribing to events from relay %s", u) - - // send REQ for admin follow lists, relay lists, and all events from follows - ff := &filter.S{} - // Add filter for admin follow lists (kind 3) - for immediate updates - f1 := &filter.F{ - Authors: tag.NewFromBytesSlice(f.admins...), - Kinds: kind.NewS(kind.New(kind.FollowList.K)), - Limit: values.ToUintPointer(100), - } - f2 := &filter.F{ - Authors: tag.NewFromBytesSlice(authors...), - Kinds: kind.NewS(kind.New(kind.RelayListMetadata.K)), - Limit: values.ToUintPointer(100), - } - // Add filter for all events from follows (last 30 days) - oneMonthAgo := timestamp.FromUnix(time.Now().Add(-30 * 24 * time.Hour).Unix()) - f3 := &filter.F{ - Authors: tag.NewFromBytesSlice(authors...), - Since: oneMonthAgo, - Limit: values.ToUintPointer(500), - } - *ff = append(*ff, f1, f2, f3) - // Use a subscription ID for event sync (no follow lists) - subID := "event-sync" - req := reqenvelope.NewFrom([]byte(subID), ff) - reqBytes := req.Marshal(nil) - log.T.F("follows syncer: outbound REQ to %s: %s", u, string(reqBytes)) - c.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err = c.WriteMessage(websocket.TextMessage, reqBytes); chk.E(err) { - log.W.F( - "follows syncer: failed to send event REQ to %s: %v", u, err, - ) - _ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "write failed"), time.Now().Add(time.Second)) - continue - } - log.T.F( - "follows syncer: sent event REQ to %s for admin follow lists, kind 10002, and all events (last 30 days) from followed users", - u, - ) - // read loop with keepalive - keepaliveTicker := time.NewTicker(30 * time.Second) - defer keepaliveTicker.Stop() - - readLoop: - for { - select { - case <-ctx.Done(): - _ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "ctx done"), time.Now().Add(time.Second)) - return - case <-keepaliveTicker.C: - // Send ping to keep connection alive - c.SetWriteDeadline(time.Now().Add(5 * time.Second)) - if err := c.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err != nil { - log.T.F("follows syncer: ping failed for %s: %v", u, err) - break readLoop - } - log.T.F("follows syncer: sent ping to %s", u) - continue - default: - // Set a read timeout to avoid hanging - c.SetReadDeadline(time.Now().Add(60 * time.Second)) - _, data, err := c.ReadMessage() - if err != nil { - _ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "read err"), time.Now().Add(time.Second)) - break readLoop - } - label, rem, err := envelopes.Identify(data) - if chk.E(err) { - continue - } - switch label { - case eventenvelope.L: - res, _, err := eventenvelope.ParseResult(rem) - if chk.E(err) || res == nil || res.Event == nil { - continue - } - // verify signature before saving - if ok, err := res.Event.Verify(); chk.T(err) || !ok { - continue - } - - // Process events based on kind - switch res.Event.Kind { - case kind.FollowList.K: - // Check if this is from an admin and process immediately - if f.isAdminPubkey(res.Event.Pubkey) { - log.I.F( - "follows syncer: received admin follow list from %s on relay %s - processing immediately", - hex.EncodeToString(res.Event.Pubkey), u, - ) - f.extractFollowedPubkeys(res.Event) - } else { - log.T.F( - "follows syncer: received follow list from non-admin %s on relay %s - ignoring", - hex.EncodeToString(res.Event.Pubkey), u, - ) - } - case kind.RelayListMetadata.K: - log.T.F( - "follows syncer: received kind 10002 (relay list) event from %s on relay %s", - hex.EncodeToString(res.Event.Pubkey), u, - ) - default: - // Log all other events from followed users - log.T.F( - "follows syncer: received kind %d event from %s on relay %s", - res.Event.Kind, - hex.EncodeToString(res.Event.Pubkey), u, - ) - } - - if _, err = f.D.SaveEvent( - ctx, res.Event, - ); err != nil { - if !strings.HasPrefix( - err.Error(), "blocked:", - ) { - log.W.F( - "follows syncer: save event failed: %v", - err, - ) - } - // ignore duplicates and continue - } else { - // Only dispatch if the event was newly saved (no error) - if f.pubs != nil { - go f.pubs.Deliver(res.Event) - } - // log.I.F( - // "saved new event from follows syncer: %0x", - // res.Event.ID, - // ) - } - case eoseenvelope.L: - log.T.F("follows syncer: received EOSE from %s, continuing persistent subscription", u) - // Continue the subscription for new events - default: - // ignore other labels - } - } - } - // Connection dropped, reconnect after delay - log.W.F("follows syncer: connection to %s dropped, will reconnect in 30 seconds", u) - - // Wait before reconnecting to avoid tight reconnection loops - timer := time.NewTimer(30 * time.Second) - select { - case <-ctx.Done(): - return - case <-timer.C: - // Continue to reconnect - } - } - }() - } -} func (f *Follows) Syncer() { log.I.F("starting follows syncer") - // Start periodic follow list fetching + // Start periodic follow list and metadata fetching go f.startPeriodicFollowListFetching() - - // Start event subscriptions - go func() { - // start immediately if Configure already ran - for { - var innerCancel context.CancelFunc - select { - case <-f.Ctx.Done(): - if f.subsCancel != nil { - f.subsCancel() - } - return - case <-f.updated: - // close and reopen subscriptions to users on the follow list and admins - if f.subsCancel != nil { - log.I.F("follows syncer: cancelling existing subscriptions") - f.subsCancel() - } - ctx, cancel := context.WithCancel(f.Ctx) - f.subsCancel = cancel - innerCancel = cancel - log.I.F("follows syncer: (re)opening subscriptions") - f.startEventSubscriptions(ctx) - } - // small sleep to avoid tight loop if updated fires rapidly - if innerCancel == nil { - time.Sleep(50 * time.Millisecond) - } - } - }() - f.updated <- struct{}{} } // startPeriodicFollowListFetching starts periodic fetching of admin follow lists @@ -677,9 +381,9 @@ func (f *Follows) startPeriodicFollowListFetching() { } } -// fetchAdminFollowLists fetches follow lists from admin relays +// fetchAdminFollowLists fetches follow lists for admins and metadata for all follows func (f *Follows) fetchAdminFollowLists() { - log.I.F("follows syncer: fetching admin follow lists") + log.I.F("follows syncer: fetching admin follow lists and follows metadata") urls := f.adminRelays() if len(urls) == 0 { @@ -687,27 +391,31 @@ func (f *Follows) fetchAdminFollowLists() { return } - // build authors list: admins only (not follows) + // build authors lists: admins for follow lists, all follows for metadata f.followsMx.RLock() - authors := make([][]byte, len(f.admins)) - copy(authors, f.admins) + admins := make([][]byte, len(f.admins)) + copy(admins, f.admins) + allFollows := make([][]byte, 0, len(f.admins)+len(f.follows)) + allFollows = append(allFollows, f.admins...) + allFollows = append(allFollows, f.follows...) f.followsMx.RUnlock() - if len(authors) == 0 { + if len(admins) == 0 { log.W.F("follows syncer: no admins to fetch follow lists for") return } - log.I.F("follows syncer: fetching follow lists from %d relays for %d admins", len(urls), len(authors)) + log.I.F("follows syncer: fetching from %d relays: follow lists for %d admins, metadata for %d follows", + len(urls), len(admins), len(allFollows)) for _, u := range urls { - go f.fetchFollowListsFromRelay(u, authors) + go f.fetchFollowListsFromRelay(u, admins, allFollows) } } -// fetchFollowListsFromRelay fetches follow lists from a specific relay -func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { - ctx, cancel := context.WithTimeout(f.Ctx, 30*time.Second) +// fetchFollowListsFromRelay fetches follow lists for admins and metadata for all follows from a specific relay +func (f *Follows) fetchFollowListsFromRelay(relayURL string, admins [][]byte, allFollows [][]byte) { + ctx, cancel := context.WithTimeout(f.Ctx, 60*time.Second) defer cancel() // Create proper headers for the WebSocket connection @@ -730,21 +438,34 @@ func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { } defer c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "follow list fetch complete"), time.Now().Add(time.Second)) - log.I.F("follows syncer: fetching follow lists from relay %s", relayURL) + log.I.F("follows syncer: fetching follow lists and metadata from relay %s", relayURL) - // Create filter for follow lists and relay lists (kind 3 and kind 10002) + // Create filters: + // - kind 3 (follow lists) for admins only + // - kind 0 (metadata) + kind 10002 (relay lists) for all follows ff := &filter.S{} + + // Filter for admin follow lists (kind 3) f1 := &filter.F{ - Authors: tag.NewFromBytesSlice(authors...), + Authors: tag.NewFromBytesSlice(admins...), Kinds: kind.NewS(kind.New(kind.FollowList.K)), - Limit: values.ToUintPointer(100), + Limit: values.ToUintPointer(uint(len(admins) * 2)), } + + // Filter for metadata (kind 0) for all follows f2 := &filter.F{ - Authors: tag.NewFromBytesSlice(authors...), - Kinds: kind.NewS(kind.New(kind.RelayListMetadata.K)), - Limit: values.ToUintPointer(100), + Authors: tag.NewFromBytesSlice(allFollows...), + Kinds: kind.NewS(kind.New(kind.ProfileMetadata.K)), + Limit: values.ToUintPointer(uint(len(allFollows) * 2)), } - *ff = append(*ff, f1, f2) + + // Filter for relay lists (kind 10002) for all follows + f3 := &filter.F{ + Authors: tag.NewFromBytesSlice(allFollows...), + Kinds: kind.NewS(kind.New(kind.RelayListMetadata.K)), + Limit: values.ToUintPointer(uint(len(allFollows) * 2)), + } + *ff = append(*ff, f1, f2, f3) // Use a specific subscription ID for follow list fetching subID := "follow-lists-fetch" @@ -757,14 +478,15 @@ func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { return } - log.T.F("follows syncer: sent follow list and relay list REQ to %s", relayURL) + log.T.F("follows syncer: sent follow list, metadata, and relay list REQ to %s", relayURL) // Collect all events before processing var followListEvents []*event.E + var metadataEvents []*event.E var relayListEvents []*event.E - // Read events with timeout - timeout := time.After(10 * time.Second) + // Read events with timeout (longer timeout for larger fetches) + timeout := time.After(30 * time.Second) for { select { case <-ctx.Done(): @@ -775,7 +497,7 @@ func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { default: } - c.SetReadDeadline(time.Now().Add(10 * time.Second)) + c.SetReadDeadline(time.Now().Add(30 * time.Second)) _, data, err := c.ReadMessage() if err != nil { log.T.F("follows syncer: error reading events from %s: %v", relayURL, err) @@ -797,11 +519,15 @@ func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { // Collect events by kind switch res.Event.Kind { case kind.FollowList.K: - log.I.F("follows syncer: received follow list from %s on relay %s", + log.T.F("follows syncer: received follow list from %s on relay %s", hex.EncodeToString(res.Event.Pubkey), relayURL) followListEvents = append(followListEvents, res.Event) + case kind.ProfileMetadata.K: + log.T.F("follows syncer: received metadata from %s on relay %s", + hex.EncodeToString(res.Event.Pubkey), relayURL) + metadataEvents = append(metadataEvents, res.Event) case kind.RelayListMetadata.K: - log.I.F("follows syncer: received relay list from %s on relay %s", + log.T.F("follows syncer: received relay list from %s on relay %s", hex.EncodeToString(res.Event.Pubkey), relayURL) relayListEvents = append(relayListEvents, res.Event) } @@ -815,11 +541,11 @@ func (f *Follows) fetchFollowListsFromRelay(relayURL string, authors [][]byte) { processEvents: // Process collected events - keep only the newest per pubkey and save to database - f.processCollectedEvents(relayURL, followListEvents, relayListEvents) + f.processCollectedEvents(relayURL, followListEvents, metadataEvents, relayListEvents) } // processCollectedEvents processes the collected events, keeping only the newest per pubkey -func (f *Follows) processCollectedEvents(relayURL string, followListEvents, relayListEvents []*event.E) { +func (f *Follows) processCollectedEvents(relayURL string, followListEvents, metadataEvents, relayListEvents []*event.E) { // Process follow list events (kind 3) - keep newest per pubkey latestFollowLists := make(map[string]*event.E) for _, ev := range followListEvents { @@ -830,6 +556,16 @@ func (f *Follows) processCollectedEvents(relayURL string, followListEvents, rela } } + // Process metadata events (kind 0) - keep newest per pubkey + latestMetadata := make(map[string]*event.E) + for _, ev := range metadataEvents { + pubkeyHex := hex.EncodeToString(ev.Pubkey) + existing, exists := latestMetadata[pubkeyHex] + if !exists || ev.CreatedAt > existing.CreatedAt { + latestMetadata[pubkeyHex] = ev + } + } + // Process relay list events (kind 10002) - keep newest per pubkey latestRelayLists := make(map[string]*event.E) for _, ev := range relayListEvents { @@ -842,6 +578,7 @@ func (f *Follows) processCollectedEvents(relayURL string, followListEvents, rela // Save and process the newest events savedFollowLists := 0 + savedMetadata := 0 savedRelayLists := 0 // Save follow list events to database and extract follows @@ -852,7 +589,7 @@ func (f *Follows) processCollectedEvents(relayURL string, followListEvents, rela } } else { savedFollowLists++ - log.I.F("follows syncer: saved newest follow list from %s (created_at: %d) from relay %s", + log.T.F("follows syncer: saved follow list from %s (created_at: %d) from relay %s", pubkeyHex, ev.CreatedAt, relayURL) } @@ -863,6 +600,19 @@ func (f *Follows) processCollectedEvents(relayURL string, followListEvents, rela } } + // Save metadata events to database + for pubkeyHex, ev := range latestMetadata { + if _, err := f.D.SaveEvent(f.Ctx, ev); err != nil { + if !strings.HasPrefix(err.Error(), "blocked:") { + log.W.F("follows syncer: failed to save metadata from %s: %v", pubkeyHex, err) + } + } else { + savedMetadata++ + log.T.F("follows syncer: saved metadata from %s (created_at: %d) from relay %s", + pubkeyHex, ev.CreatedAt, relayURL) + } + } + // Save relay list events to database for pubkeyHex, ev := range latestRelayLists { if _, err := f.D.SaveEvent(f.Ctx, ev); err != nil { @@ -871,24 +621,14 @@ func (f *Follows) processCollectedEvents(relayURL string, followListEvents, rela } } else { savedRelayLists++ - log.I.F("follows syncer: saved newest relay list from %s (created_at: %d) from relay %s", + log.T.F("follows syncer: saved relay list from %s (created_at: %d) from relay %s", pubkeyHex, ev.CreatedAt, relayURL) } } - log.I.F("follows syncer: processed %d follow lists and %d relay lists from %s, saved %d follow lists and %d relay lists", - len(followListEvents), len(relayListEvents), relayURL, savedFollowLists, savedRelayLists) - - // If we saved any relay lists, trigger a refresh of subscriptions to use the new relay lists - if savedRelayLists > 0 { - log.I.F("follows syncer: saved new relay lists, triggering subscription refresh") - // Signal that follows have been updated to refresh subscriptions - select { - case f.updated <- struct{}{}: - default: - // Channel might be full, that's okay - } - } + log.I.F("follows syncer: from %s - received: %d follow lists, %d metadata, %d relay lists; saved: %d, %d, %d", + relayURL, len(followListEvents), len(metadataEvents), len(relayListEvents), + savedFollowLists, savedMetadata, savedRelayLists) } // GetFollowedPubkeys returns a copy of the followed pubkeys list @@ -967,14 +707,6 @@ func (f *Follows) AddFollow(pub []byte) { "follows syncer: added new followed pubkey: %s", hex.EncodeToString(pub), ) - // notify syncer if initialized - if f.updated != nil { - select { - case f.updated <- struct{}{}: - default: - // if channel is full or not yet listened to, ignore - } - } // notify external listeners (e.g., spider) if f.onFollowListUpdate != nil { go f.onFollowListUpdate() diff --git a/pkg/blossom/server.go b/pkg/blossom/server.go index 007f60f..caa4174 100644 --- a/pkg/blossom/server.go +++ b/pkg/blossom/server.go @@ -141,10 +141,12 @@ func (s *Server) Handler() http.Handler { // setCORSHeaders sets CORS headers as required by BUD-01 func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, *") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, OPTIONS") + // Include all headers used by Blossom clients (BUD-01, BUD-06) + // Include both cases for maximum compatibility with various clients + w.Header().Set("Access-Control-Allow-Headers", "Authorization, authorization, Content-Type, content-type, X-SHA-256, x-sha-256, X-Content-Length, x-content-length, X-Content-Type, x-content-type, Accept, accept") + w.Header().Set("Access-Control-Expose-Headers", "X-Reason, Content-Length, Content-Type, Accept-Ranges") w.Header().Set("Access-Control-Max-Age", "86400") - w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers") } @@ -198,10 +200,12 @@ func (s *Server) checkACL( return actual >= required } +// BaseURLKey is the context key for the base URL (exported for use by app handler) +type BaseURLKey struct{} + // getBaseURL returns the base URL, preferring request context if available func (s *Server) getBaseURL(r *http.Request) string { - type baseURLKey struct{} - if baseURL := r.Context().Value(baseURLKey{}); baseURL != nil { + if baseURL := r.Context().Value(BaseURLKey{}); baseURL != nil { if url, ok := baseURL.(string); ok && url != "" { return url } diff --git a/pkg/blossom/utils.go b/pkg/blossom/utils.go index 01b8325..e999513 100644 --- a/pkg/blossom/utils.go +++ b/pkg/blossom/utils.go @@ -226,6 +226,10 @@ func WriteRangeResponse( // BuildBlobURL builds a blob URL with optional extension func BuildBlobURL(baseURL, sha256Hex, ext string) string { + // Ensure baseURL ends with / + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } url := baseURL + sha256Hex if ext != "" { url += ext diff --git a/pkg/version/version b/pkg/version/version index 9b2761d..46a7524 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.36.11 +v0.36.12