# 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*