Architecture Decision Record
ADR-128: Time as an Input (No Ambient Clock Reads in Domain and Application)
Status
Accepted (2026-10-01).
Context
Expiry windows, payment deadlines, discount windows, overdue checks and cutoffs are business rules,
and every one of them branches on "now". When the rule reads DateTime.UtcNow itself, a test cannot
choose which side of the branch it lands on: the branch is either untested or tested by sleeping
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.ClockReads.cs:30-36).
.NET already ships the input that removes the problem, TimeProvider, which a handler can take by
injection and a test can replace with a fake clock.
The convention existed before it was enforced, and a convention that lives only in prose is followed
until someone is in a hurry. It also has one place where reading the clock is the correct model:
a domain event's occurrence instant is, by definition, the moment the aggregate raises it, so
BaseDomainEvent.DateOccurred defaults to DateTime.UtcNow at construction
(MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28, with the reasoning in
the type's remarks at :18-25). Any enforcement has to accept that one stamp without turning into a
list every consumer has to repeat.
The rule shipped as a fitness base in MMCA.Common v1.210.0 (MMCA.Common/CHANGELOG.md:269, the entry
at :282-285), and v1.213.0 extended it to DateTime.Today (MMCA.Common/CHANGELOG.md:82, the entry
at :173). ADR-015 sets the fitness-function style it is
written in but does not list this rule, so this record states it.
Decision
Domain and Application code takes time as an input and never reads the ambient clock. A handler
injects TimeProvider and passes the instant into the domain method; an aggregate method receives the
instant as a parameter. The rule is an IL-scanning fitness test, shipped once in
MMCA.Common.Testing.Architecture and subclassed by each adopting repo.
- Five getters are banned.
DateTime.UtcNow,DateTime.Now,DateTime.Today,DateTimeOffset.UtcNowandDateTimeOffset.Now, matched as (declaring type, getter) pairs (ArchitectureRules.ClockReads.cs:20-27) by ordinal comparison of the callee name and declaring type full name (:120-127). - Scope is the map's Domain and Application layers.
DomainAndApplicationDoNotReadTheClock(ArchitectureRules.ClockReads.cs:56) collects the assemblies the repo'sIArchitectureMapregisters underLayer.DomainandLayer.Application(:65-70). Infrastructure, API and UI code is outside the rule. - The scan reads IL, not source. Each assembly is opened with Mono.Cecil (
:78) and every method body of every type is searched (:80,:104), which includes lambdas and async or iterator state machines whether the compiler emits them as nested types or as methods on the declaring type. A read in a lambda or in an async or iterator method is attributed back to the member the developer wrote, recovered from the text inside the first angle brackets of the generated name (:129-160), so the report readsType.Member reads DateTime.UtcNow(:114). The name is cut at the first>(:158-159), so the doubly bracketed state machine of an async lambda or async local function reports a member name that keeps a leading<. - A vacuous scan is a failure. A map that yields no Domain or Application assembly fails with that
explanation rather than passing (
:72-73). - The failure message is the instruction. It names the fix (inject
TimeProvider, pass the instant into the domain method) and the escape hatch (allowlist the type orType.Member) (:85-88). - The framework exemption is exactly one type, built into the rule. The constant
DomainEventOccurrenceStampis the type full nameMMCA.Common.Domain.DomainEvents.BaseDomainEvent(ArchitectureRules.ClockReads.cs:14), prepended to every caller's allowlist (:63), so no map has to repeat it. Because the exemption is keyed on the owning type (:98-102), it covers theDateOccurredinitializer compiled intoBaseDomainEventand nothing declared on a derived event. - The allowlist is an adoption ratchet. Entries are a type full name, a namespace prefix, or one
member written
Namespace.Type.Member(:45-48); a member entry is matched by ordinal equality on{owner}.{member}(:107) and exempts that member's lambda and async-method bodies with it (not the state machine of an async lambda inside it, whose recovered name keeps a leading<). The base exposes it asAllowedClockReaders, empty by default (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/ClockReadTestsBase.cs:24), and its documentation asks for a comment saying why each entry is right (:10-12). - The base is one fact over one map.
ClockReadTestsBase(ClockReadTestsBase.cs:15) declares an abstractMap(:17) and one[Fact],DomainAndApplication_ShouldNotReadTheAmbientClock, that calls the rule (:26-28). - MMCA.Common adopts it over its own code and self-tests the rule. Its subclass
(
MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/ClockReadTests.cs:14) maps the framework throughCommonArchitectureMap(:20) with no allowlist, and runs the rule against compiled fixtures through a map whose Domain layer is the test assembly (:84-92): direct reads of both clock types are flagged (:22-29),DateTime.Todayis flagged (:31-35), lambda and async reads are attributed to their source member (:37-48), an injectedTimeProvideris not flagged (:50-54), a member entry exempts only that member (:56-63), and a namespace entry exempts every type under it (:65-67). - MMCA.ADC adopts it with no exemptions. Its subclass
(
MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/ClockReadTests.cs:8) suppliesAdcArchitectureMap(:10) and does not overrideAllowedClockReaders. - MMCA.Store adopts it with one reviewed exemption. Its subclass
(
MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Domain/ClockReadTests.cs:9) suppliesStoreArchitectureMap(:11) and allowlists the single memberMMCA.Store.Sales.Domain.Orders.Order.RepublishFulfillment(:18-21), documented as backfill-only: its one caller, the one-shotOrderFulfilledBackfillService, supplies no clock, and the member is removed with the backfill services (:13-17). - MMCA.Helpdesk does not adopt it. Helpdesk has an architecture test project
(
MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/), but no type in the repo subclassesClockReadTestsBaseor calls the rule, so its Domain and Application code is not checked.
Rationale
- A test can only drive what it can supply. Passing the instant in makes every time-dependent
branch a plain input, so an expiry, a cutoff or an overdue state is one argument away in a unit test
instead of a
Task.Delayor an untested path (ArchitectureRules.ClockReads.cs:33-35). - Executable beats documented. This is the enforcement style ADR-015 sets and ADR-125 applies to raw SQL: a failing test whose message names the fix needs nobody to remember the convention.
- IL is the only layer that sees the whole shape. A reflection-only rule cannot see calls inside
method bodies, and a text scan would miss a read hidden behind a
using staticor flag one inside a comment. Mono.Cecil reads what the compiler actually emitted, including lambda and state-machine bodies, at the cost of a dependency NetArchTest already carries (:40-43). - The one framework exemption belongs to the framework.
DateOccurredis a deliberate modelling choice (BaseDomainEvent.cs:18-25), and every repo whose map registers the framework Domain assembly would otherwise need the same entry (ArchitectureRules.ClockReads.cs:7-13). Building it into the rule keeps every consumer allowlist to its own decisions. - An allowlist lets a repo adopt the day the rule ships. A repo with existing reads subclasses, runs
once, and either threads the instant through or parks the reported member with a reason
(
ClockReadTestsBase.cs:10-12). Store's single entry is the result of that pass.
Trade-offs
- The rule bans five getters, not the idea of a clock. A read through any other path (a static
TimeProvider.System.GetUtcNow(),Environment.TickCount,Stopwatch) is not matched (ArchitectureRules.ClockReads.cs:20-27), so the rule stops the common accident rather than proving the code is clock-free. - Infrastructure, API and UI are not scanned (
:65-66). Audit stamping and other infrastructure timestamps are expected to use an injectedTimeProvider(BaseDomainEvent.cs:22-23), but this rule does not check that. DateOccurredstays non-deterministic in tests. Because the stamp is taken at construction, a test asserting an exact occurrence instant has to set it through theinitaccessor (BaseDomainEvent.cs:28) rather than through a fake clock.- The scan only covers assemblies present on disk. Assemblies whose location is empty or missing
are dropped before the scan (
ArchitectureRules.ClockReads.cs:68); the vacuous-scan check fires only when none remain (:72-73). - A type or namespace entry exempts more than one read. A type entry exempts every member of that
type and a namespace entry every type under it (
:45-48,:98-102), which is wider than a member entry; Store uses the narrow form. - MMCA.Helpdesk is unguarded. As the reference app and template source, it hands an adopter no clock-read test until it subclasses the base.
Related
ADR-015 (the fitness-function style and the shared
*TestsBase package this rule is shipped in),
ADR-125 (the precedent for a fitness-gated convention with its own
record and an allowlist used as an adoption ratchet). Framework version and package figures live in
MMCA.Common/FACTS.md.