Service Lifecycle

How Kontainer's lifecycle model enables per-request scoping, stateful dynamic services, and automatic cleanup — without manual wiring.

Per-request containers

The real power of Kontainer's lifecycle model shows up on the server side. The pattern is simple and inspired by PHP's request model:

  1. Define a blueprint once at application startup
  2. For every incoming request (or CLI command, scheduled job, etc.), create a fresh container from the blueprint
  3. Dynamic services carry per-request state — the current user, collected metrics, audit context
  4. When the request is done, the container and all its dynamic services are garbage collected — clean slate, no leaks

Singletons survive across requests (database pools, caches, configuration). Dynamic services are scoped to exactly one unit of work. This gives you the simplicity of "everything is fresh per request" without the cost of recreating stateless services.

// Define once at app startup
val blueprint = kontainer {
    // Shared across all requests
    singleton(DatabasePool::class)
    singleton(UserRepository::class)
    singleton(AuditLogRepository::class)

    // Fresh per request — carry request-scoped state
    dynamic(RequestContext::class)
    dynamic(RequestInsights::class)
    dynamic(AuditLog::class)
}

// On each incoming request:
val kontainer = blueprint.create {
    with(RequestContext::class) {
        RequestContext(userId = authenticatedUser.id, traceId = request.traceId)
    }
}

Example: Injecting the current user everywhere

Since RequestContext is a dynamic service, any service can inject it and access the current user — without passing it through every method call:

class RequestContext(val userId: String, val traceId: String)

class AuditLog(private val ctx: RequestContext) {
    private val entries = mutableListOf<String>()

    fun record(action: String) {
        entries.add("[${ctx.traceId}] User ${ctx.userId}: $action")
    }

    fun getEntries() = entries.toList()
}

class OrderService(
    private val ctx: RequestContext,
    private val auditLog: AuditLog,
    private val repo: OrderRepository,
) {
    fun placeOrder(item: String) {
        repo.save(Order(item, ctx.userId))
        auditLog.record("placed order for $item")
    }
}

The AuditLog and OrderService both get the same RequestContext instance — scoped to this request. Next request, fresh instances.

Example: Per-request metrics collection

Collect metrics throughout request processing, then flush them at the end. This pattern is used extensively in Funktor Insights:

class RequestInsights {
    private val dbQueries = mutableListOf<QueryRecord>()
    private val logs = mutableListOf<LogEntry>()
    private val timings = mutableMapOf<String, Long>()

    fun recordDbQuery(query: String, durationMs: Long) {
        dbQueries.add(QueryRecord(query, durationMs))
    }

    fun recordLog(level: String, message: String) {
        logs.add(LogEntry(level, message))
    }

    fun recordTiming(label: String, durationMs: Long) {
        timings[label] = durationMs
    }

    fun summary() = InsightsSummary(
        totalDbQueries = dbQueries.size,
        totalDbTimeMs = dbQueries.sumOf { it.durationMs },
        logs = logs.toList(),
        timings = timings.toMap(),
    )
}

// Any service in the request can record metrics
class UserRepository(private val insights: RequestInsights) {
    fun findById(id: String): User {
        val start = System.currentTimeMillis()
        val result = db.query("SELECT * FROM users WHERE id = ?", id)
        insights.recordDbQuery("findById", System.currentTimeMillis() - start)
        return result
    }
}

// At the end of the request, flush the collected insights
val summary = kontainer.get(RequestInsights::class).summary()
metricsService.report(summary)

Because RequestInsights is dynamic, every service in the request writes to the same instance. When the request ends and the container is garbage collected, all that state goes with it — no manual cleanup needed.

Lifecycles side by side

The best way to understand the three lifecycles is to see them running together. Here we register one counter as each type and create three container instances, calling each counter three times per container:

abstract class Counter {
    private var count = 0
    fun next() = ++count
}

class SingletonCounter : Counter()
class DynamicCounter : Counter()
class PrototypeCounter : Counter()

val blueprint = kontainer {
    singleton(SingletonCounter::class)
    dynamic(DynamicCounter::class)
    prototype(PrototypeCounter::class)
}

for (round in 1..3) {
    println("Round #$round")
    val kontainer = blueprint.create()

    repeat(3) {
        val s = kontainer.get(SingletonCounter::class).next()
        val d = kontainer.get(DynamicCounter::class).next()
        val p = kontainer.get(PrototypeCounter::class).next()
        println("  singleton: $s  dynamic: $d  prototype: $p")
    }
}

Output:

Container Call singleton dynamic prototype
Round #11st111
2nd221
3rd331
Round #21st411
2nd521
3rd631
Round #31st711
2nd821
3rd931

Notice the pattern:

  • Singleton keeps counting globally (1 → 9) — same instance across all three containers
  • Dynamic resets to 1 each round — fresh instance per container, but stable within it
  • Prototype is always 1 — brand new instance on every .get() call

This is why dynamic services are the key to per-request state: they behave like singletons within a container (so all services in the same request share the same instance), but each new container gets a clean slate.

Automatic lifecycle promotion (semi-dynamic)

There's a subtle but important problem with mixing singletons and dynamic services. Consider this:

class RequestContext(val userId: String)   // dynamic — fresh per request

class OrderService(val ctx: RequestContext)  // singleton — shared globally?

If OrderService were a true singleton, it would be created once and hold a reference to the RequestContext from the first request forever. Every subsequent request would see the wrong user. That's a bug.

Kontainer prevents this automatically. When a service is defined as singleton but injects a dynamic service (directly or transitively), Kontainer promotes it to semi-dynamic — it behaves like a dynamic service, getting a fresh instance per container.

val blueprint = kontainer {
    dynamic(RequestContext::class)

    // Defined as singleton, but Kontainer detects that it injects
    // a dynamic service and promotes it to semi-dynamic automatically.
    singleton(OrderService::class)
    singleton(AuditLog::class)
}

You don't need to do anything — Kontainer analyzes the dependency graph and handles it. The rule is simple:

If you depend on something that must be cleaned up per unit of work, you must be cleaned up too.

This one was born from a bug. A singleton held onto a stale request context, and we spent an afternoon wondering why user A was seeing user B's data. Never again.

This promotion bubbles up the dependency tree. If service A injects service B, and B injects a dynamic service C, then both A and B become semi-dynamic — even though only C was explicitly marked as dynamic. The "contamination" flows from the leaves to the root, ensuring that no singleton accidentally holds onto stale per-request state.

class RequestContext(val userId: String)        // dynamic

class AuditLog(val ctx: RequestContext)           // singleton → semi-dynamic (injects dynamic)

class OrderService(val audit: AuditLog)           // singleton → semi-dynamic (injects semi-dynamic)

class OrderController(val orders: OrderService)   // singleton → semi-dynamic (injects semi-dynamic)

The only services that remain true singletons are those with no transitive dependency on any dynamic service — like a database pool or a configuration object. Everything else is automatically scoped correctly.

You can verify this at runtime using the debug tools — the provider type will show SemiDynamic for promoted services.

Comparison

Lifecycle Created Shared across containers Use case
singleton Once, globally Yes Stateless services, DB pools, configs
dynamic Once per container No Request context, user, audit logs, metrics
prototype Every injection / get() No Disposable, short-lived objects
semi-dynamic Once per container No Auto-promoted singletons that inject dynamic services