DOMAIN MODEL DOCUMENT
Roundtrip · A Traxs Company Product · Version 1.0 · March 2026
Bounded contexts, aggregate roots, entities, value objects, domain events, invariants, and domain services for the Roundtrip field service management platform.
| Attribute | Value |
|---|---|
| Document Title | Roundtrip Domain Model Document |
| Version | 1.0 — Initial Release |
| Status | Draft — In Review |
| Related Documents | SAD v1.0, PRD v1.0, Clean Architecture Document v1.0 (pending) |
| Date | March 2026 |
01 — INTRODUCTION
Introduction
Purpose
This document defines the Domain Model for Roundtrip — the complete set of bounded contexts, aggregate roots, entities, value objects, domain events, invariants, and domain services that represent the business reality of a field service management platform. It serves as the authoritative reference for the development team when implementing business logic in the Domain layer of the Clean Architecture.
The domain model is deliberately free of infrastructure concerns — no EF Core attributes, no ASP.NET types, no database column definitions appear here. It describes what the business is, not how it is stored or delivered.
Domain-Driven Design Principles Applied
- Ubiquitous Language — all terms in this document are agreed vocabulary used consistently in code, conversations, and documentation
- Bounded Contexts — the domain is divided into logically independent sub-domains, each with its own model and language
- Aggregates — each bounded context has one or more aggregate roots that enforce consistency boundaries
- Invariants — every aggregate enforces business rules that must always hold true; invalid state is never representable
- Domain Events — significant domain occurrences are published as events, enabling loose coupling between contexts
- Value Objects — immutable, identity-less objects defined by their attributes; equality is structural
Ubiquitous Language Glossary
| Term | Definition | Context |
|---|---|---|
| Tenant | A business using Roundtrip (e.g., Acme HVAC Services). The top-level isolation boundary. | All |
| Service Ticket | A record of work requested by a client and performed by a technician. The central unit of work in Roundtrip. | Tickets |
| Client | A customer of the tenant — the business or individual who requests service. | Clients |
| Technician | A member of the tenant's field team who performs service at client locations. | Users |
| Dispatcher | An office user who creates and assigns tickets and monitors the field team. | Users |
| Part Usage | A record of an inventory item consumed during a service ticket, with quantity and photo evidence. | Inventory, Tickets |
| Route | An ordered list of service stops assigned to a technician for a given day, optimized for efficiency. | Routing |
| Invoice | A financial document generated from a completed ticket, itemizing labour and parts for client billing. | Billing |
| Quote | A pre-work estimate of costs provided to a client before service is performed. | Billing |
| Inventory Item | A stocked part, consumable, or material tracked in the tenant's inventory catalogue. | Inventory |
| Aggregate Root | An entity that controls access to a cluster of related objects and enforces invariants across them. | Architecture |
| Domain Event | An immutable record of something significant that happened in the domain, named in past tense. | Architecture |
| Value Object | An immutable object with no identity, defined entirely by its attributes (e.g., an address). | Architecture |
| Invariant | A business rule that must always be true. The aggregate is responsible for enforcing it. | Architecture |
02 — BOUNDED CONTEXTS
Bounded Contexts Overview
The Roundtrip domain is divided into six bounded contexts. Each context owns its model independently — the same word may mean different things in different contexts, and that is intentional. Context maps define how contexts interact without violating each other's boundaries.
Tickets Context
The core of Roundtrip's domain. Manages the full lifecycle of a service ticket from creation through completion.
- Aggregate Roots: ServiceTicket
- Entities: PartUsage, TicketNote, TicketAttachment, ChecklistItem
- Value Objects: TicketNumber, ServiceAddress, TicketStatus, Priority, TimeWindow
- Domain Events: TicketCreated, TicketAssigned, TicketReassigned, StatusChanged, TicketCompleted, TicketCancelled, PartUsageRecorded, NoteAdded
- Domain Services: TicketNumberGenerator
Clients Context
Manages the tenant's client database and their service history relationship with Tickets context.
- Aggregate Roots: Client
- Entities: ServiceAddress (per client), ClientNote
- Value Objects: ContactInfo, ServiceAddress, ClientReference
- Domain Events: ClientCreated, ClientUpdated, ClientArchived
- Domain Services: (none — client data enriched by Tickets context via integration events)
Users Context
Manages tenants, technicians, dispatchers, and all user roles. Identity integration via Keycloak.
- Aggregate Roots: Technician, TenantUser
- Entities: TechnicianSkill, Schedule, AvailabilitySlot
- Value Objects: GpsCoordinate, TechnicianStatus, UserRole
- Domain Events: TechnicianCreated, TechnicianDeactivated, LocationUpdated, SkillsUpdated
- Domain Services: TechnicianAvailabilityService
Inventory Context
Manages the tenant's parts and consumables catalogue, stock levels, and movement history.
- Aggregate Roots: InventoryItem
- Entities: StockMovement, ItemLocation
- Value Objects: SKU, QuantityOnHand, ReorderThreshold, UnitCost
- Domain Events: ItemCreated, StockAdjusted, StockConsumed, StockRestocked, LowStockThresholdBreached
- Domain Services: StockConsumptionService
Billing Context
Manages invoices, quotes, line items, and financial document delivery.
- Aggregate Roots: Invoice, Quote
- Entities: LineItem, InvoiceDelivery
- Value Objects: InvoiceNumber, QuoteNumber, Money, InvoiceStatus, QuoteStatus, TaxRate
- Domain Events: InvoiceGenerated, InvoiceSent, InvoiceViewed, PaymentRecorded, QuoteCreated, QuoteSent, QuoteApproved, QuoteExpired
- Domain Services: InvoiceNumberGenerator, TaxCalculationService
Routing Context
Manages daily route plans for technicians. Consumes ticket and technician data via read models.
- Aggregate Roots: TechnicianRoute
- Entities: RouteStop
- Value Objects: RouteDate, StopOrder, EstimatedArrival, DrivingDuration
- Domain Events: RouteOptimized, RouteUpdated, StopCompleted
- Domain Services: RouteOptimizationService (orchestrates n8n/Ollama calls)
Context Map
The following diagram shows how the bounded contexts relate to each other and the integration patterns used between them.
┌──────────────────────────────────────────────────────────────────┐
│ CONTEXT MAP │
│ │
│ ┌──────────┐ Customer/Supplier ┌──────────┐ │
│ │ Clients │ ──────────────────────►│ Tickets │ │
│ └──────────┘ └────┬─────┘ │
│ ▲ │ Domain Events │
│ │ ClientReference (read model) │ (TicketCompleted, │
│ │ │ PartUsageRecorded) │
│ ┌────┴─────┐ Customer/Supplier ┌─────▼────┐ │
│ │ Users │ ──────────────────────►│Inventory │ │
│ │ (Techs) │ └────┬─────┘ │
│ └────┬─────┘ │ StockConsumed event │
│ │ TechnicianReference │ │
│ │ (read model) ┌─────▼────┐ │
│ ┌────▼─────┐ Published Language │ Billing │ │
│ │ Routing │ ◄──────────────────────└──────────┘ │
│ └──────────┘ (RouteStop uses ticket │
│ and technician IDs) │
│ │
│ Legend: │
│ ──────► Customer/Supplier (upstream owns the model) │
│ ◄────── Published Language (shared read model / events) │
└──────────────────────────────────────────────────────────────────┘
03 — TICKETS BOUNDED CONTEXT
Tickets Bounded Context
ServiceTicket — Aggregate Root
ServiceTicket is the most important aggregate in the entire domain. Every field service job is a ServiceTicket. It owns the complete lifecycle of a job, enforces all transition rules, and coordinates its child entities.
public sealed class ServiceTicket : AggregateRoot<TicketId>
{
public TicketNumber Number { get; private set; }
public TenantId TenantId { get; private set; }
public ClientId ClientId { get; private set; }
public TechnicianId? TechnicianId { get; private set; } // null = unassigned
public ServiceAddress ServiceAddress { get; private set; }
public string ServiceDescription { get; private set; }
public Priority Priority { get; private set; }
public TicketStatus Status { get; private set; }
public TimeWindow? RequestedWindow { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? AssignedAt { get; private set; }
public DateTime? StartedAt { get; private set; }
public DateTime? CompletedAt { get; private set; }
public string? CompletionNote { get; private set; }
public OnHoldReason? OnHoldReason { get; private set; }
// Owned collections
private readonly List<PartUsage> _partUsages = new();
private readonly List<TicketNote> _notes = new();
private readonly List<TicketAttachment> _attachments = new();
public IReadOnlyList<PartUsage> PartUsages => _partUsages.AsReadOnly();
public IReadOnlyList<TicketNote> Notes => _notes.AsReadOnly();
public IReadOnlyList<TicketAttachment> Attachments => _attachments.AsReadOnly();
}
Invariants
| Invariant | Rule | Enforcement |
|---|---|---|
| INV-T-01 | ServiceDescription cannot be null or whitespace | Private setter; factory method validates before construction |
| INV-T-02 | Status transitions must follow the defined state machine. Invalid transitions throw DomainException. | Assign(), Start(), Complete(), Cancel(), PutOnHold() methods validate current state |
| INV-T-03 | CompletionNote is required (min 10 chars) when completing a ticket | Complete() validates before transitioning |
| INV-T-04 | A ticket cannot be completed if it has unresolved checklist items (future feature) | Complete() checks checklist state |
| INV-T-05 | TechnicianId can only be set via Assign() or Reassign() — never directly | No public setter on TechnicianId |
| INV-T-06 | PartUsage cannot be added to a Completed or Cancelled ticket | AddPartUsage() checks Status |
| INV-T-07 | Ticket belongs to exactly one tenant; TenantId is set at creation and immutable | Private setter, set only in factory |
State Machine & Transition Methods
// Valid transitions only — DomainException thrown on invalid transition
public void Assign(TechnicianId technicianId)
{
Guard.Against(Status is not (TicketStatus.Open or TicketStatus.OnHold),
"Can only assign an Open or OnHold ticket");
TechnicianId = technicianId;
Status = TicketStatus.Assigned;
AssignedAt = DateTime.UtcNow;
RaiseDomainEvent(new TicketAssignedEvent(Id, TenantId, technicianId));
}
public void Start()
{
Guard.Against(Status is not TicketStatus.Assigned,
"Can only start an Assigned ticket");
Status = TicketStatus.InProgress;
StartedAt = DateTime.UtcNow;
RaiseDomainEvent(new TicketStatusChangedEvent(Id, TenantId, Status));
}
public void Complete(string completionNote)
{
Guard.Against(Status is not TicketStatus.InProgress,
"Can only complete an InProgress ticket");
Guard.Against(string.IsNullOrWhiteSpace(completionNote) || completionNote.Length < 10,
"Completion note is required (minimum 10 characters)");
CompletionNote = completionNote;
Status = TicketStatus.Completed;
CompletedAt = DateTime.UtcNow;
RaiseDomainEvent(new TicketCompletedEvent(Id, TenantId, TechnicianId!, ClientId));
}
public void PutOnHold(OnHoldReason reason)
{
Guard.Against(Status is not TicketStatus.InProgress,
"Can only put an InProgress ticket on hold");
OnHoldReason = reason;
Status = TicketStatus.OnHold;
RaiseDomainEvent(new TicketStatusChangedEvent(Id, TenantId, Status));
}
public void Cancel()
{
Guard.Against(Status is TicketStatus.Completed or TicketStatus.Cancelled,
"Cannot cancel a terminal-state ticket");
Status = TicketStatus.Cancelled;
RaiseDomainEvent(new TicketCancelledEvent(Id, TenantId));
}
PartUsage — Child Entity
public sealed class PartUsage : Entity<PartUsageId>
{
public InventoryItemId InventoryItemId { get; private set; }
public string ItemName { get; private set; } // denormalised for history
public int QuantityUsed { get; private set; } // must be > 0
public Money UnitCostAtTime { get; private set; } // snapshot at time of use
public string? PhotoBlobKey { get; private set; } // nullable — photo optional
public TechnicianId RecordedBy { get; private set; }
public DateTime RecordedAt { get; private set; }
// Invariant: QuantityUsed must be > 0
// Invariant: UnitCostAtTime cannot be negative
}
Domain Events — Tickets Context
| Event | Raised When | Key Payload | Consumers |
|---|---|---|---|
| TicketCreated | New ServiceTicket is constructed | TicketId, TenantId, ClientId, Priority | Routing context (add to unscheduled pool) |
| TicketAssigned | Assign() or Reassign() called | TicketId, TenantId, TechnicianId | Users context (notify technician via SignalR) |
| TicketReassigned | Reassign() called (tech changed) | TicketId, TenantId, OldTechId, NewTechId | Users context (notify both technicians) |
| StatusChanged | Any status transition | TicketId, TenantId, NewStatus | Dispatcher dashboard (SignalR push) |
| TicketCompleted | Complete() called | TicketId, TenantId, TechnicianId, ClientId | Billing context (trigger invoice generation job) |
| TicketCancelled | Cancel() called | TicketId, TenantId | Routing context (remove from route) |
| PartUsageRecorded | AddPartUsage() called | PartUsageId, InventoryItemId, Quantity | Inventory context (decrement stock) |
04 — CLIENTS BOUNDED CONTEXT
Clients Bounded Context
Client — Aggregate Root
public sealed class Client : AggregateRoot<ClientId>
{
public TenantId TenantId { get; private set; }
public ContactInfo PrimaryContact { get; private set; }
public string? CompanyName { get; private set; } // null for residential
public bool IsCommercial { get; private set; }
public ClientNote? Notes { get; private set; }
public TechnicianId? PreferredTech { get; private set; } // null = no preference
public bool IsArchived { get; private set; }
public DateTime CreatedAt { get; private set; }
private readonly List<ClientAddress> _serviceAddresses = new();
public IReadOnlyList<ClientAddress> ServiceAddresses => _serviceAddresses.AsReadOnly();
// Factory method — enforces that at least one service address is provided
public static Client Create(TenantId tenantId, ContactInfo contact,
ServiceAddress firstAddress, string? companyName = null)
{
Guard.Against(firstAddress is null, "At least one service address is required");
var client = new Client { ... };
client.RaiseDomainEvent(new ClientCreatedEvent(client.Id, tenantId));
return client;
}
}
Invariants
| Invariant | Rule |
|---|---|
| INV-C-01 | A Client must have at least one ServiceAddress at all times. The last address cannot be removed. |
| INV-C-02 | An archived Client cannot have new ServiceAddresses added. Archive is a soft-delete state. |
| INV-C-03 | ContactInfo.PrimaryPhone or ContactInfo.Email must be present — at least one contact method is required. |
| INV-C-04 | CompanyName is required when IsCommercial is true. |
ClientAddress — Entity
public sealed class ClientAddress : Entity<ClientAddressId>
{
public string Label { get; private set; } // e.g. 'Main Office'
public ServiceAddress Address { get; private set; } // value object
public bool IsPrimary { get; private set; }
public DateTime CreatedAt { get; private set; }
// Invariant: Label cannot be null or empty
// Invariant: Exactly one address per client must be IsPrimary = true
}
05 — USERS BOUNDED CONTEXT
Users Bounded Context
Technician — Aggregate Root
public sealed class Technician : AggregateRoot<TechnicianId>
{
public TenantId TenantId { get; private set; }
public UserId UserId { get; private set; } // Keycloak user ID
public string FirstName { get; private set; }
public string LastName { get; private set; }
public string Email { get; private set; }
public string? Phone { get; private set; }
public TechnicianStatus Status { get; private set; }
public GpsCoordinate? LastKnownLocation { get; private set; }
public DateTime? LocationUpdatedAt { get; private set; }
public string? ProfilePhotoBlobKey { get; private set; }
public bool IsActive { get; private set; }
private readonly List<TechnicianSkill> _skills = new();
public IReadOnlyList<TechnicianSkill> Skills => _skills.AsReadOnly();
public void UpdateLocation(GpsCoordinate coordinate)
{
Guard.Against(!IsActive, "Cannot update location of inactive technician");
LastKnownLocation = coordinate;
LocationUpdatedAt = DateTime.UtcNow;
RaiseDomainEvent(new LocationUpdatedEvent(Id, TenantId, coordinate));
}
}
TechnicianStatus Enum
| Status | Meaning | Set By |
|---|---|---|
| Available | Technician is between jobs, able to accept new assignments | Technician (on completing a stop) or auto-set by route engine |
| EnRoute | Technician is driving to their next stop | Technician (taps 'Start Driving' in PWA) |
| OnSite | Technician has arrived and is actively working | Technician (taps 'I've Arrived' in PWA) |
| Unavailable | Technician is on break, lunch, or personal time | Technician |
| OffDuty | Technician's shift has ended for the day | Auto-set by schedule or manually by Technician/Dispatcher |
TenantUser — Aggregate Root
// Represents non-technician users: Dispatchers, BillingStaff, TenantAdmins, ReadOnly
public sealed class TenantUser : AggregateRoot<TenantUserId>
{
public TenantId TenantId { get; private set; }
public UserId UserId { get; private set; } // Keycloak user ID
public string FirstName { get; private set; }
public string LastName { get; private set; }
public string Email { get; private set; }
public UserRole Role { get; private set; }
public bool IsActive { get; private set; }
public DateTime CreatedAt { get; private set; }
// Invariant: Email must be unique within the tenant
// Invariant: Cannot deactivate the last active TenantAdmin
}
06 — INVENTORY BOUNDED CONTEXT
Inventory Bounded Context
InventoryItem — Aggregate Root
public sealed class InventoryItem : AggregateRoot<InventoryItemId>
{
public TenantId TenantId { get; private set; }
public SKU Sku { get; private set; }
public string Name { get; private set; }
public string? Description { get; private set; }
public string UnitOfMeasure { get; private set; } // 'each', 'litre', 'metre'
public Money UnitCost { get; private set; }
public QuantityOnHand QuantityOnHand { get; private set; }
public int ReorderThreshold { get; private set; }
public string? BarCode { get; private set; }
public string? PhotoBlobKey { get; private set; }
public string? Category { get; private set; }
public bool IsArchived { get; private set; }
private readonly List<StockMovement> _movements = new();
public IReadOnlyList<StockMovement> Movements => _movements.AsReadOnly();
public void Consume(int quantity, TicketId ticketId, TechnicianId technicianId)
{
Guard.Against(quantity <= 0, "Quantity must be positive");
Guard.Against(QuantityOnHand.Value < quantity, "Insufficient stock");
Guard.Against(IsArchived, "Cannot consume from archived item");
QuantityOnHand = QuantityOnHand.Subtract(quantity);
_movements.Add(StockMovement.Consumption(Id, quantity, ticketId, technicianId));
RaiseDomainEvent(new StockConsumedEvent(Id, TenantId, quantity, QuantityOnHand));
if (QuantityOnHand.Value <= ReorderThreshold)
RaiseDomainEvent(new LowStockThresholdBreachedEvent(Id, TenantId, QuantityOnHand));
}
public void Adjust(int newQuantity, StockAdjustmentReason reason, UserId adjustedBy)
{
Guard.Against(newQuantity < 0, "Quantity cannot be negative");
var delta = newQuantity - QuantityOnHand.Value;
QuantityOnHand = new QuantityOnHand(newQuantity);
_movements.Add(StockMovement.Adjustment(Id, delta, reason, adjustedBy));
RaiseDomainEvent(new StockAdjustedEvent(Id, TenantId, delta, reason));
}
}
Invariants
| Invariant | Rule |
|---|---|
| INV-I-01 | QuantityOnHand cannot be negative. Consume() throws DomainException if requested quantity exceeds stock. |
| INV-I-02 | SKU must be unique within the tenant. Enforced at application layer via uniqueness check before construction. |
| INV-I-03 | UnitCost cannot be negative. |
| INV-I-04 | An archived item cannot have stock consumed from it. Adjustment is still allowed (e.g., to zero out). |
| INV-I-05 | ReorderThreshold cannot be negative. |
StockMovement — Entity
public sealed class StockMovement : Entity<StockMovementId>
{
public InventoryItemId ItemId { get; private set; }
public StockMovementType Type { get; private set; } // Consumption | Adjustment | Receipt
public int QuantityDelta { get; private set; } // negative = removed, positive = added
public int QuantityAfter { get; private set; } // snapshot for audit trail
public TicketId? TicketId { get; private set; } // set for Consumption movements
public UserId RecordedBy { get; private set; }
public StockAdjustmentReason? Reason { get; private set; } // set for Adjustment movements
public DateTime RecordedAt { get; private set; }
}
07 — BILLING BOUNDED CONTEXT
Billing Bounded Context
Invoice — Aggregate Root
public sealed class Invoice : AggregateRoot<InvoiceId>
{
public TenantId TenantId { get; private set; }
public InvoiceNumber Number { get; private set; }
public TicketId SourceTicketId { get; private set; }
public ClientId ClientId { get; private set; }
public InvoiceStatus Status { get; private set; }
public TaxRate TaxRate { get; private set; }
public Money Subtotal { get; private set; } // computed
public Money TaxAmount { get; private set; } // computed
public Money Total { get; private set; } // computed
public DateTime IssuedAt { get; private set; }
public DateTime? SentAt { get; private set; }
public DateTime? DueDate { get; private set; }
public DateTime? PaidAt { get; private set; }
public bool IsLocked { get; private set; } // true once Sent
private readonly List<LineItem> _lineItems = new();
public IReadOnlyList<LineItem> LineItems => _lineItems.AsReadOnly();
public void AddLineItem(LineItem item)
{
Guard.Against(IsLocked, "Cannot modify a sent or paid invoice");
_lineItems.Add(item);
RecalculateTotals();
}
public void MarkSent(DateTime sentAt)
{
Guard.Against(Status is InvoiceStatus.Paid or InvoiceStatus.Cancelled,
"Cannot send a paid or cancelled invoice");
Guard.Against(!_lineItems.Any(), "Cannot send an invoice with no line items");
Status = InvoiceStatus.Sent;
SentAt = sentAt;
IsLocked = true;
RaiseDomainEvent(new InvoiceSentEvent(Id, TenantId, ClientId, Total));
}
public void RecordPayment(DateTime paidAt)
{
Guard.Against(Status is not InvoiceStatus.Sent and not InvoiceStatus.Overdue,
"Can only record payment on a Sent or Overdue invoice");
Status = InvoiceStatus.Paid;
PaidAt = paidAt;
RaiseDomainEvent(new PaymentRecordedEvent(Id, TenantId, Total));
}
}
LineItem — Value Object
// Value Object — immutable, defined by its attributes
public sealed record LineItem
{
public string Description { get; init; }
public int Quantity { get; init; } // must be > 0
public Money UnitPrice { get; init; } // cannot be negative
public Money Total => UnitPrice * Quantity;
public LineItemType Type { get; init; } // Labour | Part | Discount | Other
// Invariants enforced in constructor/factory
// Description cannot be empty
// Quantity must be positive
// UnitPrice cannot be negative (use Discount type for reductions)
}
Quote — Aggregate Root
public sealed class Quote : AggregateRoot<QuoteId>
{
public TenantId TenantId { get; private set; }
public QuoteNumber Number { get; private set; }
public ClientId ClientId { get; private set; }
public TicketId? TicketId { get; private set; } // optional — linked to ticket
public QuoteStatus Status { get; private set; }
public DateTime ExpiresAt { get; private set; }
public string? TermsAndConditions { get; private set; }
public bool IsConverted { get; private set; } // true once converted to Invoice
private readonly List<LineItem> _lineItems = new();
public void Approve(string approverName, DateTime approvedAt)
{
Guard.Against(Status is not QuoteStatus.Sent, "Can only approve a Sent quote");
Guard.Against(approvedAt > ExpiresAt, "Cannot approve an expired quote");
Status = QuoteStatus.Approved;
RaiseDomainEvent(new QuoteApprovedEvent(Id, TenantId, ClientId, approverName));
}
}
08 — ROUTING BOUNDED CONTEXT
Routing Bounded Context
TechnicianRoute — Aggregate Root
public sealed class TechnicianRoute : AggregateRoot<TechnicianRouteId>
{
public TenantId TenantId { get; private set; }
public TechnicianId TechnicianId { get; private set; }
public RouteDate Date { get; private set; }
public bool IsOptimized { get; private set; }
public DateTime? OptimizedAt { get; private set; }
public RouteVersion Version { get; private set; } // incremented on each re-optimization
private readonly List<RouteStop> _stops = new();
public IReadOnlyList<RouteStop> Stops => _stops.OrderBy(s => s.StopOrder).ToList().AsReadOnly();
public void ApplyOptimizedRoute(IEnumerable<RouteStop> optimizedStops)
{
Guard.Against(!optimizedStops.Any(), "Route must have at least one stop");
_stops.Clear();
_stops.AddRange(optimizedStops);
IsOptimized = true;
OptimizedAt = DateTime.UtcNow;
Version = Version.Increment();
RaiseDomainEvent(new RouteOptimizedEvent(Id, TenantId, TechnicianId, Date, _stops.Count));
}
public void CompleteStop(TicketId ticketId)
{
var stop = _stops.FirstOrDefault(s => s.TicketId == ticketId)
?? throw new DomainException("Stop not found on this route");
stop.MarkCompleted();
RaiseDomainEvent(new StopCompletedEvent(Id, TenantId, TechnicianId, ticketId));
}
}
RouteStop — Entity
public sealed class RouteStop : Entity<RouteStopId>
{
public TicketId TicketId { get; private set; }
public StopOrder StopOrder { get; private set; } // 1-based ordering
public ServiceAddress Address { get; private set; } // denormalised
public EstimatedArrival EstimatedArrival { get; private set; }
public DrivingDuration DrivingFromPrev { get; private set; } // from previous stop
public int? EstimatedDuration { get; private set; } // job duration in minutes
public bool IsCompleted { get; private set; }
public DateTime? CompletedAt { get; private set; }
public bool IsPinned { get; private set; } // true = in progress, skip in re-opt
}
09 — VALUE OBJECTS
Value Objects Catalogue
Value objects are immutable, identity-less objects defined entirely by their attributes. Two value objects with the same attributes are considered equal. They are used wherever the concept is defined by its value rather than its identity.
| Value Object | Properties | Validation Invariants | Equality |
|---|---|---|---|
| ServiceAddress | Street, City, State/Province, PostalCode, Country, Latitude?, Longitude? | PostalCode non-empty. Country required. Lat/Lng are optional until geocoded — both must be present or both absent. | Structural — all properties equal |
| ContactInfo | FirstName, LastName, Email?, PrimaryPhone?, SecondaryPhone? | At least one of Email or PrimaryPhone required. Email must match valid format regex. Phone must be 10+ digits. | Structural |
| Money | Amount (decimal), Currency (ISO 4217) | Amount >= 0 for most uses (except Discount line items which can be negative). Currency must be valid ISO code. | Amount AND Currency must match |
| GpsCoordinate | Latitude (decimal), Longitude (decimal) | Latitude: -90 to 90. Longitude: -180 to 180. Both required together. | Structural |
| TicketNumber | Value (string, format RT-YYYY-NNNNN) | Must match pattern RT-{4 digit year}-{5 digit seq}. Immutable once issued. | Value equality |
| InvoiceNumber | Value (string, format INV-YYYY-NNNNN) | Must match pattern INV-{year}-{5 digit seq}. | Value equality |
| QuoteNumber | Value (string, format QT-YYYY-NNNNN) | Must match pattern QT-{year}-{5 digit seq}. | Value equality |
| SKU | Value (string) | Non-empty. Max 50 characters. No whitespace. | Value equality |
| QuantityOnHand | Value (int) | Cannot be negative. | Value equality |
| TaxRate | Percentage (decimal 0.00 to 1.00) | 0.00 = no tax (valid). Cannot exceed 1.00 (100%). | Value equality |
| TimeWindow | Start (TimeOnly), End (TimeOnly) | End must be after Start. Window cannot be zero-length. | Structural |
| RouteDate | Value (DateOnly) | Cannot be in the past when creating a new route. | Value equality |
| StopOrder | Value (int, 1-based) | Must be >= 1. Uniqueness within a route enforced by TechnicianRoute aggregate. | Value equality |
| EstimatedArrival | Time (DateTime UTC) | Must be in the future at time of route creation. | Value equality |
| TenantSlug | Value (string) | Lowercase alphanumeric + hyphens. 3–30 chars. Must be unique across all tenants. | Value equality |
Strongly Typed IDs
All entity and aggregate IDs are strongly typed to prevent accidental mixing of IDs from different entities. Passing a TechnicianId where a ClientId is expected is a compile-time error.
// All IDs follow this pattern — newtype wrappers over Guid
public readonly record struct TicketId(Guid Value)
{
public static TicketId New() => new(Guid.NewGuid());
public static TicketId From(Guid value) => new(value);
public override string ToString() => Value.ToString();
}
// Similarly defined:
// ClientId, TechnicianId, TenantUserId, TenantId, InventoryItemId,
// InvoiceId, QuoteId, TechnicianRouteId, RouteStopId, PartUsageId,
// StockMovementId, ClientAddressId
10 — BASE CLASSES & SHARED ABSTRACTIONS
Base Classes & Shared Abstractions
AggregateRoot<TId>
public abstract class AggregateRoot<TId> : Entity<TId>
where TId : struct
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
Entity<TId>
public abstract class Entity<TId> where TId : struct
{
public TId Id { get; protected set; }
protected Entity() { } // for EF Core
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
}
IDomainEvent
public interface IDomainEvent
{
Guid EventId { get; }
DateTime OccurredAt { get; }
}
// Base record for all domain events
public abstract record DomainEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
// Example domain event
public sealed record TicketCompletedEvent(
TicketId TicketId,
TenantId TenantId,
TechnicianId TechnicianId,
ClientId ClientId
) : DomainEvent;
ITenantEntity
// All tenant-scoped entities implement this interface
// EF Core uses it to apply Global Query Filters automatically
public interface ITenantEntity
{
TenantId TenantId { get; }
}
// Example — every Aggregate Root in a tenant context implements this:
public sealed class ServiceTicket : AggregateRoot<TicketId>, ITenantEntity { ... }
public sealed class Client : AggregateRoot<ClientId>, ITenantEntity { ... }
public sealed class InventoryItem : AggregateRoot<InventoryItemId>, ITenantEntity { ... }
Result<T> — Domain Operation Results
// No exceptions thrown from domain or application layers for expected failures
// All operations return Result<T> which is explicitly checked by callers
public sealed class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public string Error { get; }
public ResultType Type { get; } // Success | NotFound | Unauthorized | Conflict | Validation
public static Result<T> Success(T value) => new(true, value, string.Empty, ResultType.Success);
public static Result<T> Failure(string error) => new(false, default, error, ResultType.Failure);
public static Result<T> NotFound() => new(false, default, "Not found", ResultType.NotFound);
public static Result<T> Unauthorized() => new(false, default, "Unauthorized", ResultType.Unauthorized);
}
Roundtrip · Domain Model Document v1.0 · A Traxs Company Product · March 2026