Skip to main content

Domain-Driven Design

Based on RoundTrip Domain-Driven Design Document v1.0 — March 2026


What is Domain-Driven Design?

Domain-Driven Design (DDD) is a software development approach introduced by Eric Evans in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. Its central premise is that the structure and language of software code — class names, method names, module names — should match the business domain it models. The software becomes a direct reflection of business reality rather than an arbitrary technical abstraction layered on top of it.

DDD distinguishes between Strategic Design (how to break a large domain into manageable sub-domains and team boundaries) and Tactical Design (how to structure code within a bounded context using specific patterns). Both are applied in RoundTrip.

Why DDD for RoundTrip?

RoundTrip is not a simple CRUD application — it has genuine domain complexity:

  • Service tickets follow strict state machine rules with business consequences for invalid transitions
  • Inventory consumption must be atomic with ticket part usage and cannot go negative
  • Multi-tenancy means all data access must be scoped — a business rule with system-wide implications
  • Billing amounts are derived from ticket part usage with pricing snapshots — not live lookups

These are not infrastructure problems — they are business logic problems. DDD gives us the tools to handle this complexity in a way that is understandable, testable, and maintainable as the domain evolves.

:::info Core DDD Philosophy for RoundTrip Developers The domain layer must be able to be read aloud to a business stakeholder and make complete sense to them. If a method name, class name, or variable requires a technical explanation rather than a business explanation, it belongs in infrastructure — not in the domain. The domain layer has no idea databases, HTTP, or background jobs exist. It only knows about business concepts. :::


Strategic Design

Core Domain vs Supporting Domains

In DDD, every system has a Core Domain — the part unique to the business that provides genuine competitive advantage. This is where the most investment in domain modelling is made.

Sub-domainTypeRationaleApproach
Ticket Lifecycle ManagementCore DomainThe rules governing how a service job moves from creation to completion are RoundTrip's core differentiator. Assignment, technician tracking, part capture, status transitions.Full DDD tactical patterns. Rich aggregate. All invariants enforced in domain layer.
Inventory ManagementSupporting DomainParts tracking is critical but not unique. Moderate domain complexity (stock levels, consumption, audit trail).DDD aggregate (InventoryItem). Simpler than Core Domain but fully modelled.
Billing & InvoicingSupporting DomainInvoicing is complex but well-understood. The domain models invoices, quotes, and line items. Payment processing is generic.Invoice and Quote aggregates. Payment processing delegated to Stripe.
Client ManagementSupporting DomainClient records with service history links. Important but not complex enough to justify full DDD depth.Client aggregate with straightforward rules.
Identity & AuthenticationGeneric Sub-domainAuthentication is a solved problem. No competitive advantage in building custom identity.Entra External ID handles this entirely. Domain has UserId as a value object reference only.
Email & SMS DeliveryGeneric Sub-domainCommodity service. No business logic beyond triggering the right message.SendGrid and Twilio in Infrastructure. Domain raises events; infrastructure handles delivery.
PDF GenerationGeneric Sub-domainPresentation concern. No domain logic involved.QuestPDF in Infrastructure. Domain models the data; infrastructure renders the PDF.

Bounded Contexts

RoundTrip is organised into six bounded contexts. Each context has its own domain model, its own ubiquitous language, and integrates with other contexts through well-defined boundaries.

┌──────────────────────────────────────────────────────────────┐
│ ROUNDTRIP CONTEXT MAP │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ USERS CONTEXT [Supporting] │ │
│ │ Technician, TenantUser, TenantAdmin │ │
│ │ Published Language: TechnicianReference │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ TechnicianReference read model │
│ ┌──────────────────────▼───────────────────────────────┐ │
│ │ TICKETS CONTEXT [CORE DOMAIN] ★ │ │
│ │ ServiceTicket, PartUsage, TicketNote │ │
│ │ Raises: TicketCreated, TicketAssigned, │ │
│ │ TicketCompleted, PartUsageRecorded │ │
│ └────┬──────────────────────┬───────────────────────────┘ │
│ │ PartUsageRecorded │ TicketCompleted │
│ │ │ │
│ ┌────▼──────────────┐ ┌───▼──────────────────────────┐ │
│ │ INVENTORY │ │ BILLING CONTEXT │ │
│ │ CONTEXT │ │ Invoice, Quote, LineItem │ │
│ │ InventoryItem, │ │ Consumes: TicketCompleted │ │
│ │ StockMovement │ │ to trigger invoice gen │ │
│ └───────────────────┘ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ CLIENTS CONTEXT [Supporting] │ │
│ │ Client, ClientAddress │ │
│ │ Customer/Supplier to Tickets context │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

