Skip to main content

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.

AttributeValue
Document TitleRoundtrip Domain Model Document
Version1.0 — Initial Release
StatusDraft — In Review
Related DocumentsSAD v1.0, PRD v1.0, Clean Architecture Document v1.0 (pending)
DateMarch 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

TermDefinitionContext
TenantA business using Roundtrip (e.g., Acme HVAC Services). The top-level isolation boundary.All
Service TicketA record of work requested by a client and performed by a technician. The central unit of work in Roundtrip.Tickets
ClientA customer of the tenant — the business or individual who requests service.Clients
TechnicianA member of the tenant's field team who performs service at client locations.Users
DispatcherAn office user who creates and assigns tickets and monitors the field team.Users
Part UsageA record of an inventory item consumed during a service ticket, with quantity and photo evidence.Inventory, Tickets
RouteAn ordered list of service stops assigned to a technician for a given day, optimized for efficiency.Routing
InvoiceA financial document generated from a completed ticket, itemizing labour and parts for client billing.Billing
QuoteA pre-work estimate of costs provided to a client before service is performed.Billing
Inventory ItemA stocked part, consumable, or material tracked in the tenant's inventory catalogue.Inventory
Aggregate RootAn entity that controls access to a cluster of related objects and enforces invariants across them.Architecture
Domain EventAn immutable record of something significant that happened in the domain, named in past tense.Architecture
Value ObjectAn immutable object with no identity, defined entirely by its attributes (e.g., an address).Architecture
InvariantA 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

InvariantRuleEnforcement
INV-T-01ServiceDescription cannot be null or whitespacePrivate setter; factory method validates before construction
INV-T-02Status transitions must follow the defined state machine. Invalid transitions throw DomainException.Assign(), Start(), Complete(), Cancel(), PutOnHold() methods validate current state
INV-T-03CompletionNote is required (min 10 chars) when completing a ticketComplete() validates before transitioning
INV-T-04A ticket cannot be completed if it has unresolved checklist items (future feature)Complete() checks checklist state
INV-T-05TechnicianId can only be set via Assign() or Reassign() — never directlyNo public setter on TechnicianId
INV-T-06PartUsage cannot be added to a Completed or Cancelled ticketAddPartUsage() checks Status
INV-T-07Ticket belongs to exactly one tenant; TenantId is set at creation and immutablePrivate 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

EventRaised WhenKey PayloadConsumers
TicketCreatedNew ServiceTicket is constructedTicketId, TenantId, ClientId, PriorityRouting context (add to unscheduled pool)
TicketAssignedAssign() or Reassign() calledTicketId, TenantId, TechnicianIdUsers context (notify technician via SignalR)
TicketReassignedReassign() called (tech changed)TicketId, TenantId, OldTechId, NewTechIdUsers context (notify both technicians)
StatusChangedAny status transitionTicketId, TenantId, NewStatusDispatcher dashboard (SignalR push)
TicketCompletedComplete() calledTicketId, TenantId, TechnicianId, ClientIdBilling context (trigger invoice generation job)
TicketCancelledCancel() calledTicketId, TenantIdRouting context (remove from route)
PartUsageRecordedAddPartUsage() calledPartUsageId, InventoryItemId, QuantityInventory 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

InvariantRule
INV-C-01A Client must have at least one ServiceAddress at all times. The last address cannot be removed.
INV-C-02An archived Client cannot have new ServiceAddresses added. Archive is a soft-delete state.
INV-C-03ContactInfo.PrimaryPhone or ContactInfo.Email must be present — at least one contact method is required.
INV-C-04CompanyName 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

StatusMeaningSet By
AvailableTechnician is between jobs, able to accept new assignmentsTechnician (on completing a stop) or auto-set by route engine
EnRouteTechnician is driving to their next stopTechnician (taps 'Start Driving' in PWA)
OnSiteTechnician has arrived and is actively workingTechnician (taps 'I've Arrived' in PWA)
UnavailableTechnician is on break, lunch, or personal timeTechnician
OffDutyTechnician's shift has ended for the dayAuto-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

InvariantRule
INV-I-01QuantityOnHand cannot be negative. Consume() throws DomainException if requested quantity exceeds stock.
INV-I-02SKU must be unique within the tenant. Enforced at application layer via uniqueness check before construction.
INV-I-03UnitCost cannot be negative.
INV-I-04An archived item cannot have stock consumed from it. Adjustment is still allowed (e.g., to zero out).
INV-I-05ReorderThreshold 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 ObjectPropertiesValidation InvariantsEquality
ServiceAddressStreet, 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
ContactInfoFirstName, LastName, Email?, PrimaryPhone?, SecondaryPhone?At least one of Email or PrimaryPhone required. Email must match valid format regex. Phone must be 10+ digits.Structural
MoneyAmount (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
GpsCoordinateLatitude (decimal), Longitude (decimal)Latitude: -90 to 90. Longitude: -180 to 180. Both required together.Structural
TicketNumberValue (string, format RT-YYYY-NNNNN)Must match pattern RT-{4 digit year}-{5 digit seq}. Immutable once issued.Value equality
InvoiceNumberValue (string, format INV-YYYY-NNNNN)Must match pattern INV-{year}-{5 digit seq}.Value equality
QuoteNumberValue (string, format QT-YYYY-NNNNN)Must match pattern QT-{year}-{5 digit seq}.Value equality
SKUValue (string)Non-empty. Max 50 characters. No whitespace.Value equality
QuantityOnHandValue (int)Cannot be negative.Value equality
TaxRatePercentage (decimal 0.00 to 1.00)0.00 = no tax (valid). Cannot exceed 1.00 (100%).Value equality
TimeWindowStart (TimeOnly), End (TimeOnly)End must be after Start. Window cannot be zero-length.Structural
RouteDateValue (DateOnly)Cannot be in the past when creating a new route.Value equality
StopOrderValue (int, 1-based)Must be >= 1. Uniqueness within a route enforced by TechnicianRoute aggregate.Value equality
EstimatedArrivalTime (DateTime UTC)Must be in the future at time of route creation.Value equality
TenantSlugValue (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