$ whoami kotlin-utils
kotlin-utils
Framework-agnostic Kotlin library — saga/flow orchestration, structured logging, exception hierarchy, backoff retry, validation DSL. Coroutines-native, zero framework coupling.
$ cat ~/principles.md #
Zero framework coupling
Only depends on kotlinx-coroutines and slf4j. Works with Ktor, Quarkus, Spring, Compose, or any Kotlin/JVM project.
Coroutines-native API
Every step is suspend fun. No reactive wrappers (Mutiny, Reactor, RxJava) — the suspend signature is the contract.
Minimalist surface
Only what's needed, no over-engineering. If the lib solves a problem, it's a 10-line function — not a framework.
$ cat ~/install.md #
Add the dependency to your build.gradle.kts:
// build.gradle.kts — published on GitHub Packages
repositories {
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/juanMaAV92/kotlin-utils")
credentials {
username = System.getenv("GITHUB_USERNAME")
password = System.getenv("GITHUB_TOKEN") // token with read:packages
}
}
}
dependencies {
implementation("com.juanmaav:kotlin-utils:0.2.0")
// the lib ships slf4j-api at runtime — pick a backend
implementation("ch.qos.logback:logback-classic:1.5.6")
} # GitHub Packages requires a token with read:packages even for public artifacts. kotlinx-coroutines-core comes transitively (api); slf4j-api arrives at runtime — just pick a backend.
$ cd ~/modules && ls 6 #
▸ Flow Engine Saga orchestration#
Sequential step orchestration with automatic compensation (Saga pattern). Supports sequential, parallel and async steps with per-step timeouts.
// 1. Define your context
class OrderContext(
val orderId: String,
var paymentId: String? = null,
userId: String,
) : FlowContext(userId = userId)
// 2. Define your steps
class ValidateStockStep : Step<OrderContext> {
override suspend fun execute(context: OrderContext): OrderContext {
// validate stock…
return context
}
override suspend fun onFailure(context: OrderContext) {
// compensate: release reservation
}
}
// 3. Run with the DSL
val result = flow(OrderContext("order-1", userId = "user-1"), logger) {
step(ValidateStockStep())
step(ProcessPaymentStep())
parallel {
step(SendEmailStep())
step(UpdateAnalyticsStep())
}
asyncStep(AuditLogStep()) // background — never fails the flow
} - Per-step configurable timeouts (default 30s)
- Automatic compensation in reverse order (Saga) — runs under
NonCancellable, so it completes even if the caller cancels the coroutine ParallelStep— concurrent execution withasync/awaitAllAsyncStep— background side effects: failures are logged, never fail the flow; passasyncScopetoflow()for true fire-and-forget- Declarative DSL for flow composition
- Built-in logging via
StructuredLogger(traceId, error_code, error_details)
▸ Logger Structured JSON#
Flat JSON, one line per event — attributes land at the root, no nesting. Emitted through slf4j, so any backend works. Grafana / Loki / CloudWatch friendly. error() lines include error_type, error_message and the full stack_trace.
// Without tracing (Compose, CLI)
val logger = JsonStructuredLogger(serviceName = "pos-desktop")
// With OpenTelemetry (Ktor, Quarkus)
val logger = JsonStructuredLogger(
serviceName = "pos-server",
traceProvider = { ... }
)
// Usage
logger.info("process_payment", "Payment processed", mapOf("amount" to 50000)) Output:
{"time":"2026-03-22T10:15:30.123456Z","level":"INFO","service":"pos-server","step":"process_payment","message":"Payment processed","trace_id":"abc","span_id":"def","amount":50000} ▸ Exception Error hierarchy#
Centralised error format. The handler is written once per framework.
PlatformException (base) → ErrorResponse
└── HttpException (APIs) → HttpErrorResponse
├── ForbiddenException (403)
└── UnauthorizedException (401) // Throwing errors
throw PlatformException(code = "ORDER_NOT_FOUND", message = "Order 123 not found")
throw HttpException(code = "ORDER_NOT_FOUND", message = "Order 123 not found", httpStatus = 404)
throw ForbiddenException()
throw UnauthorizedException("Token expired")
// Wrapping another exception keeps the original chain
throw PlatformException(code = "DB_ERROR", message = "Insert failed", cause = e)
// Convert to a standardised response
val error = exception.toErrorResponse() // ErrorResponse
val error = httpException.toHttpErrorResponse() // HttpErrorResponse (includes httpStatus) Shape of HttpErrorResponse (note messages is a list):
{"code":"ORDER_NOT_FOUND","messages":["Order 123 not found"],"timestamp":"2026-03-22T10:15:30.123456Z","httpStatus":404,"details":{}} Handler in Ktor (once):
install(StatusPages) {
exception<HttpException> { call, e ->
call.respond(HttpStatusCode.fromValue(e.httpStatus), e.toHttpErrorResponse())
}
} ▸ Retry Exponential backoff#
Retry with exponential backoff for transient errors. By default retries connection errors, IOException and HTTP 408 / 429 / 500 / 502 / 503 / 504 — never 400, 401 or 403. Delay is capped at 10s. Coroutine cancellation is never retried.
// Defaults: 3 attempts, 100ms initial, factor 2x, max delay 10s
val invoice = retry { dianClient.sendInvoice(data) }
// Customised
val ticket = retry(maxAttempts = 5, initialDelay = 2.seconds, logger = logger) {
cloudBackend.renewLicense(deviceId)
}
// Override the predicate
retry(retryIf = { it is HttpException && it.httpStatus == 429 }) {
externalApi.call()
} ▸ Validation Accumulating DSL#
Declarative checks that accumulate instead of failing fast — every failed check is collected and thrown as a single PlatformException (VALIDATION_FAILED), ready for the Exception module's handler.
validate(request) {
check(value.orderId.isNotBlank()) { "orderId must not be blank" }
check(value.amount > 0) { "amount must be positive" }
}
// Failed checks accumulate — one exception with every message:
// PlatformException(code = "VALIDATION_FAILED", messages = [...]) ▸ Context Traceable#
FlowContext — base context with traceability fields (traceId, userId, tenantId, thread-safe metadata). It travels through every step of the flow — parallel steps can mutate it safely — and the engine tags its own log lines with the traceId.
class OrderContext(
val orderId: String,
var paymentId: String? = null,
userId: String,
tenantId: String? = null,
) : FlowContext(
userId = userId,
tenantId = tenantId,
metadata = mapOf("order_id" to orderId) // copied into a thread-safe map
) $ cat ~/compatibility.md #
uni { flowEngine.run(ctx, steps) } via mutiny-kotlin — full working template: kotlin-quarkus-blueprint ↗ mono { flowEngine.run(ctx, steps) } via kotlinx-coroutines-reactor coroutineScope { flowEngine.run(ctx, steps) } runBlocking { flowEngine.run(ctx, steps) }