From 0c6de715c499e2a91e74843c708733309b8426ec Mon Sep 17 00:00:00 2001 From: mleku Date: Fri, 26 Dec 2025 12:28:32 +0200 Subject: [PATCH] docs: add Domain-Driven Design analysis report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive DDD analysis of the Smesh codebase including: - Domain and bounded context identification - Anti-pattern analysis with remediation strategies - 5-phase refactoring roadmap - Migration strategy and success metrics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- DDD_ANALYSIS.md | 786 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 DDD_ANALYSIS.md diff --git a/DDD_ANALYSIS.md b/DDD_ANALYSIS.md new file mode 100644 index 00000000..c6d95347 --- /dev/null +++ b/DDD_ANALYSIS.md @@ -0,0 +1,786 @@ +# Domain-Driven Design Analysis: Smesh Nostr Client + +## Executive Summary + +This document provides a Domain-Driven Design (DDD) analysis of the Smesh codebase, a React/TypeScript Nostr client. The analysis identifies the implicit domain model, evaluates current architecture against DDD principles, and provides actionable recommendations for refactoring toward a more domain-centric design. + +**Key Findings:** +- The codebase has implicit bounded contexts but lacks explicit boundaries +- Domain logic is scattered across providers, services, and lib utilities +- The architecture exhibits several DDD anti-patterns (Anemic Domain Model, Smart UI tendencies) +- Nostr events naturally align with Domain Events pattern +- Strong foundation exists for incremental DDD adoption + +--- + +## 1. Domain Analysis + +### 1.1 Core Domain Identification + +The Smesh application operates in the **decentralized social networking** domain, specifically implementing the Nostr protocol. The core business capabilities are: + +| Subdomain | Type | Description | +|-----------|------|-------------| +| **Identity & Authentication** | Core | Key management, signing, account switching | +| **Social Graph** | Core | Following, muting, trust relationships | +| **Content Publishing** | Core | Notes, reactions, reposts, media | +| **Feed Curation** | Core | Timeline construction, filtering, relay selection | +| **Relay Management** | Supporting | Relay sets, discovery, connectivity | +| **Notifications** | Supporting | Real-time event monitoring | +| **Translation** | Generic | Multi-language content translation | +| **Media Upload** | Generic | NIP-96/Blossom file hosting | + +### 1.2 Ubiquitous Language + +The codebase uses Nostr protocol terminology, which forms the basis of the ubiquitous language: + +| Term | Definition | Current Implementation | +|------|------------|----------------------| +| **Event** | Signed JSON object (note, reaction, etc.) | `nostr-tools` Event type | +| **Pubkey** | User's public key identifier | String (should be Value Object) | +| **Relay** | WebSocket server for event distribution | String URL (should be Value Object) | +| **Kind** | Event type identifier (0=profile, 1=note, etc.) | Number constants in `constants.ts` | +| **Tag** | Metadata attached to events (p, e, a tags) | String arrays (should be Value Objects) | +| **Profile** | User metadata (name, avatar, etc.) | `TProfile` type | +| **Follow List** | User's contact list (kind 3) | Array in `FollowListProvider` | +| **Mute List** | Blocked users/content (kind 10000) | Array in `MuteListProvider` | +| **Relay List** | User's preferred relays (kind 10002) | `TRelayList` type | +| **Signer** | Key management abstraction | `ISigner` interface | + +**Language Issues Identified:** +- "Stuff Stats" is unclear domain terminology (rename to `InteractionMetrics`) +- "Favorite Relays" vs "Relay Sets" inconsistency +- "Draft Event" conflates unsigned events with work-in-progress content + +--- + +## 2. Current Architecture Assessment + +### 2.1 Directory Structure Analysis + +``` +src/ +├── providers/ # State management + some domain logic (17 contexts) +├── services/ # Business logic + infrastructure concerns mixed +├── lib/ # Utility functions + domain logic mixed +├── types/ # Type definitions (implicit domain model) +├── components/ # UI components (some contain business logic) +├── pages/ # Page components +└── hooks/ # Custom React hooks +``` + +**Assessment:** The architecture follows a layered approach but lacks explicit domain layer separation. Domain logic is distributed across: +- `lib/` - Event manipulation, validation +- `services/` - Data fetching, caching, persistence +- `providers/` - State management with embedded business rules + +### 2.2 Implicit Bounded Contexts + +The codebase contains several implicit bounded contexts that could be made explicit: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CONTEXT MAP │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ Partnership ┌──────────────┐ │ +│ │ Identity │◄────────────────────►│ Social Graph │ │ +│ │ Context │ │ Context │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ │ Customer/Supplier │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ Content │ │ Feed │ │ +│ │ Context │ │ Context │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ │ │ +│ └──────────────┬───────────────────────┘ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ Relay │ │ +│ │ Context │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Context Descriptions:** + +1. **Identity Context** + - Concerns: Key management, signing, account switching + - Current: `NostrProvider`, `ISigner` implementations + - Entities: Account, Signer + +2. **Social Graph Context** + - Concerns: Following, muting, trust, pinned users + - Current: `FollowListProvider`, `MuteListProvider`, `UserTrustProvider` + - Entities: User, FollowList, MuteList + +3. **Content Context** + - Concerns: Creating and publishing events + - Current: `lib/draft-event.ts`, publishing logic in providers + - Entities: Note, Reaction, Repost, Bookmark + +4. **Feed Context** + - Concerns: Timeline construction, filtering, display + - Current: `FeedProvider`, `KindFilterProvider`, `NotificationProvider` + - Entities: Feed, Filter, Timeline + +5. **Relay Context** + - Concerns: Relay management, connectivity, selection + - Current: `FavoriteRelaysProvider`, `ClientService` + - Entities: Relay, RelaySet, RelayList + +--- + +## 3. Anti-Pattern Analysis + +### 3.1 Anemic Domain Model + +**Severity: High** + +The current implementation exhibits a classic anemic domain model. Domain types are primarily data structures without behavior. + +**Evidence:** + +```typescript +// Current: Types are data containers (src/types/index.d.ts) +type TProfile = { + pubkey: string + username?: string + displayName?: string + avatar?: string + // ... no behavior +} + +// Business logic lives in external functions (src/lib/event-metadata.ts) +export function extractProfileFromEventContent(event: Event): TProfile { + // Logic external to the domain object +} +``` + +**Impact:** +- Business rules scattered across `lib/`, `services/`, `providers/` +- Difficult to find all rules related to a concept +- Easy to bypass validation by directly manipulating data + +### 3.2 Smart UI Tendencies + +**Severity: Medium** + +Some business logic exists in UI components and providers that should be in domain layer. + +**Evidence:** + +```typescript +// Provider contains domain logic (src/providers/FollowListProvider.tsx) +const follow = async (pubkey: string) => { + // Business rule: can't follow yourself + if (pubkey === currentPubkey) return + + // Business rule: avoid duplicates + if (followList.includes(pubkey)) return + + // Event creation and publishing + const newFollowList = [...followList, pubkey] + const draftEvent = createFollowListDraftEvent(...) + await publish(draftEvent) +} +``` + +This logic belongs in a domain service or aggregate, not in a React context provider. + +### 3.3 Database-Driven Design Elements + +**Severity: Low** + +The `IndexedDB` schema influences some type definitions, though this is less severe than traditional database-driven design. + +**Evidence:** +- Storage keys defined alongside domain constants +- Some types mirror storage structure rather than domain concepts + +### 3.4 Missing Aggregate Boundaries + +**Severity: Medium** + +No explicit aggregate roots or boundaries exist. Related data is managed independently. + +**Evidence:** +- `FollowList`, `MuteList`, `PinList` are managed by separate providers +- No transactional consistency guarantees +- Cross-cutting updates happen independently + +### 3.5 Leaky Abstractions + +**Severity: Medium** + +Infrastructure concerns leak into what should be domain logic. + +**Evidence:** + +```typescript +// Service mixes domain and infrastructure (src/services/client.service.ts) +class ClientService extends EventTarget { + private pool = new SimplePool() // Infrastructure + private cache = new LRUCache(...) // Infrastructure + private userIndex = new FlexSearch(...) // Infrastructure + + // Domain logic mixed with caching, batching, retries + async fetchProfile(pubkey: string): Promise { + // Caching logic + // Relay selection logic (domain) + // Network calls (infrastructure) + // Index updates (infrastructure) + } +} +``` + +--- + +## 4. Current Strengths + +### 4.1 Natural Domain Event Alignment + +Nostr events ARE domain events. The protocol's event-sourced nature aligns perfectly with DDD: + +```typescript +// Nostr events capture domain facts +{ + kind: 1, // Note created + content: "Hello Nostr!", + tags: [["p", "..."]], // Mentions + created_at: 1234567890, + pubkey: "...", + sig: "..." +} +``` + +### 4.2 Signer Interface Abstraction + +The `ISigner` interface is a well-designed port in hexagonal architecture terms: + +```typescript +interface ISigner { + getPublicKey(): Promise + signEvent(draftEvent: TDraftEvent): Promise + nip04Encrypt(pubkey: string, plainText: string): Promise + nip04Decrypt(pubkey: string, cipherText: string): Promise +} +``` + +Multiple implementations exist: `NsecSigner`, `Nip07Signer`, `BunkerSigner`, etc. + +### 4.3 Event Creation Factories + +The `lib/draft-event.ts` file contains factory functions that encapsulate event creation: + +```typescript +createShortTextNoteDraftEvent(content, tags?, relays?) +createReactionDraftEvent(event, emoji?) +createFollowListDraftEvent(tags, content?) +createBookmarkDraftEvent(tags, content?) +``` + +These are proto-factories that could be formalized into proper Factory patterns. + +### 4.4 Clear Type Definitions + +The `types/index.d.ts` file provides a foundation for a rich domain model, even if currently anemic. + +--- + +## 5. Refactoring Recommendations + +### 5.1 Phase 1: Establish Domain Layer (Low Risk) + +**Goal:** Create explicit domain layer without disrupting existing functionality. + +**Actions:** + +1. **Create domain directory structure:** + +``` +src/ +├── domain/ +│ ├── identity/ +│ │ ├── Account.ts +│ │ ├── Pubkey.ts (Value Object) +│ │ └── index.ts +│ ├── social/ +│ │ ├── FollowList.ts (Aggregate) +│ │ ├── MuteList.ts (Aggregate) +│ │ └── index.ts +│ ├── content/ +│ │ ├── Note.ts (Entity) +│ │ ├── Reaction.ts (Value Object) +│ │ └── index.ts +│ ├── relay/ +│ │ ├── Relay.ts (Value Object) +│ │ ├── RelaySet.ts (Aggregate) +│ │ └── index.ts +│ └── shared/ +│ ├── EventId.ts +│ ├── Timestamp.ts +│ └── index.ts +``` + +2. **Introduce Value Objects for primitives:** + +```typescript +// src/domain/identity/Pubkey.ts +export class Pubkey { + private constructor(private readonly value: string) {} + + static fromHex(hex: string): Pubkey { + if (!/^[0-9a-f]{64}$/i.test(hex)) { + throw new InvalidPubkeyError(hex) + } + return new Pubkey(hex) + } + + static fromNpub(npub: string): Pubkey { + const decoded = nip19.decode(npub) + if (decoded.type !== 'npub') { + throw new InvalidPubkeyError(npub) + } + return new Pubkey(decoded.data) + } + + toHex(): string { return this.value } + toNpub(): string { return nip19.npubEncode(this.value) } + + equals(other: Pubkey): boolean { + return this.value === other.value + } +} +``` + +```typescript +// src/domain/relay/RelayUrl.ts +export class RelayUrl { + private constructor(private readonly value: string) {} + + static create(url: string): RelayUrl { + const normalized = normalizeRelayUrl(url) + if (!isValidRelayUrl(normalized)) { + throw new InvalidRelayUrlError(url) + } + return new RelayUrl(normalized) + } + + toString(): string { return this.value } + + equals(other: RelayUrl): boolean { + return this.value === other.value + } +} +``` + +3. **Create rich domain entities:** + +```typescript +// src/domain/social/FollowList.ts +export class FollowList { + private constructor( + private readonly _ownerPubkey: Pubkey, + private _following: Set, + private _petnames: Map + ) {} + + static empty(owner: Pubkey): FollowList { + return new FollowList(owner, new Set(), new Map()) + } + + static fromEvent(event: Event): FollowList { + // Reconstitute from Nostr event + } + + follow(pubkey: Pubkey): FollowListUpdated { + if (pubkey.equals(this._ownerPubkey)) { + throw new CannotFollowSelfError() + } + if (this._following.has(pubkey.toHex())) { + return FollowListUpdated.noChange() + } + this._following.add(pubkey.toHex()) + return FollowListUpdated.added(pubkey) + } + + unfollow(pubkey: Pubkey): FollowListUpdated { + if (!this._following.has(pubkey.toHex())) { + return FollowListUpdated.noChange() + } + this._following.delete(pubkey.toHex()) + return FollowListUpdated.removed(pubkey) + } + + isFollowing(pubkey: Pubkey): boolean { + return this._following.has(pubkey.toHex()) + } + + toDraftEvent(): TDraftEvent { + // Convert to publishable event + } +} +``` + +### 5.2 Phase 2: Introduce Domain Services (Medium Risk) + +**Goal:** Extract business logic from providers into domain services. + +**Actions:** + +1. **Create domain services for cross-aggregate operations:** + +```typescript +// src/domain/content/PublishingService.ts +export class PublishingService { + constructor( + private readonly relaySelector: RelaySelector, + private readonly signer: ISigner + ) {} + + async publishNote( + content: string, + mentions: Pubkey[], + replyTo?: EventId + ): Promise { + const note = Note.create(content, mentions, replyTo) + const relays = await this.relaySelector.selectForPublishing(note) + const signedEvent = await this.signer.signEvent(note.toDraftEvent()) + + return new PublishedNote(signedEvent, relays) + } +} +``` + +```typescript +// src/domain/relay/RelaySelector.ts +export class RelaySelector { + constructor( + private readonly userRelayList: RelayList, + private readonly mentionRelayResolver: MentionRelayResolver + ) {} + + async selectForPublishing(note: Note): Promise { + const writeRelays = this.userRelayList.writeRelays() + const mentionRelays = await this.resolveMentionRelays(note.mentions) + + return this.mergeAndDeduplicate(writeRelays, mentionRelays) + } +} +``` + +2. **Refactor providers to use domain services:** + +```typescript +// src/providers/ContentProvider.tsx (refactored) +export function ContentProvider({ children }: Props) { + const { signer, relayList } = useNostr() + + // Domain service instantiation + const publishingService = useMemo( + () => new PublishingService( + new RelaySelector(relayList, new MentionRelayResolver()), + signer + ), + [signer, relayList] + ) + + const publishNote = useCallback(async (content: string, mentions: string[]) => { + const pubkeys = mentions.map(Pubkey.fromHex) + const result = await publishingService.publishNote(content, pubkeys) + // Update UI state + }, [publishingService]) + + return ( + + {children} + + ) +} +``` + +### 5.3 Phase 3: Define Aggregate Boundaries (Higher Risk) + +**Goal:** Establish clear aggregate roots with transactional boundaries. + +**Proposed Aggregates:** + +| Aggregate Root | Child Entities | Invariants | +|----------------|----------------|------------| +| `UserProfile` | Profile metadata | NIP-05 validation | +| `FollowList` | Follow entries, petnames | No self-follow, unique entries | +| `MuteList` | Public mutes, private mutes | Encryption for private | +| `RelaySet` | Relay URLs, names | Valid URLs, unique within set | +| `Bookmark` | Bookmarked events | Unique event references | + +**Implementation:** + +```typescript +// src/domain/social/FollowList.ts (Aggregate Root) +export class FollowList { + private _domainEvents: DomainEvent[] = [] + + follow(pubkey: Pubkey): void { + // Invariant enforcement + this.ensureNotSelf(pubkey) + this.ensureNotAlreadyFollowing(pubkey) + + this._following.add(pubkey.toHex()) + + // Raise domain event + this._domainEvents.push( + new UserFollowed(this._ownerPubkey, pubkey, new Date()) + ) + } + + pullDomainEvents(): DomainEvent[] { + const events = [...this._domainEvents] + this._domainEvents = [] + return events + } +} +``` + +### 5.4 Phase 4: Introduce Repositories (Higher Risk) + +**Goal:** Abstract persistence behind domain-focused interfaces. + +```typescript +// src/domain/social/FollowListRepository.ts (Interface in domain) +export interface FollowListRepository { + findByOwner(pubkey: Pubkey): Promise + save(followList: FollowList): Promise +} + +// src/infrastructure/persistence/IndexedDbFollowListRepository.ts +export class IndexedDbFollowListRepository implements FollowListRepository { + constructor( + private readonly indexedDb: IndexedDbService, + private readonly clientService: ClientService + ) {} + + async findByOwner(pubkey: Pubkey): Promise { + // Check IndexedDB cache + const cached = await this.indexedDb.getFollowList(pubkey.toHex()) + if (cached) { + return FollowList.fromEvent(cached) + } + + // Fetch from relays + const event = await this.clientService.fetchFollowList(pubkey.toHex()) + if (event) { + await this.indexedDb.saveFollowList(event) + return FollowList.fromEvent(event) + } + + return null + } + + async save(followList: FollowList): Promise { + const draftEvent = followList.toDraftEvent() + // Sign and publish handled by application service + } +} +``` + +### 5.5 Phase 5: Event-Driven Architecture (Advanced) + +**Goal:** Leverage Nostr's event-sourced nature for cross-context communication. + +```typescript +// src/domain/shared/DomainEvent.ts +export abstract class DomainEvent { + readonly occurredAt: Date = new Date() + abstract get eventType(): string +} + +// src/domain/social/events/UserFollowed.ts +export class UserFollowed extends DomainEvent { + constructor( + readonly follower: Pubkey, + readonly followed: Pubkey, + readonly timestamp: Date + ) { + super() + } + + get eventType(): string { return 'social.user_followed' } +} + +// src/application/handlers/UserFollowedHandler.ts +export class UserFollowedHandler { + constructor( + private readonly notificationService: NotificationService + ) {} + + async handle(event: UserFollowed): Promise { + // Cross-context reaction + await this.notificationService.notifyNewFollower( + event.followed, + event.follower + ) + } +} +``` + +--- + +## 6. Proposed Target Architecture + +``` +src/ +├── domain/ # Core domain logic (no dependencies) +│ ├── identity/ +│ │ ├── model/ +│ │ │ ├── Account.ts +│ │ │ ├── Pubkey.ts +│ │ │ └── Keypair.ts +│ │ ├── services/ +│ │ │ └── SigningService.ts +│ │ └── index.ts +│ ├── social/ +│ │ ├── model/ +│ │ │ ├── FollowList.ts +│ │ │ ├── MuteList.ts +│ │ │ └── UserProfile.ts +│ │ ├── services/ +│ │ │ └── TrustCalculator.ts +│ │ ├── events/ +│ │ │ ├── UserFollowed.ts +│ │ │ └── UserMuted.ts +│ │ └── index.ts +│ ├── content/ +│ │ ├── model/ +│ │ │ ├── Note.ts +│ │ │ ├── Reaction.ts +│ │ │ └── Repost.ts +│ │ ├── services/ +│ │ │ └── ContentValidator.ts +│ │ └── index.ts +│ ├── relay/ +│ │ ├── model/ +│ │ │ ├── RelayUrl.ts +│ │ │ ├── RelaySet.ts +│ │ │ └── RelayList.ts +│ │ ├── services/ +│ │ │ └── RelaySelector.ts +│ │ └── index.ts +│ └── shared/ +│ ├── EventId.ts +│ ├── Timestamp.ts +│ └── DomainEvent.ts +│ +├── application/ # Use cases, orchestration +│ ├── identity/ +│ │ └── AccountService.ts +│ ├── social/ +│ │ ├── FollowService.ts +│ │ └── MuteService.ts +│ ├── content/ +│ │ └── PublishingService.ts +│ └── handlers/ +│ └── DomainEventHandlers.ts +│ +├── infrastructure/ # External concerns +│ ├── persistence/ +│ │ ├── IndexedDbRepository.ts +│ │ └── LocalStorageRepository.ts +│ ├── nostr/ +│ │ ├── NostrClient.ts +│ │ └── RelayPool.ts +│ ├── signing/ +│ │ ├── NsecSigner.ts +│ │ ├── Nip07Signer.ts +│ │ └── BunkerSigner.ts +│ └── translation/ +│ └── TranslationApiClient.ts +│ +├── presentation/ # React components +│ ├── providers/ # Thin wrappers around application services +│ ├── components/ +│ ├── pages/ +│ └── hooks/ +│ +└── shared/ # Cross-cutting utilities + ├── lib/ + └── constants/ +``` + +--- + +## 7. Migration Strategy + +### 7.1 Incremental Approach + +1. **Week 1-2:** Create `domain/shared/` with Value Objects (Pubkey, RelayUrl, EventId) +2. **Week 3-4:** Migrate one bounded context (recommend: Social Graph) +3. **Week 5-6:** Add domain services, refactor related providers +4. **Week 7-8:** Introduce repositories for the migrated context +5. **Ongoing:** Repeat for remaining contexts + +### 7.2 Coexistence Strategy + +During migration, old and new code can coexist: + +```typescript +// Adapter to bridge old and new +export function legacyPubkeyToDomain(pubkey: string): Pubkey { + return Pubkey.fromHex(pubkey) +} + +export function domainPubkeyToLegacy(pubkey: Pubkey): string { + return pubkey.toHex() +} +``` + +### 7.3 Testing Strategy + +- Unit test domain objects in isolation +- Integration test application services +- Keep existing component tests as regression safety + +--- + +## 8. Metrics for Success + +| Metric | Current State | Target State | +|--------|---------------|--------------| +| Domain logic in providers | ~60% | <10% | +| Value Objects usage | 0 | 15+ types | +| Explicit aggregates | 0 | 5 aggregates | +| Domain events | 0 (implicit) | 10+ event types | +| Repository interfaces | 0 | 5 repositories | +| Test coverage (domain) | N/A | >80% | + +--- + +## 9. Risks and Mitigations + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Breaking changes during migration | Medium | High | Incremental migration, adapter layer | +| Performance regression | Low | Medium | Benchmark critical paths, optimize lazily | +| Team learning curve | Medium | Medium | Documentation, pair programming | +| Over-engineering | Medium | Medium | YAGNI principle, concrete before abstract | + +--- + +## 10. Conclusion + +The Smesh codebase has a solid foundation that can be evolved toward DDD principles. The key recommendations are: + +1. **Immediate:** Introduce Value Objects for Pubkey, RelayUrl, EventId +2. **Short-term:** Create rich domain entities with behavior +3. **Medium-term:** Extract domain services from providers +4. **Long-term:** Full bounded context separation with repositories + +The Nostr protocol's event-sourced nature is a natural fit for DDD, and the existing type definitions provide a starting point for a rich domain model. The main effort will be moving from an anemic model to entities with behavior, and establishing clear aggregate boundaries. + +--- + +*Generated: December 2024* +*Analysis based on DDD principles from Eric Evans and Vaughn Vernon*