ADR-0009: Domain-Driven Design Architecture
Status
Accepted - 2025-01-26
Context
TVL Platform is a complex vacation rental management system with multiple business domains (properties, bookings, payments, channels, etc.). We need an architecture pattern that:
Business Requirements
- Handle complex business logic (pricing rules, availability conflicts, channel synchronization)
- Support rapid feature development (MVP.0 → V3.0 over 24 months)
- Enable team scalability (2-3 engineers → 10+ engineers)
- Maintain system flexibility as requirements evolve
Technical Requirements
- Clear boundaries between different parts of the system
- Minimize coupling between unrelated features
- Enable independent deployment of services (future)
- Support complex domain logic (pricing algorithms, calendar conflicts)
- Facilitate testing and refactoring
Constraints
- Small initial team (need clear structure for onboarding)
- Monorepo structure (not microservices initially)
- Must work with PostgreSQL (shared database)
- TypeScript/Node.js stack
Decision
Domain-Driven Design (DDD) with 14 bounded contexts organized as a modular monolith.
The 14 Domains
Core Business Domains
- Identity & Tenancy - Org/Account/User management, multi-tenancy
- Authorization & Access - RBAC, permissions, RLS policies
- Supply - Properties (spaces) and inventory (units)
- Availability & Calendar - Time-based inventory, date ranges, conflicts
- Pricing & Revenue - Dynamic pricing, fees, quotes, revenue splits
- Bookings - Reservation lifecycle (quote → hold → booking)
- Payments & Financials - Payment processing, refunds, payouts, ledger
Supporting Domains
- Content & Media - Descriptions, photos, translations
- Channels & Distribution - Multi-channel syndication (Hostaway, Airbnb, VRBO)
- Delegation & Collaboration - Cross-org permissions, property assignments
- Search & Indexing - Full-text search, geospatial, filters
- Analytics & Audit - Metrics, compliance, audit trail
Cross-Cutting Domains
- System Architecture - Event-driven patterns, caching, queues
- Events & Experiences - Event management, ticketing (V2.0+)
Rationale
- Clear Boundaries: Each domain has well-defined responsibilities
- Team Scaling: Teams can own specific domains (e.g., Payments team)
- Complexity Management: Complex logic isolated within domain boundaries
- Ubiquitous Language: Business terms match code (e.g., "Unit" not "Listing")
- Independent Evolution: Domains can evolve independently
Alternatives Considered
Alternative 1: Layered Architecture (MVC)
Rejected
Pros:
- Simple to understand (controllers → services → models)
- Familiar to most developers
- Works well for CRUD applications
Cons:
- Business logic scattered across services
- No clear domain boundaries (everything mixes together)
- Difficult to test (tight coupling between layers)
- Doesn't scale to complex business logic
- "Fat services" anti-pattern (services do everything)
Decision: Too simplistic for complex vacation rental business logic.
Alternative 2: Microservices
Rejected (for MVP)
Pros:
- Independent deployment
- Technology diversity (different services use different tech)
- Team autonomy (teams own services end-to-end)
Cons:
- Operational Complexity: Service discovery, API gateway, distributed tracing
- Data Consistency: Distributed transactions are hard
- Performance: Network latency between services
- Team Size: Requires 5+ engineers per service (we have 2-3 total)
- Overkill for MVP: Premature optimization
Decision: Start with modular monolith, migrate to microservices if needed (V2.0+).
Alternative 3: Feature-Based Organization
Rejected
Pros:
- Organized by features (e.g., features/booking,features/pricing)
- Simple to navigate
- Co-locate related code
Cons:
- No domain boundaries (features cross-cut domains)
- Business logic leaks between features
- Difficult to extract to microservices later
- No ubiquitous language (terms are ambiguous)
Decision: DDD provides better structure for complex business logic.
Alternative 4: Anemic Domain Model
Rejected
Pros:
- Simple data structures (DTOs)
- Services contain all logic
- Easy to understand initially
Cons:
- Business logic scattered across services
- Violates single responsibility principle
- Difficult to test (mock all services)
- No encapsulation (data structures are dumb)
Decision: Rich domain model better encapsulates business rules.
Consequences
Positive
- 
Clarity - Each domain has clear responsibilities
- Ubiquitous language (business terms match code)
- New developers can understand one domain at a time
 
- 
Modularity - Domains are loosely coupled
- Changes in one domain don't break others
- Can extract to microservices later if needed
 
- 
Testability - Domain logic tested in isolation
- Clear boundaries for unit tests
- Integration tests scope to domain boundaries
 
- 
Team Scaling - Teams can own specific domains
- Parallel development (teams work on different domains)
- Clear ownership (Booking team owns Bookings domain)
 
- 
Business Alignment - Code structure mirrors business structure
- Business stakeholders understand domain names
- Features map to domain capabilities
 
Negative
- 
Initial Complexity - More upfront design (define domain boundaries)
- Steeper learning curve (DDD concepts)
- Mitigation: Comprehensive documentation, domain specifications
 
- 
Boilerplate - More files and folders (domain structure)
- Domain events, repositories, services
- Mitigation: Code generation tools, templates
 
- 
Cross-Domain Coordination - Features may span multiple domains
- Requires domain events for communication
- Mitigation: Event-driven architecture (transactional outbox)
 
- 
Premature Boundaries - Risk of wrong domain boundaries (will refactor)
- Mitigation: Start with coarse boundaries, refine over time
 
