Files
next.orly.dev/DDD_ANALYSIS.md
mleku c9a03db395
Some checks failed
Go / build-and-release (push) Has been cancelled
Fix Blossom CORS headers and add root-level upload routes (v0.36.12)
- Add proper CORS headers for Blossom endpoints including X-SHA-256,
  X-Content-Length, X-Content-Type headers required by blossom-client-sdk
- Add root-level Blossom routes (/upload, /media, /mirror, /report, /list/)
  for clients like Jumble that expect Blossom at root
- Export BaseURLKey from pkg/blossom for use by app handlers
- Make blossomRootHandler return URLs with /blossom prefix so blob
  downloads work via the registered /blossom/ route
- Remove Access-Control-Allow-Credentials header (not needed for * origin)
- Add Access-Control-Expose-Headers for X-Reason and other response headers

Files modified:
- app/blossom.go: Add blossomRootHandler, use exported BaseURLKey
- app/server.go: Add CORS handling for blossom paths, register root routes
- pkg/blossom/server.go: Fix CORS headers, export BaseURLKey
- pkg/blossom/utils.go: Minor formatting
- pkg/version/version: Bump to v0.36.12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:32:52 +01:00

24 KiB

Domain-Driven Design Analysis: ORLY Relay

This document provides a comprehensive Domain-Driven Design (DDD) analysis of the ORLY Nostr relay codebase, evaluating its alignment with DDD principles and identifying opportunities for improvement.


Key Recommendations Summary

# Recommendation Impact Effort
1 Formalize Domain Events High Medium
2 Strengthen Aggregate Boundaries High Medium
3 Extract Application Services Medium High
4 Establish Ubiquitous Language Glossary Medium Low
5 Add Domain-Specific Error Types Medium Low
6 Enforce Value Object Immutability Low Low
7 Document Context Map Medium Low

Table of Contents

  1. Executive Summary
  2. Strategic Design Analysis
  3. Tactical Design Analysis
  4. Anti-Patterns Identified
  5. Detailed Recommendations
  6. Implementation Checklist
  7. Appendix: File References

Executive Summary

ORLY demonstrates mature DDD adoption for a system of its complexity. The codebase exhibits clear bounded context separation, proper repository patterns with multiple backend implementations, and well-designed interface segregation that prevents circular dependencies.

Strengths:

  • Clear separation between app/ (application layer) and pkg/ (domain/infrastructure)
  • Repository pattern with three interchangeable backends (Badger, Neo4j, WasmDB)
  • Interface-based ACL system with pluggable implementations
  • Per-connection aggregate isolation in Listener
  • Strong use of Go interfaces for dependency inversion

Areas for Improvement:

  • Domain events are implicit rather than explicit types
  • Some aggregates expose mutable state via public fields
  • Handler methods mix application orchestration with domain logic
  • Ubiquitous language is partially documented

Overall DDD Maturity Score: 7/10


Strategic Design Analysis

Bounded Contexts

ORLY organizes code into distinct bounded contexts, each with its own model and language:

1. Event Storage Context (pkg/database/)

  • Responsibility: Persistent storage of Nostr events
  • Key Abstractions: Database interface, Subscription, Payment
  • Implementations: Badger (embedded), Neo4j (graph), WasmDB (browser)
  • File: pkg/database/interface.go:17-109

2. Access Control Context (pkg/acl/)

  • Responsibility: Authorization decisions for read/write operations
  • Key Abstractions: I interface, Registry, access levels
  • Implementations: None, Follows, Managed
  • Files: pkg/acl/acl.go, pkg/interfaces/acl/acl.go:21-34

3. Event Policy Context (pkg/policy/)

  • Responsibility: Event filtering, validation, and rate limiting rules
  • Key Abstractions: Rule, Kinds, PolicyManager
  • Invariants: Whitelist/blacklist precedence, size limits, tag requirements
  • File: pkg/policy/policy.go:58-180

