Skip to content
Asmar.
← Blog
GuideMay 23, 20264 min readSeries: Platform standards

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.

ShareLinkedInX

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 locationsshared/kernel/ports/, shared/application/ports/, modules/<feature>/application/ports/, and more.
  • Three different interface namesIClock, 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.

typescript
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:

  1. Swap the local IClock import for the platform ClockPort + token.
  2. Replace DI registration and @Inject('CLOCK') with the platform token.
  3. Update use-cases to call clock.nowMs() / clock.nowIso() / clock.now().
  4. Delete the service-local IClock.ts + SystemClock.ts.
  5. Point unit tests at the shared TestClock.

And the acceptance gate is a grep — which is exactly the kind of check that survives:

bash
# 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.

ShareLinkedInX

Keep reading

Stay in the loop

New posts, in your inbox.

When I publish on architecture, AI engineering, or the systems I'm building, you'll be the first to know.

New posts only. No spam; unsubscribe anytime.