Stop calling new Date() in your services
Time and randomness are infrastructure. When every service rolls its own clock port, you get six file paths and three interface names for one concern. The fix is one platform port — and the testability it unlocks.
This RFC was triggered by a recommendation to "replace new Date() with the platform's clock port." The problem: the port didn't exist. The audit had flagged a fix that targeted a non-existent feature — and in doing so, surfaced something worse.
Across 15 backend services, every one had independently rolled its own clock. Same concern, fourteen slightly different shapes.
Two primitives every service needs, and nobody should own#
System time and randomness are needed by every service to keep domain and application code testable. You cannot freeze Date.now() in a unit test without monkey-patching globals, and you cannot assert on a code path that calls crypto.randomUUID() inline. So the textbook move is to put both behind a driven port and inject them.
The textbook move, applied 15 times independently, produces drift:
- Six different file locations —
shared/kernel/ports/,shared/application/ports/,modules/<feature>/application/ports/, and more. - Three different interface names —
IClock,Clock,ClockPort. - One service had two clock ports (a defect entirely inside one service). Another had zero — ten source files calling
new Date()directly, untestable.
A platform engineer reading two services in the same week sees two divergent shapes for the same concern. And the real killer: a platform-wide test harness cannot mock 14 incompatible interfaces.
The decision: one port, in the package that already exists#
Time and randomness belong with the other vendor-neutral infrastructure ports — the same category as an HTTP client: system-bound, swappable, no business logic. Not with the branded primitives (TenantId, Money); those are value objects, not adapters.
export interface ClockPort {
/** Wall-clock time in milliseconds since the Unix epoch. */
nowMs(): number;
/** ISO-8601 UTC string at the current instant. */
nowIso(): string;
/** A Date at the current instant. */
now(): Date;
}
export interface RandomPort {
/** RFC 4122 v4 UUID. */
uuid(): string;
/** Cryptographically-secure random bytes as base64url. */
bytes(n: number): string;
/** Crockford Base32 ULID (26 chars). Monotonic within a millisecond. */
ulid(): string;
}
Each ships with a default SystemClock / SystemRandom adapter and a DI token — and, critically, a TestClock / TestRandom in the test-utils package. One fake clock, usable by every service and every cross-service integration test.
Why not just bless one of the existing patterns?#
I considered amending the rules to say "the service-local IClock is canonical." Rejected, for a reason worth internalizing:
There are already 6 file locations and 3 interface names across 14 services. Codifying any one of them invalidates the other 13. The per-service-port precedent only exists because no platform port did. Once a platform port exists, the precedent disappears.
The "everyone rolls their own" pattern isn't a convention you should ratify. It's the absence of a convention, wearing a convention's clothes.
Migration: per service, on its own freeze cycle#
This is the load-bearing part, and it is not a big-bang PR. Each service picks up the migration on its next freeze-and-implement cycle. The per-service deliverable is mechanical:
- Swap the local
IClockimport for the platformClockPort+ token. - Replace DI registration and
@Inject('CLOCK')with the platform token. - Update use-cases to call
clock.nowMs()/clock.nowIso()/clock.now(). - Delete the service-local
IClock.ts+SystemClock.ts. - Point unit tests at the shared
TestClock.
And the acceptance gate is a grep — which is exactly the kind of check that survives:
# Zero hits = the service is clean
grep -rn 'IClock\|SystemClock\|UlidIdentifierPort' services/<svc>/src/
grep -rn 'new Date()\|crypto.randomUUID()' services/<svc>/src/ \
| grep -v 'lib/adapters/' # composition root is allowed
Order matters: start with the smallest, cleanest service as the reference migration, then the ones that share its shape, then the messy ones (the service with two ports, the services with four module-scoped clocks). Each is one PR. No bundling.
The takeaway#
If a fix points at a port that doesn't exist, the bug isn't the missing new Date() replacement — it's the missing port. Time and randomness feel too trivial to centralize, which is exactly why every team re-implements them and why none of them match. Put them behind one shared, mockable port early, and "is this code testable?" stops being a per-service answer.
Keep reading
ULIDs over UUIDs: standardizing identifiers across 40 services
An implicit decision left unenforced becomes 40 services split 60/40 between two incompatible identifier shapes. Here's the standard I wrote to close the door — and why ULID won.
- Architecture
- PostgreSQL
- Platform engineering
- DDD
ADR: Outbox over dual-write for cross-context events
Why every state-changing event in the Ghasi suite goes through a transactional outbox instead of a direct publish — and what that costs.
- Event-driven
- DDD
- Reliability
- PostgreSQL
AI-first is a posture, not a feature
Most platforms bolt AI onto a non-AI core. Treating it as foundational architecture — a single governed gateway, provenance on every artifact — changes what you can promise.
- AI-first
- Architecture
- Governance
- DDD