Domain Architecture
Modular Monolith Structure
packages/
├── identity/          # Domain: Identity & Tenancy
│   ├── domain/        # Entities, value objects, domain logic
│   ├── application/   # Use cases, services
│   ├── infrastructure/# Repositories, external integrations
│   └── api/           # HTTP endpoints
│
├── supply/            # Domain: Supply
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── api/
│
├── bookings/          # Domain: Bookings
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── api/
│
└── shared/            # Shared kernel (types, utils, events)
    ├── events/        # Domain events
    ├── types/         # Shared types
    └── utils/         # Shared utilities
Domain Layers
1. Domain Layer (Core Business Logic)
// domain/entities/Booking.ts
export class Booking {
  private constructor(
    public readonly id: string,
    public readonly guestName: string,
    public readonly checkIn: Date,
    public readonly checkOut: Date,
    private _status: BookingStatus
  ) {}
  // Business logic encapsulated in entity
  public confirm(): Result<void> {
    if (this._status !== 'pending') {
      return { ok: false, error: new Error('Only pending bookings can be confirmed') };
    }
    this._status = 'confirmed';
    return { ok: true, value: undefined };
  }
  public get status(): BookingStatus {
    return this._status;
  }
}
2. Application Layer (Use Cases)
// application/use-cases/ConfirmBooking.ts
export class ConfirmBooking {
  constructor(
    private bookingRepository: BookingRepository,
    private eventBus: EventBus
  ) {}
  async execute(bookingId: string): Promise<Result<Booking>> {
    const booking = await this.bookingRepository.findById(bookingId);
    if (!booking) {
      return { ok: false, error: new Error('Booking not found') };
    }
    const result = booking.confirm();
    if (!result.ok) {
      return result;
    }
    await this.bookingRepository.save(booking);
    await this.eventBus.publish(new BookingConfirmedEvent(booking));
    return { ok: true, value: booking };
  }
}
3. Infrastructure Layer (Persistence, External APIs)
// infrastructure/repositories/BookingRepository.ts
export class BookingRepository {
  constructor(private db: Database) {}
  async findById(id: string): Promise<Booking | null> {
    const row = await this.db.query('SELECT * FROM bookings WHERE id = $1', [id]);
    if (!row) return null;
    return this.toDomain(row);
  }
  async save(booking: Booking): Promise<void> {
    await this.db.query(
      'UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2',
      [booking.status, booking.id]
    );
  }
  private toDomain(row: any): Booking {
    // Map database row to domain entity
  }
}
4. API Layer (HTTP Endpoints)
// api/routes/bookings.ts
export async function confirmBookingRoute(req: Request, reply: Reply) {
  const { bookingId } = req.params;
  const useCase = new ConfirmBooking(bookingRepository, eventBus);
  const result = await useCase.execute(bookingId);
  if (!result.ok) {
    return reply.status(400).send({ error: result.error.message });
  }
  return reply.status(200).send({ booking: result.value });
}
Domain Relationships
Dependencies (Who Depends on Whom)
Identity & Tenancy (foundational)
  ↑
Authorization & Access
  ↑
Supply → Availability → Pricing → Bookings → Payments
  ↑         ↑            ↑           ↑          ↑
  └─────────┴────────────┴───────────┴──────────┘
              Content & Media
              Channels & Distribution
              Search & Indexing
              Analytics & Audit
Rule: Domains can only depend on domains below them (acyclic dependency graph).
Communication Patterns
- 
Synchronous (Direct Call) - Within same domain: Direct function calls
- Across domains: Use cases call other domains via interfaces
 
- 
Asynchronous (Events) - Cross-domain updates: Publish domain events
- Example: BookingConfirmedEvent→ Payments domain listens
 
Implementation Guidelines
1. Ubiquitous Language
Use business terms consistently:
| Term | Meaning | NOT | 
|---|---|---|
| Space | Physical property | |
| Unit | Bookable inventory | |
| Booking | Confirmed reservation | |
| Hold | Temporary reservation | |
| Quote | Price estimate | 
2. Domain Events
Publish events for significant domain changes:
// domain/events/BookingCreatedEvent.ts
export class BookingCreatedEvent {
  constructor(
    public readonly bookingId: string,
    public readonly guestName: string,
    public readonly totalCents: number,
    public readonly occurredAt: Date
  ) {}
}
3. Anti-Corruption Layer
Protect domain from external APIs:
// infrastructure/adapters/HostawayAdapter.ts
export class HostawayAdapter {
  async fetchBooking(id: string): Promise<Booking> {
    const response = await fetch(`https://api.hostaway.com/bookings/${id}`);
    const data = await response.json();
    // Translate Hostaway terms to our domain terms
    return new Booking(
      data.id,
      data.guestFirstName + ' ' + data.guestLastName, // ← Hostaway has firstName/lastName
      new Date(data.arrivalDate), // ← Hostaway uses "arrivalDate"
      new Date(data.departureDate), // ← Hostaway uses "departureDate"
      this.mapStatus(data.status) // ← Translate Hostaway status to our status
    );
  }
}
Validation Checklist
-  All 14 domains specified in /docs/specifications/domains/
- Domain boundaries documented
- Ubiquitous language glossary created
- Domain events defined
- Dependencies are acyclic (no circular dependencies)
- Each domain has specification document
- Anti-corruption layers for external APIs
- Domain logic tested in isolation