DEV Community

Cover image for Injecting a Clock in Go: Testable Time in Your Hexagonal Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Injecting a Clock in Go: Testable Time in Your Hexagonal Domain


You wrote a function that expires a subscription after 30 days. The test passed on your machine. Then CI ran it at 23:59 UTC, the clock ticked past midnight between two time.Now() calls, and the assertion flipped. Someone re-ran the job, it went green, and everybody moved on.

That test did not flake. It told the truth. Your business logic reads the wall clock directly, so its behavior depends on the exact instant the process happens to run. You cannot test a function whose answer changes every second unless you can freeze the second.

The fix is old and boring: stop calling time.Now() inside domain code. Put time behind a port, inject a real clock in production, inject a fake one in tests. In a hexagonal Go service this costs you one small interface and about ten lines of adapter. Here is the whole thing.

Where the wall clock leaks in

The tell is a time.Now() call sitting in the middle of a rule.

package subscription

import "time"

type Subscription struct {
    ID       string
    StartedAt time.Time
    Plan     string
}

func (s Subscription) IsExpired() bool {
    return time.Now().Sub(s.StartedAt) > 30*24*time.Hour
}
Enter fullscreen mode Exit fullscreen mode

IsExpired has a hidden input. It reads the global clock, so the same Subscription value returns false today and true next month. To test the expired branch you either sleep, or you backdate StartedAt and hope the arithmetic lines up. Both are worse than the disease.

The same leak shows up as time.Since, time.Now().Unix(), time.Now().Add(ttl) for a cache entry, or a created_at stamped inside a repository. Every one of them is an undeclared dependency on the moment the code runs.

The Clock port

Define the smallest interface that covers what your domain actually asks of time. For most services that is "what time is it now."

package clock

import "time"

type Clock interface {
    Now() time.Time
}
Enter fullscreen mode Exit fullscreen mode

That is the port. It lives with your domain, not in an adapter package, because the domain is the thing that depends on it. Keep it this small. Do not add Sleep, After, or Timer until a piece of domain logic needs them — and most domain logic does not, because sleeping is an adapter concern.

Now IsExpired takes the clock as an input instead of reaching for a global.

package subscription

import (
    "time"

    "yourapp/domain/clock"
)

func (s Subscription) IsExpired(c clock.Clock) bool {
    return c.Now().Sub(s.StartedAt) > 30*24*time.Hour
}
Enter fullscreen mode Exit fullscreen mode

The rule is now a pure function of its inputs. Give it a subscription and a clock, and its answer is fixed. Nothing about the wall clock leaks in.

The real adapter

Production wants the actual system time. That adapter is four lines.

package clockadapter

import "time"

type System struct{}

func (System) Now() time.Time { return time.Now() }
Enter fullscreen mode Exit fullscreen mode

System{} satisfies clock.Clock and does exactly what the old code did, except now it is a value you pass in rather than a global you call. Wire it once at the edge of the program, in main, where every other adapter gets constructed.

func main() {
    var clk clock.Clock = clockadapter.System{}

    svc := subscription.NewService(repo, clk)
    // ... http server, etc.
}
Enter fullscreen mode Exit fullscreen mode

The service holds the clock and hands it to the domain when a rule needs it. The dependency points inward: main knows about the concrete System, the domain knows only the interface.

The fake

The test wants time to hold still. A fake clock is a struct wrapping a time.Time you control.

package clockadapter

import (
    "sync"
    "time"
)

type Fake struct {
    mu  sync.Mutex
    now time.Time
}

func NewFake(t time.Time) *Fake {
    return &Fake{now: t}
}

func (f *Fake) Now() time.Time {
    f.mu.Lock()
    defer f.mu.Unlock()
    return f.now
}

func (f *Fake) Advance(d time.Duration) {
    f.mu.Lock()
    defer f.mu.Unlock()
    f.now = f.now.Add(d)
}
Enter fullscreen mode Exit fullscreen mode

Advance lets a test move time forward by an exact amount. The mutex is there because a fake often gets read from a goroutine under test while the test body advances it; without the lock the race detector will catch you.

Now the expired-subscription rule tests without a single real second passing.

