Skip to main content

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-domainTypeRationale
Financial PerformanceCore DomainThe P&L visibility and revenue tracking is Waypoint's primary differentiator. This is what owners open Waypoint to see.
HR & People ManagementSupporting DomainImportant but well-understood. Employee records, leave, certifications — valuable but not unique.
Document ManagementSupporting DomainFile storage with structure. Valuable operationally but commodity functionality.
Team ManagementSupporting DomainTeam visibility is valuable but derivative of HR and RoundTrip data.
Workspace ConfigurationGeneric Sub-domainSettings, 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-PatternHow It ManifestsCorrect Approach
Anemic EmployeeEmployeeService.Approveleave(employee, leaveId) — service does the workemployee.ApproveLeave(leaveId) — aggregate owns the behaviour
Cross-context DB queryWaypoint handler queries RoundTrip tables directlyAlways go through ICommandCenterClient
Raw decimal for moneydecimal monthlyCost = 1200.50mMoney monthlyCost = Money.Create(1200.50m)
Repo for child entityICertificationRepositoryAccess certifications through IEmployeeRepository
Fat handlerHandler calculates P&L, applies business rules, sends emailsHandler orchestrates only — domain service calculates, aggregate rules, event handlers notify

5. Document History

VersionDateAuthorChanges
1.0July 2026Pete CarrollInitial draft