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:
786
DDD_ANALYSIS.md
Normal file
786
DDD_ANALYSIS.md
Normal 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*
|
||||
Reference in New Issue
Block a user