4. Connection Management Context (app/)

  • Responsibility: WebSocket lifecycle, message routing, authentication
  • Key Abstractions: Listener, Server, message handlers
  • File: app/listener.go:24-52

5. Protocol Extensions Context (pkg/protocol/)

  • Responsibility: NIP implementations beyond core protocol
  • Subcontexts:
    • NIP-43 Membership (pkg/protocol/nip43/)
    • Graph queries (pkg/protocol/graph/)
    • NWC payments (pkg/protocol/nwc/)
    • Sync/replication (pkg/sync/)

6. Rate Limiting Context (pkg/ratelimit/)

  • Responsibility: Adaptive throttling based on system load
  • Key Abstractions: Limiter, Monitor, PID controller
  • Integration: Memory pressure from database backends

Context Map

┌─────────────────────────────────────────────────────────────────────────┐
│                        Connection Management (app/)                     │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                  │
│  │   Server    │───▶│  Listener   │───▶│  Handlers   │                  │
│  └─────────────┘    └─────────────┘    └─────────────┘                  │
└────────┬────────────────────┬────────────────────┬──────────────────────┘
         │                    │                    │
         │ [Conformist]       │ [Customer-Supplier]│ [Customer-Supplier]
         ▼                    ▼                    ▼
┌────────────────┐   ┌────────────────┐   ┌────────────────┐
│  Access Control│   │  Event Storage │   │  Event Policy  │
│   (pkg/acl/)   │   │ (pkg/database/)│   │  (pkg/policy/) │
│                │   │                │   │                │
│  Registry ◀────┼───┼────Conformist──┼───┼─▶ Manager      │
└────────────────┘   └────────────────┘   └────────────────┘
         │                    │                    │
         │                    │ [Shared Kernel]    │
         │                    ▼                    │
         │           ┌────────────────┐            │
         │           │  Event Entity  │            │
         │           │(git.mleku.dev/ │◀───────────┘
         │           │ mleku/nostr)   │
         │           └────────────────┘
         │                    │
         │ [Anti-Corruption]  │ [Customer-Supplier]
         ▼                    ▼
┌────────────────┐   ┌────────────────┐
│  Rate Limiting │   │   Protocol     │
│ (pkg/ratelimit)│   │   Extensions   │
│                │   │ (pkg/protocol/)│
└────────────────┘   └────────────────┘

Integration Patterns Identified:

Upstream Downstream Pattern Notes
nostr library All contexts Shared Kernel Event, Filter, Tag types
Database ACL, Policy Customer-Supplier Query for follow lists, permissions
Policy Handlers Conformist Handlers respect policy decisions
ACL Handlers Conformist Handlers respect access levels
Rate Limit Database Anti-Corruption Load monitor abstraction

Subdomain Classification

Subdomain Type Justification
Event Storage Core Central to relay's value proposition
Access Control Core Key differentiator (WoT, follows-based)
Event Policy Core Enables complex filtering rules
Connection Management Supporting Standard WebSocket infrastructure
Rate Limiting Supporting Operational concern, not domain-specific
NIP-43 Membership Core Unique invite-based access model
Sync/Replication Supporting Infrastructure for federation

Tactical Design Analysis

Entities

Entities are objects with identity that persists across state changes.

Listener (Connection Entity)

// app/listener.go:24-52
type Listener struct {
    conn             *websocket.Conn      // Identity: connection handle
    challenge        atomicutils.Bytes    // Auth challenge state
    authedPubkey     atomicutils.Bytes    // Authenticated identity
    subscriptions    map[string]context.CancelFunc
    // ... more fields
}
  • Identity: WebSocket connection pointer
  • Lifecycle: Created on connect, destroyed on disconnect
  • Invariants: Only one authenticated pubkey per connection

InviteCode (NIP-43 Entity)

