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