Context Integration Patterns

FromToPatternMechanismConsistency
TicketsInventoryDomain EventPartUsageRecordedEventInventoryItem.Consume()Eventual
TicketsBillingDomain EventTicketCompletedEvent → invoice generation Hangfire jobEventual
UsersTicketsPublished LanguageTechnicianReference DTO — read model for assignment UISync read
ClientsTicketsCustomer/SupplierClientReference DTO used in ticket creationSync lookup

Tactical Design Patterns

Tactical DDD provides building blocks for implementing rich domain models. Each pattern has a specific role. Misusing a pattern is a common source of domain model degradation.

P-01: Aggregates & Aggregate Roots

An Aggregate is a cluster of domain objects that must be changed together to maintain consistency. The Aggregate Root is the single entry point — all external code interacts with the root, never with internal entities directly.

In RoundTrip: ServiceTicket is the primary aggregate root. All changes to PartUsage, TicketNote, and TicketAttachment happen through ServiceTicket methods — never directly.

// ❌ WRONG — bypasses aggregate
dbContext.PartUsages.Add(partUsage);

// ✅ CORRECT — through aggregate root
ticket.AddPartUsage(itemId, qty, photo);
repository.Update(ticket);

P-02: Value Objects

Value Objects have no identity — they are defined entirely by their attributes. Two value objects with the same attributes are equal. They must be immutable. Implemented as readonly record struct in RoundTrip.

In RoundTrip: ServiceAddress, Money, ContactInfo, GpsCoordinate, TicketNumber, SKU. Money always carries its currency — arithmetic without currency is impossible by design.

// ❌ WRONG — primitive obsession
public decimal Amount;
public string Currency;

// ✅ CORRECT — value object
public Money UnitCost { get; }
// Money = Amount + Currency, always together

:::caution ServiceAddress.Create() Normalizes State and Country ServiceAddress.Create() automatically normalizes State and Country to uppercase. Do not pre-format these values before passing them in. :::

P-03: Domain Events

A Domain Event is something significant that happened in the domain — named in past tense. Events are raised inside aggregate methods and dispatched after the transaction commits. They decouple bounded contexts without creating direct dependencies.

In RoundTrip: Events are raised inside aggregate methods, collected by TransactionBehavior, and dispatched via IDomainEventPublisher after SaveChanges succeeds.

// ❌ WRONG — direct cross-context call
_billingService.GenerateInvoice(ticket);

// ✅ CORRECT — domain event
RaiseDomainEvent(new TicketCompletedEvent(Id, TenantId));
// Handler triggers invoice generation in a separate transaction

:::danger Do Not Use IMediator.Publish for Domain Events Domain events are dispatched via IDomainEventPublisher, not IMediator.Publish(). The Cyrus source generator does not generate notification handler dispatch. Domain event handlers are registered as IDomainEventHandler<T> in MediatorConfig.cs. :::

P-04: Domain Services

A Domain Service encapsulates domain logic that doesn't naturally belong to any single entity — typically logic that involves multiple aggregates or requires coordination. Domain Services are stateless and live in the Domain layer.

In RoundTrip: RouteOptimizationService, TechnicianAvailabilityService, TaxCalculationService.

// ❌ WRONG — put in aggregate
ticket.OptimizeRouteWith(otherTickets);

// ✅ CORRECT — domain service
routeOptimizationService.OptimizeForTechnician(
technicianId, tickets, constraints);

P-05: Repositories

A Repository provides a collection-like interface for accessing aggregates. The domain layer defines the interface; the infrastructure layer implements it. Only aggregate roots have repositories — never child entities.

// ❌ WRONG — repository for child entity
IPartUsageRepository.GetByTicketId(ticketId);

