Files
smesh/DDD_ANALYSIS.md
mleku 0c6de715c4 docs: add Domain-Driven Design analysis report
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 <noreply@anthropic.com>
2025-12-26 12:28:32 +02:00

25 KiB

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:

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

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

// 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<TProfile | null> {
    // 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:

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

interface ISigner {
  getPublicKey(): Promise<string>
  signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent>
  nip04Encrypt(pubkey: string, plainText: string): Promise<string>
  nip04Decrypt(pubkey: string, cipherText: string): Promise<string>
}

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:

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
  1. Introduce Value Objects for primitives:
// 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
  }
}
// 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
  }
}
  1. Create rich domain entities:
// src/domain/social/FollowList.ts
export class FollowList {
  private constructor(
    private readonly _ownerPubkey: Pubkey,
    private _following: Set<string>,
    private _petnames: Map<string, string>
  ) {}

  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:
// 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<PublishedNote> {
    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)
  }
}
// src/domain/relay/RelaySelector.ts
export class RelaySelector {
  constructor(
    private readonly userRelayList: RelayList,
    private readonly mentionRelayResolver: MentionRelayResolver
  ) {}

  async selectForPublishing(note: Note): Promise<RelayUrl[]> {
    const writeRelays = this.userRelayList.writeRelays()
    const mentionRelays = await this.resolveMentionRelays(note.mentions)

    return this.mergeAndDeduplicate(writeRelays, mentionRelays)
  }
}
  1. Refactor providers to use domain services:
// 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 (
    <ContentContext.Provider value={{ publishNote }}>
      {children}
    </ContentContext.Provider>
  )
}

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:

// 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.

// src/domain/social/FollowListRepository.ts (Interface in domain)
export interface FollowListRepository {
  findByOwner(pubkey: Pubkey): Promise<FollowList | null>
  save(followList: FollowList): Promise<void>
}

// src/infrastructure/persistence/IndexedDbFollowListRepository.ts
export class IndexedDbFollowListRepository implements FollowListRepository {
  constructor(
    private readonly indexedDb: IndexedDbService,
    private readonly clientService: ClientService
  ) {}

  async findByOwner(pubkey: Pubkey): Promise<FollowList | null> {
    // 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<void> {
    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.

// 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<void> {
    // 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:

// 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