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-domain | Type | Rationale | Approach |
|---|---|---|---|
| Ticket Lifecycle Management | Core Domain ★ | The 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 Management | Supporting Domain | Parts 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 & Invoicing | Supporting Domain | Invoicing 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 Management | Supporting Domain | Client records with service history links. Important but not complex enough to justify full DDD depth. | Client aggregate with straightforward rules. |
| Identity & Authentication | Generic Sub-domain | Authentication 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 Delivery | Generic Sub-domain | Commodity service. No business logic beyond triggering the right message. | SendGrid and Twilio in Infrastructure. Domain raises events; infrastructure handles delivery. |
| PDF Generation | Generic Sub-domain | Presentation 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
| From | To | Pattern | Mechanism | Consistency |
|---|---|---|---|---|
| Tickets | Inventory | Domain Event | PartUsageRecordedEvent → InventoryItem.Consume() | Eventual |
| Tickets | Billing | Domain Event | TicketCompletedEvent → invoice generation Hangfire job | Eventual |
| Users | Tickets | Published Language | TechnicianReference DTO — read model for assignment UI | Sync read |
| Clients | Tickets | Customer/Supplier | ClientReference DTO used in ticket creation | Sync 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 Term | Used in Code As | Used in UI As | Do NOT Use |
|---|---|---|---|
| Service Ticket | ServiceTicket | Ticket | Job, WorkOrder, Task, Case, Incident |
| Complete (a ticket) | ticket.Complete(note) | Mark Complete | Close, Finish, Resolve, Done |
| Put On Hold | ticket.PutOnHold(reason) | Put On Hold | Pause, Defer, Suspend, Block |
| Client | Client | Client | Customer, Account, Contact |
| Technician | Technician | Technician | Engineer, Worker, Employee, Staff |
| Dispatcher | Dispatcher | Dispatcher | Coordinator, Admin, Scheduler |
| Part Usage | PartUsage | Part Used | LineItem, Material, Consumption |
| Service Address | ServiceAddress | Service Location | Site, Location, Venue |
| Inventory Item | InventoryItem | Part / Item | Product, Stock Item |
| Invoice | Invoice | Invoice | Bill, Statement, Receipt |
| Quote | Quote | Quote | Estimate, Proposal, Bid |
| Assign (a ticket) | ticket.Assign(techId) | Assign Technician | Allocate, Dispatch, Send |
| Tenant | Tenant (code) / Company (UI) | Your Company | Client, 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
| Event | Context | Handlers | Side Effects |
|---|---|---|---|
TicketCreatedEvent | Tickets | RoutingHandler | Adds ticket to unscheduled pool |
TicketAssignedEvent | Tickets | NotificationHandler | Pushes SignalR notification to technician PWA |
TicketCompletedEvent | Tickets | InvoiceHandler, RouteHandler | Enqueues invoice generation job; removes route stop |
TicketCancelledEvent | Tickets | RouteHandler, NotificationHandler | Removes stop from route; notifies technician |
PartUsageRecordedEvent | Tickets | InventoryHandler | Calls InventoryItem.Consume() in separate transaction |
StockConsumedEvent | Inventory | AlertHandler | Checks if stock is below threshold |
LowStockThresholdBreached | Inventory | AlertHandler | Enqueues low-stock digest email for tenant admin |
InvoiceSentEvent | Billing | NotificationHandler | Marks 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
| Characteristic | Commands | Queries |
|---|---|---|
| Purpose | Change system state (write) | Read system state (read-only) |
| Returns | Result<T> (success/failure) | Read DTO — never a domain aggregate |
| Loads aggregates? | Yes — always through aggregate root | No — Dapper or EF Core .AsNoTracking() |
| Domain events? | Yes — raised inside aggregate methods | No — never raises events |
Common Anti-Patterns to Avoid
| Anti-Pattern | How It Manifests | Correct Approach |
|---|---|---|
| Anemic Domain Model | ServiceTicket 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 Handler | Handler 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 Transaction | CompleteTicket 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-Root | IPartUsageRepository — 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 Infrastructure | Status 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*");
}
}
Related Documents
- Clean Architecture — how the layers are structured around the domain
- Cyrus Mediator — the mediator pattern used to dispatch commands and queries
- Backend Coding — day-to-day coding patterns and conventions