// ✅ CORRECT — through aggregate root
var ticket = await _tickets.GetByIdAsync(ticketId);
var usages = ticket.PartUsages;

P-06: Factories

Factories encapsulate the complex logic of creating aggregates and ensuring they are born in a valid state. An invalid aggregate cannot be created — the factory throws before the object exists.

// ❌ WRONG — public constructor
new ServiceTicket { TenantId = id }; // Partially constructed — invalid!

// ✅ CORRECT — factory method
ServiceTicket.Create(tenantId, clientId, address, description, priority);
// Always valid or throws DomainException

P-07: Specifications

A Specification encapsulates a query or business rule as a reusable object. Used with Ardalis.Specification to compose queries in a type-safe, reusable way.

// ❌ WRONG — query logic in handler
dbContext.Tickets.Where(t => t.TechnicianId == id && t.Date == today);

// ✅ CORRECT — reusable specification
var spec = new TicketsByTechnicianSpec(technicianId, today);
await _repo.ListAsync(spec);

Ubiquitous Language

Ubiquitous Language is a shared vocabulary used consistently in conversations, documentation, and code. If a dispatcher calls something a "job ticket" and the code calls it a WorkOrder, there is a translation tax every time communication happens — bugs and misunderstandings follow.

In RoundTrip, we use the exact same terms in class names, method names, database columns, API endpoints, and UI labels. This is not optional.

Domain TermUsed in Code AsUsed in UI AsDo NOT Use
Service TicketServiceTicketTicketJob, WorkOrder, Task, Case, Incident
Complete (a ticket)ticket.Complete(note)Mark CompleteClose, Finish, Resolve, Done
Put On Holdticket.PutOnHold(reason)Put On HoldPause, Defer, Suspend, Block
ClientClientClientCustomer, Account, Contact
TechnicianTechnicianTechnicianEngineer, Worker, Employee, Staff
DispatcherDispatcherDispatcherCoordinator, Admin, Scheduler
Part UsagePartUsagePart UsedLineItem, Material, Consumption
Service AddressServiceAddressService LocationSite, Location, Venue
Inventory ItemInventoryItemPart / ItemProduct, Stock Item
InvoiceInvoiceInvoiceBill, Statement, Receipt
QuoteQuoteQuoteEstimate, Proposal, Bid
Assign (a ticket)ticket.Assign(techId)Assign TechnicianAllocate, Dispatch, Send
TenantTenant (code) / Company (UI)Your CompanyClient, Account, Organisation

:::warning Language Discipline Rule If a new term enters the codebase that doesn't match the ubiquitous language, it must be changed before the PR is merged — not after. Synonyms are not acceptable. Pick one term per concept and use it everywhere, always. :::


Domain Events Reference

EventContextHandlersSide Effects
TicketCreatedEventTicketsRoutingHandlerAdds ticket to unscheduled pool
TicketAssignedEventTicketsNotificationHandlerPushes SignalR notification to technician PWA
TicketCompletedEventTicketsInvoiceHandler, RouteHandlerEnqueues invoice generation job; removes route stop
TicketCancelledEventTicketsRouteHandler, NotificationHandlerRemoves stop from route; notifies technician
PartUsageRecordedEventTicketsInventoryHandlerCalls InventoryItem.Consume() in separate transaction
StockConsumedEventInventoryAlertHandlerChecks if stock is below threshold
LowStockThresholdBreachedInventoryAlertHandlerEnqueues low-stock digest email for tenant admin
InvoiceSentEventBillingNotificationHandlerMarks delivery timestamp; confirms to dispatcher

Domain Event Lifecycle

1. Handler calls ticket.Complete(note)
└─► ticket.Status = Completed
└─► ticket.RaiseDomainEvent(new TicketCompletedEvent(...))
(stored in _domainEvents list on the aggregate)

2. TransactionBehavior runs:
└─► await _unitOfWork.SaveChangesAsync() ← commits
└─► Collect all domain events from tracked aggregates
└─► IDomainEventPublisher.PublishAsync(domainEvent)

3. Event handlers run (in-process):
└─► InvoiceGenerationHandler
└─► Enqueues Hangfire job: GenerateInvoiceJob(ticketId)
└─► DispatcherNotificationHandler
└─► Pushes SignalR notification to dispatcher dashboard

