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:
- Define a blueprint once at application startup
- For every incoming request (or CLI command, scheduled job, etc.), create a fresh container from the blueprint
- Dynamic services carry per-request state — the current user, collected metrics, audit context
- 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 #1 | 1st | 1 | 1 | 1 |
| 2nd | 2 | 2 | 1 | |
| 3rd | 3 | 3 | 1 | |
| Round #2 | 1st | 4 | 1 | 1 |
| 2nd | 5 | 2 | 1 | |
| 3rd | 6 | 3 | 1 | |
| Round #3 | 1st | 7 | 1 | 1 |
| 2nd | 8 | 2 | 1 | |
| 3rd | 9 | 3 | 1 |
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 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 |