- Book: Hexagonal Architecture in Go
- Also by me: The Complete Guide to Go Programming — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
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
}
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
}
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() }
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.
}
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)
}
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")
}
}
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
}
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
}
The system adapter forwards to the standard library.
func (System) After(d time.Duration) <-chan time.Time {
return time.After(d)
}
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
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.

Top comments (0)