// pkg/protocol/nip43/types.go:26-31
type InviteCode struct {
    Code      string      // Identity: unique code
    ExpiresAt time.Time
    UsedBy    []byte      // Tracks consumption
    CreatedAt time.Time
}
  • Identity: Unique code string
  • Lifecycle: Created → Valid → Used/Expired
  • Invariants: Cannot be reused once consumed

Subscription (Payment Entity)

// pkg/database/interface.go (implied by methods)
// GetSubscription, ExtendSubscription, RecordPayment
  • Identity: Pubkey
  • Lifecycle: Trial → Active → Expired
  • Invariants: Can only extend if not expired

Value Objects

Value objects are immutable and defined by their attributes, not identity.

IdPkTs (Event Reference)

// pkg/interfaces/store/store_interface.go:63-68
type IdPkTs struct {
    Id  []byte   // Event ID
    Pub []byte   // Pubkey
    Ts  int64    // Timestamp
    Ser uint64   // Serial number
}
  • Equality: By all fields
  • Issue: Should be immutable but uses mutable slices

Kinds (Policy Specification)

// pkg/policy/policy.go:55-63
type Kinds struct {
    Whitelist []int `json:"whitelist,omitempty"`
    Blacklist []int `json:"blacklist,omitempty"`
}
  • Equality: By whitelist/blacklist contents
  • Semantics: Whitelist takes precedence over blacklist

Rule (Policy Rule)

// pkg/policy/policy.go:75-180
type Rule struct {
    Description       string
    WriteAllow        []string
    WriteDeny         []string
    MaxExpiry         *int64
    SizeLimit         *int64
    // ... 25+ fields
}
  • Issue: Very large, could benefit from decomposition
  • Binary caches: Performance optimization for hex→binary conversion

WriteRequest (Message Value)

// pkg/protocol/publish/types.go (implied)
type WriteRequest struct {
    Data      []byte
    MsgType   int
    IsControl bool
    Deadline  time.Time
}

Aggregates

Aggregates are clusters of entities/value objects with consistency boundaries.

Listener Aggregate

  • Root: Listener
  • Members: Subscriptions map, auth state, write channel
  • Boundary: Per-connection isolation
  • Invariants:
    • Subscriptions must exist before receiving matching events
    • AUTH must complete before other messages check authentication
    • Message processing is serialized within connection
// app/listener.go:226-238 - Aggregate consistency enforcement
l.authProcessing.Lock()
if isAuthMessage {
    // Process AUTH synchronously while holding lock
    l.HandleMessage(req.data, req.remote)
    l.authProcessing.Unlock()
} else {
    l.authProcessing.Unlock()
    // Process concurrently
}

Event Aggregate (External)

  • Root: event.E (from nostr library)
  • Members: Tags, signature, content
  • Invariants:
    • ID must match computed hash
    • Signature must be valid
    • Timestamp must be within bounds
  • Validation: app/handle-event.go:348-390

InviteCode Aggregate

  • Root: InviteCode
  • Members: Code, expiry, usage tracking
  • Invariants:
    • Code uniqueness
    • Single-use enforcement
    • Expiry validation

Repositories

The Repository pattern abstracts persistence for aggregate roots.

Database Interface (Primary Repository)

// pkg/database/interface.go:17-109
type Database interface {
    // Event persistence
    SaveEvent(c context.Context, ev *event.E) (exists bool, err error)
    QueryEvents(c context.Context, f *filter.F) (evs event.S, err error)
    DeleteEvent(c context.Context, eid []byte) error

    // Subscription management
    GetSubscription(pubkey []byte) (*Subscription, error)
    ExtendSubscription(pubkey []byte, days int) error

    // NIP-43 membership
    AddNIP43Member(pubkey []byte, inviteCode string) error
    IsNIP43Member(pubkey []byte) (isMember bool, err error)

    // ... 50+ methods
}

