~/ / portfolio / kotlin-utils
# on this page

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

v0.2.0 · 2026-07-06 Kotlin 2.2 JVM 21 coroutines-native MIT

$ cat ~/principles.md #

01

Zero framework coupling

Only depends on kotlinx-coroutines and slf4j. Works with Ktor, Quarkus, Spring, Compose, or any Kotlin/JVM project.

02

Coroutines-native API

Every step is suspend fun. No reactive wrappers (Mutiny, Reactor, RxJava) — the suspend signature is the contract.

03

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 with async/awaitAll
  • AsyncStep — background side effects: failures are logged, never fail the flow; pass asyncScope to flow() 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 #

Ktor Direct — Ktor is coroutines-native
Quarkus uni { flowEngine.run(ctx, steps) } via mutiny-kotlin — full working template: kotlin-quarkus-blueprint ↗
Spring WebFlux mono { flowEngine.run(ctx, steps) } via kotlinx-coroutines-reactor
Compose coroutineScope { flowEngine.run(ctx, steps) }
CLI / Scripts runBlocking { flowEngine.run(ctx, steps) }