Waypoint — Domain-Driven Design
Version: 1.0
Date: July 2026
Author: Pete Carroll, Founder — Traxs Group LLC
Status: Draft
1. Strategic Design
1.1 Core Domain vs Supporting Domains
| Sub-domain | Type | Rationale |
|---|---|---|
| Financial Performance | Core Domain ★ | The P&L visibility and revenue tracking is Waypoint's primary differentiator. This is what owners open Waypoint to see. |
| HR & People Management | Supporting Domain | Important but well-understood. Employee records, leave, certifications — valuable but not unique. |
| Document Management | Supporting Domain | File storage with structure. Valuable operationally but commodity functionality. |
| Team Management | Supporting Domain | Team visibility is valuable but derivative of HR and RoundTrip data. |
| Workspace Configuration | Generic Sub-domain | Settings, integrations, billing — solved problems, no competitive advantage. |
1.2 Context Map
┌──────────────────────────────────────────────────────────┐
│ WAYPOINT CONTEXT MAP │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ FINANCIAL CONTEXT [CORE DOMAIN] ★ │ │
│ │ RevenueEntry, Expense │ │
│ │ Published Language: FinancialSummaryDto │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ Revenue data │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DASHBOARD CONTEXT [Supporting] │ │
│ │ DashboardSummary │ │
│ │ Aggregates from: Financial, HR, Team, RoundTrip │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ HR CONTEXT │ │ DOCUMENTS CONTEXT │ │
│ │ Employee │ │ DocumentRecord │ │
│ │ Publishes: │ │ KnowledgeArticle │ │
│ │ EmployeeCreated │ └──────────────────────────────┘ │
│ │ LeaveApproved │ │
│ └────────┬─────────┘ │
│ │ EmployeeCreatedEvent │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ TEAM CONTEXT [Supporting] │ │
│ │ TeamMember (projection of Employee) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ WORKSPACE CONTEXT [Generic] │ │
│ │ Workspace, Integration │ │
│ │ Gates: Command Center API calls │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ EXTERNAL: RoundTrip (via Command Center API) │
│ Anticorruption Layer: ICommandCenterClient │
└──────────────────────────────────────────────────────────┘
1.3 Anticorruption Layer — RoundTrip Integration
RoundTrip data enters Waypoint through an Anticorruption Layer (ACL). Waypoint's domain model never depends on RoundTrip's domain model — it translates RoundTrip concepts into Waypoint concepts.
// Waypoint's view of RoundTrip data — Waypoint's own DTOs
public record RoundTripRevenueSummary(
decimal MtdRevenue,
decimal YtdRevenue,
int PaidInvoiceCount,
DateTime AsOf);
public record RoundTripOpenTicketSummary(
int TotalOpen,
int Unassigned,
int InProgress,
decimal EstimatedValue);
// The ACL interface — Waypoint's domain depends on this abstraction
public interface ICommandCenterClient
{
Task<RoundTripRevenueSummary?> GetRevenueSummaryAsync(
Guid tenantId, CancellationToken ct);
Task<RoundTripOpenTicketSummary?> GetOpenTicketSummaryAsync(
Guid tenantId, CancellationToken ct);
Task<IReadOnlyList<RoundTripTeamMember>> GetTeamAsync(
Guid tenantId, CancellationToken ct);
}
If RoundTrip's API changes, only the CommandCenterClient implementation changes — Waypoint's domain model is unaffected.
2. Tactical Design Patterns
2.1 Aggregates
Design Rule: Small Aggregates
Waypoint aggregates are deliberately kept small. The Employee aggregate owns its certifications, leave requests, and performance reviews because these cannot exist without an employee and are always accessed through the employee. However, documents are NOT on the Employee aggregate — they are a separate EmployeeDocument concern accessed via the employee's ID.
Correct:
// Load employee, add certification through the aggregate
var employee = await _employees.GetByIdAsync(employeeId, ct);
employee.AddCertification("HVAC License", "EPA", issueDate, expiryDate);
await _employees.UpdateAsync(employee, ct);
await _uow.SaveChangesAsync(ct);
Incorrect:
// NEVER bypass the aggregate root
_context.Certifications.Add(new Certification { EmployeeId = id, ... });
await _context.SaveChangesAsync();
2.2 Value Objects
All value objects in Waypoint are readonly record struct — immutable, equality by value, no identity.
Money — Critical Value Object
All financial values use Money. Never use raw decimal for currency.
public readonly record struct Money(decimal Amount, string Currency)
{
public static readonly Money Zero = new(0m, "USD");
public static Money Create(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new DomainException("Money amount cannot be negative.");
return new Money(amount, currency.ToUpperInvariant());
}
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException(
$"Cannot add {Currency} and {other.Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new DomainException(
$"Cannot subtract {Currency} from {other.Currency}.");
return new Money(Amount - other.Amount, Currency);
}
public override string ToString() => $"{Currency} {Amount:N2}";
}
StoragePath — Blob Storage Value Object
public readonly record struct StoragePath(
Guid TenantId,
string Container,
string BlobName)
{
public string FullPath => $"{TenantId}/{BlobName}";
public static StoragePath Create(
Guid tenantId, string container, string fileName)
{
var blobName = $"{Guid.NewGuid()}/{fileName}";
return new StoragePath(tenantId, container, blobName);
}
}
DateRange — Leave and Financial Periods
public readonly record struct DateRange(DateOnly Start, DateOnly End)
{
public static DateRange Create(DateOnly start, DateOnly end)
{
if (end < start)
throw new DomainException("End date cannot be before start date.");
return new DateRange(start, end);
}
public int WorkingDays()
{
int days = 0;
var current = Start;
while (current <= End)
{
if (current.DayOfWeek != DayOfWeek.Saturday &&
current.DayOfWeek != DayOfWeek.Sunday)
days++;
current = current.AddDays(1);
}
return days;
}
public bool Overlaps(DateRange other) =>
Start <= other.End && End >= other.Start;
}
2.3 Domain Events
Domain events in Waypoint follow the same pattern as RoundTrip. Events are collected on the aggregate and dispatched post-commit by TransactionBehavior.
Key Events and Their Handlers
LeaveApprovedEvent
Trigger: Employee.ApproveLeave(leaveRequestId)
Handlers:
1. TeamScheduleHandler — updates TeamMember schedule
2. DashboardCacheHandler — invalidates "Upcoming Leave" widget cache
3. NotificationHandler — sends in-app notification to WaypointAdmin
CertificationExpiringEvent (raised by Hangfire, not aggregate)
Trigger: DailyCertificationCheckJob — checks all certs expiring in 90/60/30/14/7 days
Handlers:
1. DashboardCacheHandler — updates "Expiring Certs" widget
2. EmailNotificationHandler — sends email to WaypointAdmin
3. InAppNotificationHandler — creates in-app notification
EmployeeCreatedEvent
Trigger: Employee.Create(...)
Handlers:
1. TeamContextHandler — creates corresponding TeamMember record
RevenueEntryCreatedEvent / ExpenseCreatedEvent
Trigger: RevenueEntry.Create(...) / Expense.Create(...)
Handlers:
1. DashboardCacheHandler — invalidates Revenue MTD and P&L cache
2.4 Repositories
Only aggregate roots have repositories. Child entities are accessed through their aggregate root.
// CORRECT
public interface IEmployeeRepository
{
Task<Employee?> GetByIdAsync(EmployeeId id, CancellationToken ct);
Task<IReadOnlyList<Employee>> ListByTenantAsync(
TenantId tenantId, bool activeOnly, CancellationToken ct);
Task AddAsync(Employee employee, CancellationToken ct);
Task UpdateAsync(Employee employee, CancellationToken ct);
}
// WRONG — no repository for child entities
// public interface ICertificationRepository ← NEVER
// public interface ILeaveRequestRepository ← NEVER
2.5 Domain Services
Domain services handle logic that spans multiple aggregates or requires coordination that doesn't belong in a single aggregate.
PtoCalculationService
Calculates PTO accrual based on start date and accrual policy. Lives in the domain layer — no infrastructure dependencies.
public sealed class PtoCalculationService
{
public decimal CalculateAccrued(
DateOnly startDate,
DateOnly asOfDate,
PtoAccrualPolicy policy)
{
var monthsWorked = MonthsBetween(startDate, asOfDate);
return Math.Round(monthsWorked * policy.DaysPerMonth, 1);
}
}
FinancialSummaryService
Calculates P&L by aggregating revenue entries and expenses for a given period.
public sealed class FinancialSummaryService
{
public ProfitLossSummary Calculate(
IReadOnlyList<RevenueEntry> revenue,
IReadOnlyList<Expense> expenses,
DateRange period)
{
var totalRevenue = revenue
.Where(r => period.Contains(r.Date))
.Aggregate(Money.Zero, (sum, r) => sum.Add(r.Amount));
var totalExpenses = expenses
.Where(e => period.Contains(e.Date))
.Aggregate(Money.Zero, (sum, e) => sum.Add(e.Amount));
var grossProfit = totalRevenue.Subtract(totalExpenses);
var margin = totalRevenue.Amount > 0
? grossProfit.Amount / totalRevenue.Amount * 100
: 0m;
return new ProfitLossSummary(totalRevenue, totalExpenses, grossProfit, margin);
}
}
3. CQRS in Waypoint
Same CQRS pattern as RoundTrip — commands change state, queries read state via Dapper.
Read Side (Queries — Dapper)
The Dashboard summary query is the most performance-critical read. It runs in parallel:
// Query — bypasses aggregates entirely, reads directly from DB
public sealed class GetPLOverviewHandler
: IRequestHandler<GetPLOverviewQuery, Result<PLOverviewDto>>
{
public async ValueTask<Result<PLOverviewDto>> Handle(
GetPLOverviewQuery query, CancellationToken ct)
{
const string sql = """
SELECT
YEAR(Date) AS Year,
MONTH(Date) AS Month,
SUM(CASE WHEN t = 'Revenue' THEN Amount ELSE 0 END) AS Revenue,
SUM(CASE WHEN t = 'Expense' THEN Amount ELSE 0 END) AS Expenses
FROM (
SELECT Date, Amount, 'Revenue' AS t
FROM RevenueEntries
WHERE TenantId = @TenantId
UNION ALL
SELECT Date, Amount, 'Expense' AS t
FROM Expenses
WHERE TenantId = @TenantId
) combined
WHERE Date >= @FromDate AND Date <= @ToDate
GROUP BY YEAR(Date), MONTH(Date)
ORDER BY Year, Month
""";
using var conn = await _db.CreateConnectionAsync(ct);
var rows = await conn.QueryAsync<MonthlyPLRow>(sql, new {
TenantId = _tenant.TenantId,
FromDate = query.FromDate,
ToDate = query.ToDate,
});
return Result<PLOverviewDto>.Success(
PLOverviewDto.From(rows.ToList()));
}
}
Write Side (Commands — EF Core through Aggregates)
// Command — always goes through aggregate root
public sealed class ApproveLeaveHandler
: IRequestHandler<ApproveLeaveCommand, Result<ApproveLeaveResponse>>
{
public async ValueTask<Result<ApproveLeaveResponse>> Handle(
ApproveLeaveCommand command, CancellationToken ct)
{
var employee = await _employees.GetByIdAsync(
EmployeeId.Create(command.EmployeeId), ct);
if (employee is null)
return Result<ApproveLeaveResponse>.NotFound();
// Business logic in the aggregate — not in the handler
employee.ApproveLeave(LeaveRequestId.Create(command.LeaveRequestId));
await _employees.UpdateAsync(employee, ct);
await _uow.SaveChangesAsync(ct);
// Domain events dispatched post-commit by TransactionBehavior
return Result<ApproveLeaveResponse>.Success(
new ApproveLeaveResponse(command.LeaveRequestId));
}
}
4. Anti-Patterns to Avoid
| Anti-Pattern | How It Manifests | Correct Approach |
|---|---|---|
| Anemic Employee | EmployeeService.Approveleave(employee, leaveId) — service does the work | employee.ApproveLeave(leaveId) — aggregate owns the behaviour |
| Cross-context DB query | Waypoint handler queries RoundTrip tables directly | Always go through ICommandCenterClient |
| Raw decimal for money | decimal monthlyCost = 1200.50m | Money monthlyCost = Money.Create(1200.50m) |
| Repo for child entity | ICertificationRepository | Access certifications through IEmployeeRepository |
| Fat handler | Handler calculates P&L, applies business rules, sends emails | Handler orchestrates only — domain service calculates, aggregate rules, event handlers notify |
5. Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | July 2026 | Pete Carroll | Initial draft |