Repository Implementations:

  1. Badger (pkg/database/database.go): Embedded key-value store
  2. Neo4j (pkg/neo4j/): Graph database for social queries
  3. WasmDB (pkg/wasmdb/): Browser IndexedDB for WASM builds

Interface Segregation:

// pkg/interfaces/store/store_interface.go:21-37
type I interface {
    Pather
    io.Closer
    Wiper
    Querier      // QueryForIds
    Querent      // QueryEvents
    Deleter      // DeleteEvent
    Saver        // SaveEvent
    Importer
    Exporter
    Syncer
    // ...
}

Domain Services

Domain services encapsulate logic that doesn't belong to any single entity.

ACL Registry (Access Decision Service)

// pkg/acl/acl.go:40-48
func (s *S) GetAccessLevel(pub []byte, address string) (level string) {
    for _, i := range s.ACL {
        if i.Type() == s.Active.Load() {
            level = i.GetAccessLevel(pub, address)
            break
        }
    }
    return
}
  • Delegates to active ACL implementation
  • Stateless decision based on pubkey and IP

Policy Manager (Event Validation Service)

// pkg/policy/policy.go (P type, CheckPolicy method)
// Evaluates rule chains, scripts, whitelist/blacklist logic
  • Complex rule evaluation logic
  • Script execution for custom validation

InviteManager (Invite Lifecycle Service)

// pkg/protocol/nip43/types.go:34-109
type InviteManager struct {
    codes  map[string]*InviteCode
    expiry time.Duration
}
func (im *InviteManager) GenerateCode() (code string, err error)
func (im *InviteManager) ValidateAndConsume(code string, pubkey []byte) (bool, string)
  • Manages invite code lifecycle
  • Thread-safe with mutex protection

Domain Events

Current State: Domain events are implicit in message flow, not explicit types.

Implicit Events Identified:

Event Trigger Effect
EventPublished SaveEvent() success publishers.Deliver()
EventDeleted Kind 5 processing Cascade delete targets
UserAuthenticated AUTH envelope accepted authedPubkey set
SubscriptionCreated REQ envelope Query + stream setup
MembershipAdded NIP-43 join request ACL update
PolicyUpdated Policy config event messagePauseMutex.Lock()

Anti-Patterns Identified

1. Large Handler Methods (Partial Anemic Domain Model)

Location: app/handle-event.go:183-783 (600+ lines)

Issue: The HandleEvent method contains:

  • Input validation
  • Policy checking
  • ACL verification
  • Signature verification
  • Persistence
  • Event delivery
  • Special case handling (delete, ephemeral, NIP-43)

Impact: Difficult to test, maintain, and understand. Business rules are embedded in orchestration code.

2. Mutable Value Object Fields

Location: pkg/interfaces/store/store_interface.go:63-68

type IdPkTs struct {
    Id  []byte   // Mutable slice
    Pub []byte   // Mutable slice
    Ts  int64
    Ser uint64
}

Impact: Value objects should be immutable. Callers could accidentally mutate shared state.

3. Global Singleton Registry

Location: pkg/acl/acl.go:10

var Registry = &S{}

Impact: Global state makes testing difficult and hides dependencies. Should be injected.

4. Missing Domain Events

Impact: Side effects are coupled to primary operations. Adding new behaviors (logging, analytics, notifications) requires modifying core handlers.

5. Oversized Rule Value Object

Location: pkg/policy/policy.go:75-180

The Rule struct has 25+ fields, suggesting it might benefit from decomposition into smaller, focused value objects:

  • AccessRule (allow/deny lists)
  • SizeRule (limits)
  • TimeRule (expiry, age)
  • ValidationRule (tags, regex)

Detailed Recommendations

1. Formalize Domain Events

Problem: Side effects are tightly coupled to primary operations.

Solution: Create explicit domain event types and a simple event dispatcher.

// pkg/domain/events/events.go
package events

type DomainEvent interface {
    OccurredAt() time.Time
    AggregateID() []byte
}

