Kronos Clock

An injectable time source. System clock in production, fixed or advancing clock in tests.

The problem with "now"

Calling Clock.System.now() directly makes code hard to test. You can't control what "now" means, so you end up with flaky tests, Thread.sleep() hacks, or mocking libraries. Kronos solves this by making the clock an explicit dependency.

If you've ever written a test that passes at 11:59 PM but fails at midnight, you know why this matters.

System clock

In production, use the system clock:

import io.peekandpoke.ultra.datetime.Kronos

val kronos = Kronos.systemUtc

val now = kronos.instantNow()
val today = kronos.localDateNow(MpTimezone.UTC)
val currentTime = kronos.localDateTimeNow()
val zoned = kronos.zonedDateTimeNow(MpTimezone.of("Europe/Berlin"))

Fixed clock

In tests, freeze time at a specific instant:

val frozen = Kronos.fixed(MpInstant.parse("2025-06-15T12:00:00Z"))

frozen.instantNow()  // always 2025-06-15T12:00:00Z
frozen.instantNow()  // still 2025-06-15T12:00:00Z

// Fix to "now" at construction time
val frozenNow = Kronos.fixedNow()

Advancing clock

advanceBy returns a new Kronos that runs ahead of (or behind) the one it wraps:

import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes

val kronos = Kronos.fixed(MpInstant.parse("2025-06-15T12:00:00Z"))

val advanced = kronos.advanceBy(2.hours)
advanced.instantNow()  // 2025-06-15T14:00:00Z

val nextDay = advanced.advanceBy(1.days)
nextDay.instantNow()   // 2025-06-16T14:00:00Z

Chained advances, including repeated mutable.mutate { advanceBy(..) } steps, collapse into a single offset, so a clock advanced a million times reads as fast as one advanced once.

When the offset is not known yet, pass a lambda. It is called once, the first time the clock is read, never while chaining. If it throws, it is called again on the next read.

// The offset lives in the database, which is not ready while the app boots
val kronos = Kronos.systemUtc.advanceBy { settings.loadClockOffset() }

Mutable clock

For tests and simulations that move time mid-execution. mutate replaces the clock with what the block returns, set replaces it outright:

val mutable = Kronos.fixed(MpInstant.parse("2025-01-01T00:00:00Z")).mutable()

mutable.mutate { advanceBy(1.hours) }
mutable.instantNow()  // 2025-01-01T01:00:00Z

mutable.set(Kronos.fixed(MpInstant.parse("2030-01-01T00:00:00Z")))
mutable.instantNow()  // 2030-01-01T00:00:00Z

advanceBy on a mutable does not move it: it returns a new clock that follows the mutable. set and mutate throw IllegalArgumentException for a clock that reads the mutable itself, which would recurse forever on every read. The check sees through mutables and advanceBy layers only, not into advanceBy { } lambdas or custom clocks, and is not safe against two threads setting two mutables to read each other.

„mutable.mutate { mutable.advanceBy(1.hours) } looks like it advances the clock. It asks the clock to read itself, forever. It used to end in a stack overflow on the first read, now it ends at the call site."

Injecting with Kontainer

Kronos works naturally with Kontainer for dependency injection:

// Production setup
val blueprint = kontainer {
    singleton(Kronos::class) { Kronos.systemUtc }
}

// Test setup — override with a fixed clock
val testBlueprint = kontainer {
    singleton(Kronos::class) {
        Kronos.fixed(MpInstant.parse("2025-06-15T12:00:00Z"))
    }
}

// Services just inject Kronos
class EventService(private val kronos: Kronos) {
    fun createEvent(name: String) = Event(
        name = name,
        createdAt = kronos.instantNow(),
    )
}

KronosDescriptor

describe() returns a serializable KronosDescriptor, and descriptor.instantiate() turns it back into a Kronos:

val descriptor = Kronos.systemUtc.advanceBy(10.minutes).advanceBy(1.hours).describe()
// KronosDescriptor.AdvancedBy(durationMs = 4_200_000, inner = KronosDescriptor.SystemClock)

val kronos = descriptor.instantiate()

A chain of advances describes as one AdvancedBy. Nested descriptors written by older versions still instantiate.

„A fixed clock describes itself as the system clock, so it does not survive the round trip. Good enough for shifting a server's idea of now; not a way to persist a frozen test clock."