Skip to main content

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

  1. Identity & Tenancy - Org/Account/User management, multi-tenancy
  2. Authorization & Access - RBAC, permissions, RLS policies
  3. Supply - Properties (spaces) and inventory (units)
  4. Availability & Calendar - Time-based inventory, date ranges, conflicts
  5. Pricing & Revenue - Dynamic pricing, fees, quotes, revenue splits
  6. Bookings - Reservation lifecycle (quote → hold → booking)
  7. Payments & Financials - Payment processing, refunds, payouts, ledger

Supporting Domains

  1. Content & Media - Descriptions, photos, translations
  2. Channels & Distribution - Multi-channel syndication (Hostaway, Airbnb, VRBO)
  3. Delegation & Collaboration - Cross-org permissions, property assignments
  4. Search & Indexing - Full-text search, geospatial, filters
  5. Analytics & Audit - Metrics, compliance, audit trail

Cross-Cutting Domains

  1. System Architecture - Event-driven patterns, caching, queues
  2. Events & Experiences - Event management, ticketing (V2.0+)

Rationale

  1. Clear Boundaries: Each domain has well-defined responsibilities
  2. Team Scaling: Teams can own specific domains (e.g., Payments team)
  3. Complexity Management: Complex logic isolated within domain boundaries
  4. Ubiquitous Language: Business terms match code (e.g., "Unit" not "Listing")
  5. 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

  1. Clarity

    • Each domain has clear responsibilities
    • Ubiquitous language (business terms match code)
    • New developers can understand one domain at a time
  2. Modularity

    • Domains are loosely coupled
    • Changes in one domain don't break others
    • Can extract to microservices later if needed
  3. Testability

    • Domain logic tested in isolation
    • Clear boundaries for unit tests
    • Integration tests scope to domain boundaries
  4. Team Scaling

    • Teams can own specific domains
    • Parallel development (teams work on different domains)
    • Clear ownership (Booking team owns Bookings domain)
  5. Business Alignment

    • Code structure mirrors business structure
    • Business stakeholders understand domain names
    • Features map to domain capabilities

Negative

  1. Initial Complexity

    • More upfront design (define domain boundaries)
    • Steeper learning curve (DDD concepts)
    • Mitigation: Comprehensive documentation, domain specifications
  2. Boilerplate

    • More files and folders (domain structure)
    • Domain events, repositories, services
    • Mitigation: Code generation tools, templates
  3. Cross-Domain Coordination

    • Features may span multiple domains
    • Requires domain events for communication
    • Mitigation: Event-driven architecture (transactional outbox)
  4. 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

  1. Synchronous (Direct Call)

    • Within same domain: Direct function calls
    • Across domains: Use cases call other domains via interfaces
  2. Asynchronous (Events)

    • Cross-domain updates: Publish domain events
    • Example: BookingConfirmedEvent → Payments domain listens

Implementation Guidelines

1. Ubiquitous Language

Use business terms consistently:

TermMeaningNOT
SpacePhysical propertyListing, Property
UnitBookable inventoryRoom, Listing
BookingConfirmed reservationReservation, Order
HoldTemporary reservationOption, Pending
QuotePrice estimateProposal

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

References