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>
This commit is contained in:
mleku
2025-12-26 12:28:32 +02:00
parent 13b3b82443
commit 0c6de715c4

786
DDD_ANALYSIS.md Normal file
View File

@@ -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<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:
```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<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:
```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<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:**
```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<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)
}
}
```
```typescript
// 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)
}
}
```
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 (
<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:**
```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<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.
```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<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:
```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*