type EventPublished struct {
    EventID   []byte
    Pubkey    []byte
    Kind      int
    Timestamp time.Time
}

type MembershipGranted struct {
    Pubkey     []byte
    InviteCode string
    Timestamp  time.Time
}

// Simple dispatcher
type Dispatcher struct {
    handlers map[reflect.Type][]func(DomainEvent)
}

Benefits:

  • Decoupled side effects
  • Easier testing
  • Audit trail capability
  • Foundation for event sourcing if needed

Files to Modify:

  • Create pkg/domain/events/
  • Update app/handle-event.go to emit events
  • Update app/handle-nip43.go for membership events

2. Strengthen Aggregate Boundaries

Problem: Aggregate internals are exposed via public fields.

Solution: Use unexported fields with behavior methods.

// Before (current)
type Listener struct {
    authedPubkey atomicutils.Bytes  // Accessible from outside
}

// After (recommended)
type Listener struct {
    authedPubkey atomicutils.Bytes  // Keep as is (already using atomic wrapper)
}

// Add behavior methods
func (l *Listener) IsAuthenticated() bool {
    return len(l.authedPubkey.Load()) > 0
}

func (l *Listener) AuthenticatedPubkey() []byte {
    return l.authedPubkey.Load()
}

func (l *Listener) Authenticate(pubkey []byte) error {
    if l.IsAuthenticated() {
        return ErrAlreadyAuthenticated
    }
    l.authedPubkey.Store(pubkey)
    return nil
}

Benefits:

  • Enforces invariants
  • Clear API surface
  • Easier refactoring

3. Extract Application Services

Problem: Handler methods contain mixed concerns.

Solution: Extract domain logic into focused application services.

// pkg/application/event_service.go
type EventService struct {
    db            database.Database
    policyMgr     *policy.P
    aclRegistry   *acl.S
    eventPublisher EventPublisher
}

func (s *EventService) ProcessIncomingEvent(ctx context.Context, ev *event.E, authedPubkey []byte) (*EventResult, error) {
    // 1. Validate event structure
    if err := s.validateEventStructure(ev); err != nil {
        return nil, err
    }

    // 2. Check policy
    if !s.policyMgr.IsAllowed("write", ev, authedPubkey) {
        return &EventResult{Blocked: true, Reason: "policy"}, nil
    }

    // 3. Check ACL
    if !s.aclRegistry.CanWrite(authedPubkey) {
        return &EventResult{Blocked: true, Reason: "acl"}, nil
    }

    // 4. Persist
    exists, err := s.db.SaveEvent(ctx, ev)
    if err != nil {
        return nil, err
    }

    // 5. Publish domain event
    s.eventPublisher.Publish(events.EventPublished{...})

    return &EventResult{Saved: !exists}, nil
}

Benefits:

  • Testable business logic
  • Handlers become thin orchestrators
  • Reusable across different entry points (WebSocket, HTTP API, CLI)

4. Establish Ubiquitous Language Glossary

Problem: Terminology is inconsistent across the codebase.

Current Inconsistencies:

  • "subscription" (payment) vs "subscription" (REQ filter)
  • "monitor" (rate limit) vs "spider" (sync)
  • "pub" vs "pubkey" vs "author"

Solution: Add a GLOSSARY.md and enforce terms in code reviews.

# ORLY Ubiquitous Language

## Core Domain Terms

| Term | Definition | Code Symbol |
|------|------------|-------------|
| Event | A signed Nostr message | `event.E` |
| Relay | This server | `Server` |
| Connection | WebSocket session | `Listener` |
| Filter | Query criteria for events | `filter.F` |
| **Event Subscription** | Active filter receiving events | `subscriptions map` |
| **Payment Subscription** | Paid access tier | `database.Subscription` |
| Access Level | Permission tier (none/read/write/admin/owner) | `acl.Level` |
| Policy | Event validation rules | `policy.Rule` |