4. Hangfire job runs (background):
└─► Creates Invoice aggregate
└─► Generates PDF via QuestPDF
└─► Stores PDF in Azure Blob Storage

CQRS in the DDD Context

RoundTrip applies Command Query Responsibility Segregation alongside DDD. Aggregates are designed for write consistency — they enforce invariants and raise events. But they are poor for reads. CQRS solves this by separating the write model (aggregates) from the read model (Dapper queries).

WRITE SIDE (Commands) READ SIDE (Queries)
───────────────────── ──────────────────
CompleteTicketCommand GetTicketListQuery
↓ ↓
CompleteTicketHandler GetTicketListHandler
↓ ↓
Loads ServiceTicket aggregate Dapper direct SQL query
(full domain model) (no aggregates loaded)
↓ ↓
ticket.Complete(note) TicketListItemDto[]
↓ (flat, optimised for grid)
SaveChanges (EF Core)

Domain events dispatched
CharacteristicCommandsQueries
PurposeChange system state (write)Read system state (read-only)
ReturnsResult<T> (success/failure)Read DTO — never a domain aggregate
Loads aggregates?Yes — always through aggregate rootNo — Dapper or EF Core .AsNoTracking()
Domain events?Yes — raised inside aggregate methodsNo — never raises events

Common Anti-Patterns to Avoid

Anti-PatternHow It ManifestsCorrect Approach
Anemic Domain ModelServiceTicket with only getters/setters. A TicketService with Complete(), Assign(), Cancel() methods that access ticket fields directly.Move behaviour into the aggregate. ticket.Complete(note). Services orchestrate, aggregates implement.
Fat HandlerHandler validates that a ticket can be completed, checks status, sets fields directly — bypassing the aggregate.Handler only orchestrates: load aggregate, call domain method, save. Business rules live in the aggregate.
Cross-Aggregate TransactionCompleteTicket handler also creates Invoice and decrements stock in the same SaveChanges call.One aggregate per transaction. Use domain events with eventual consistency for cross-aggregate coordination.
Repository on Non-RootIPartUsageRepository — allowing PartUsage to be loaded and modified without going through ServiceTicket.Repositories only for aggregate roots. Child entity data accessed through the root.
Domain Logic in InfrastructureStatus validation in a stored procedure. Inventory check in an API endpoint handler.All business rules in domain aggregates and domain services. Infrastructure is only pipes and storage.

Adding a New Feature — Checklist

Every new feature follows the same path:

  • Define the use case in domain terms first — write it as a sentence in the Ubiquitous Language: "The dispatcher puts a ticket on hold with a reason."
  • Identify which aggregate owns this behaviour — it must be one aggregate root
  • Write the domain method on the aggregate with invariant guards — write the domain test first
  • Identify the domain event raised — name it in past tense
  • Create the Mediator Command and Handler in the Application layer
  • Create the FastEndpoint in the API layer
  • Write the integration test with a real database
  • Run NetArchTest to confirm no layer boundaries were violated

Testing Domain Code

Domain layer code — aggregates, value objects, domain services — should have near-100% unit test coverage. Domain tests are fast (no I/O), focused (one behaviour per test), and act as living documentation of business rules.

public class ServiceTicketTests
{
[Fact]
public void Complete_WhenInProgress_SetsStatusAndRaisesEvent()
{
// Arrange
var ticket = ServiceTicketBuilder.Create()
.WithStatus(TicketStatus.InProgress)
.Build();

// Act
ticket.Complete("Replaced the compressor unit, tested OK");

// Assert
ticket.Status.Should().Be(TicketStatus.Completed);
ticket.CompletedAt.Should().NotBeNull();
ticket.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<TicketCompletedEvent>();
}

[Fact]
public void Complete_WhenNotInProgress_ThrowsDomainException()
{
var ticket = ServiceTicketBuilder.Create()
.WithStatus(TicketStatus.Assigned) // Not InProgress
.Build();

var act = () => ticket.Complete("Some note");

act.Should().Throw<DomainException>()
.WithMessage("*Can only complete an InProgress ticket*");
}
}