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>
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, validationservices/- Data fetching, caching, persistenceproviders/- 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:
-
Identity Context
- Concerns: Key management, signing, account switching
- Current:
NostrProvider,ISignerimplementations - Entities: Account, Signer
-
Social Graph Context
- Concerns: Following, muting, trust, pinned users
- Current:
FollowListProvider,MuteListProvider,UserTrustProvider - Entities: User, FollowList, MuteList
-
Content Context
- Concerns: Creating and publishing events
- Current:
lib/draft-event.ts, publishing logic in providers - Entities: Note, Reaction, Repost, Bookmark
-
Feed Context
- Concerns: Timeline construction, filtering, display
- Current:
FeedProvider,KindFilterProvider,NotificationProvider - Entities: Feed, Filter, Timeline
-
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,PinListare 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:
- 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
- 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
}
}
- 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:
- 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)
}
}
- 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
- Week 1-2: Create
domain/shared/with Value Objects (Pubkey, RelayUrl, EventId) - Week 3-4: Migrate one bounded context (recommend: Social Graph)
- Week 5-6: Add domain services, refactor related providers
- Week 7-8: Introduce repositories for the migrated context
- 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:
- Immediate: Introduce Value Objects for Pubkey, RelayUrl, EventId
- Short-term: Create rich domain entities with behavior
- Medium-term: Extract domain services from providers
- 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