5. Add Domain-Specific Error Types

Problem: Errors are strings or generic types, making error handling imprecise.

Solution: Create typed domain errors.

// pkg/domain/errors/errors.go
package errors

type DomainError struct {
    Code    string
    Message string
    Cause   error
}

var (
    ErrEventInvalid       = &DomainError{Code: "EVENT_INVALID"}
    ErrEventBlocked       = &DomainError{Code: "EVENT_BLOCKED"}
    ErrAuthRequired       = &DomainError{Code: "AUTH_REQUIRED"}
    ErrQuotaExceeded      = &DomainError{Code: "QUOTA_EXCEEDED"}
    ErrInviteCodeInvalid  = &DomainError{Code: "INVITE_INVALID"}
    ErrInviteCodeExpired  = &DomainError{Code: "INVITE_EXPIRED"}
    ErrInviteCodeUsed     = &DomainError{Code: "INVITE_USED"}
)

Benefits:

  • Precise error handling in handlers
  • Better error messages to clients
  • Easier testing

6. Enforce Value Object Immutability

Problem: Value objects use mutable slices.

Solution: Return copies from accessors.

// pkg/interfaces/store/store_interface.go
type IdPkTs struct {
    id  []byte  // unexported
    pub []byte  // unexported
    ts  int64
    ser uint64
}

func NewIdPkTs(id, pub []byte, ts int64, ser uint64) *IdPkTs {
    return &IdPkTs{
        id:  append([]byte(nil), id...),   // Copy
        pub: append([]byte(nil), pub...),  // Copy
        ts:  ts,
        ser: ser,
    }
}

func (i *IdPkTs) ID() []byte  { return append([]byte(nil), i.id...) }
func (i *IdPkTs) Pub() []byte { return append([]byte(nil), i.pub...) }
func (i *IdPkTs) Ts() int64   { return i.ts }
func (i *IdPkTs) Ser() uint64 { return i.ser }

7. Document Context Map

Problem: Context relationships are implicit.

Solution: Add a CONTEXT_MAP.md documenting boundaries and integration patterns.

The diagram in the Context Map section above should be maintained as living documentation.


Implementation Checklist

Currently Satisfied

  • Bounded contexts identified with clear boundaries
  • Repositories abstract persistence for aggregate roots
  • Multiple repository implementations (Badger/Neo4j/WasmDB)
  • Interface segregation prevents circular dependencies
  • Configuration centralized (app/config/config.go)
  • Per-connection aggregate isolation
  • Access control as pluggable strategy pattern

Needs Attention

  • Ubiquitous language documented and used consistently
  • Context map documenting integration patterns
  • Domain events capture important state changes
  • Entities have behavior, not just data
  • Value objects are fully immutable
  • No business logic in application services (orchestration only)
  • No infrastructure concerns in domain layer

Appendix: File References

Core Domain Files

File Purpose
pkg/database/interface.go Repository interface (50+ methods)
pkg/interfaces/acl/acl.go ACL interface definition
pkg/interfaces/store/store_interface.go Store sub-interfaces
pkg/policy/policy.go Policy rules and evaluation
pkg/protocol/nip43/types.go NIP-43 invite management

Application Layer Files

File Purpose
app/server.go HTTP/WebSocket server setup
app/listener.go Connection aggregate
app/handle-event.go EVENT message handler
app/handle-req.go REQ message handler
app/handle-auth.go AUTH message handler
app/handle-nip43.go NIP-43 membership handlers

Infrastructure Files

File Purpose
pkg/database/database.go Badger implementation
pkg/neo4j/ Neo4j implementation
pkg/wasmdb/ WasmDB implementation
pkg/ratelimit/limiter.go Rate limiting
pkg/sync/manager.go Distributed sync

Generated: 2025-12-23 Analysis based on ORLY codebase v0.36.10