func TestSubscription_IsExpired(t *testing.T) {
    start := time.Date(
        2026, 1, 1, 0, 0, 0, 0, time.UTC,
    )
    clk := clockadapter.NewFake(start)

    sub := subscription.Subscription{
        ID:        "sub_1",
        StartedAt: start,
        Plan:      "pro",
    }

    if sub.IsExpired(clk) {
        t.Fatal("fresh subscription should not be expired")
    }

    clk.Advance(31 * 24 * time.Hour)

    if !sub.IsExpired(clk) {
        t.Fatal("31 days in, should be expired")
    }
}
Enter fullscreen mode Exit fullscreen mode

No sleep. No backdating. No dependence on when CI runs the suite. You state the instant, you advance it by a known amount, you assert on both sides of the boundary. Run it at midnight, run it on a leap second, it says the same thing every time.

Stamping time on writes

The other place the wall clock leaks is the moment you record when something happened. A repository that stamps created_at with time.Now() is untestable for the same reason.

Route that through the same port. The service reads the clock and passes the timestamp down, so the value is controlled from one place instead of scattered across adapters.

package subscription

import (
    "context"

    "yourapp/domain/clock"
)

type Service struct {
    repo Repository
    clk  clock.Clock
}

func NewService(r Repository, c clock.Clock) *Service {
    return &Service{repo: r, clk: c}
}

func (s *Service) Start(
    ctx context.Context, id, plan string,
) (Subscription, error) {
    sub := Subscription{
        ID:        id,
        Plan:      plan,
        StartedAt: s.clk.Now(),
    }
    if err := s.repo.Save(ctx, sub); err != nil {
        return Subscription{}, err
    }
    return sub, nil
}
Enter fullscreen mode Exit fullscreen mode

The repository stores whatever StartedAt it is given. It no longer decides what "now" means. A test that starts a subscription can assert the stored StartedAt equals the fake's frozen instant, exactly, with no tolerance window.

When the domain needs to wait

Sometimes a rule is about elapsed real time, not a fixed instant: a rate limiter, a retry backoff, a debounce. Waiting is an adapter job, so the port grows only if a domain rule genuinely needs to schedule something. When it does, After is the shape to add, because it returns a channel you can drive from a fake.

type Clock interface {
    Now() time.Time
    After(d time.Duration) <-chan time.Time
}
Enter fullscreen mode Exit fullscreen mode

The system adapter forwards to the standard library.

func (System) After(d time.Duration) <-chan time.Time {
    return time.After(d)
}
Enter fullscreen mode Exit fullscreen mode

The fake returns a channel it controls, firing it when the test advances past the deadline instead of after real wall-clock time. That is more machinery than most services need, which is why the two-method interface should stay a two-method interface until something forces the third. If you want an existing implementation instead of hand-rolling it, the standard library added testing/synctest as an experiment in Go 1.24 (stable in Go 1.25) for testing concurrent code against a fake clock, and k8s.io/utils/clock has carried this exact interface for years.

Why the port and not a package-level variable

A common shortcut is a package global you can swap in tests:

var Now = time.Now // reassign in tests
Enter fullscreen mode Exit fullscreen mode

It works until two tests run in parallel and both reassign it. It works until a second part of the code reads time.Now() directly and the global does not cover it. It works until you have two subsystems that want different fake clocks in the same test binary. The global is shared mutable state, and shared mutable state in tests is the thing you were trying to escape.

The injected port has none of those problems. Each test constructs its own Fake, passes it to the code under test, and throws it away. Nothing is shared, so nothing races, and t.Parallel() stays safe. The clock became a value, which is the whole point: time stopped being a global effect and started being an argument, the same way a database handle or an HTTP client already is in a hexagonal Go service.

Grep your domain packages for time.Now(. Every hit is a rule whose answer depends on the wall clock. Push each one behind the port, and your time-sensitive tests stop being a coin flip.


If this was useful

A Clock port is a small example of a larger habit: pushing every ambient dependency (time, randomness, IDs, the network) to the edge so the core stays a pure function of its inputs. Hexagonal Architecture in Go walks that boundary in full, where ports live, how adapters wire in at main, and how far to take it before it turns into ceremony. The Complete Guide to Go Programming covers the language and stdlib underneath: time, interfaces, and the concurrency primitives the fake clock leans on.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)