Skip to content

Module Wiring Entry Point

Each Arda backend module exposes exactly one wiring entry-point — a top-level function on Application that the runtime calls during start-up. The entry-point owns three responsibilities, in order: resolve module configuration, construct the module’s services, and register the module’s routes/observers on the running Ktor Application. It returns the module’s public service handle so peers can inject it.

package cards.arda.<component>.<area>.<module>
fun Application.<module>(
cfgProvider: ConfigurationProvider,
authentication: Authentication,
registry: ModuleRegistry,
): <Module>Services

The three parameters are the only sources of runtime context: configuration, authentication, and the inter-module registry. Anything else the module needs (clients, secrets, databases) is constructed inside this function from those three inputs.

Main.kt calls every module’s entry-point in one place inside the Component.build { ... } block:

fun applicationConfigurer(cfgProvider: ConfigurationProvider): Application.() -> Unit = {
val modules = Modules(cfgProvider)
val builder = Component.build(cfgProvider, this, modules.authentication, Realm.USER)
val registry = builder.registry
builder.apply {
val itemSvc = module { item(cfgProvider, modules.authentication, registry, /* ... */) }
val emailSvc = module { email(cfgProvider, modules.authentication, registry) }
val orderSvc = module { orders(cfgProvider, modules.authentication, registry, itemSvc, /* ... */) }
}.build()
}

Every module appears as a single-line module { <name>(...) } invocation. No business code lives in Main.kt.

Module-level resources whose construction touches I/O (file reads, secret-store lookups, HTTP-client TLS init) must be built lazily, so tests that inject mocks via <module>ServicesForTest(...) (see below) never trigger that I/O.

fun Application.email(
cfgProvider: ConfigurationProvider,
authentication: Authentication,
registry: ModuleRegistry,
): EmailServices {
val cfg = cfgProvider.<emailModuleCfg>()
val httpClient: HttpClient by lazy { buildPostmarkHttpClient(cfg) }
val accountToken: PostmarkAccountToken by lazy { readAccountTokenFromFile(cfg.tokenPath) }
val proxy = PostmarkApiProxy.Impl(httpClient, accountToken)
// ...wiring continues; proxy and downstream services are constructed eagerly,
// but the I/O-bearing resources stay deferred until first use.
return EmailServices(/* public handles */).also { registry.register(it) }
}

The proxy itself is constructed eagerly so any wiring errors surface at start-up; only the resource handles it closes over are lazy.

Wiring-boundary getOrThrow(). The single sanctioned use of getOrThrow() outside fillPayload is at the module entry-point on a startup-fatal Result<T>. For example, MaterialRegistryRefresher(path).load().getOrThrow() in Module.kt loads a required template registry at start-up; if the load fails there is no meaningful fallback and the pod must not start. This pattern is acceptable only when (1) there is no surrounding Result chain to propagate into (i.e., you are at the top of the call stack inside the entry-point function body), and (2) a failure is genuinely pod-fatal. Any other use of getOrThrow() in module code is a code-review block — use map, flatMap, or resultNotNull instead.

When tests need to construct a module without the file/secret/network I/O the production entry-point performs, expose a separate internal function. Do not add optional parameters to the canonical entry-point; that pollutes production callers and weakens type inference.

internal fun Application.emailServicesForTest(
cfgProvider: ConfigurationProvider,
authentication: Authentication,
registry: ModuleRegistry,
httpClient: HttpClient, // injected
accountToken: PostmarkAccountToken, // injected
): EmailServices {
// Same body as the production entry-point, but uses the injected resources
// directly instead of building them lazily from cfg.
val proxy = PostmarkApiProxy.Impl(httpClient, accountToken)
// ...same wiring...
return EmailServices(/* public handles */).also { registry.register(it) }
}

Conventions:

  • Name: <module>ServicesForTest. Do not reuse <module> (the production name).
  • Visibility: internal. Production callers in other modules cannot see it; tests in the same module can.
  • Signature mirror: same first three parameters as the canonical entry-point, plus the resources to inject. Tests already constructing cfgProvider/authentication can pass them through unchanged.

The entry-point returns a small data class (<Module>Services) that exposes only the services other modules may consume. This is the public surface of the module’s wiring; peer modules in Main.kt capture it in a local val and pass it to downstream modules’ entry-points.

data class EmailServices(
val configuration: EmailConfigurationService,
val sender: OutboundEmailService,
)

Three rules for this type:

  1. Interfaces, not implementations. Each field’s type is the service’s public interface; Impl classes do not leak.
  2. No internal collaborators. Components that the module’s own code wires up (caches, queues, internal observers) stay inside the wiring closure — they are not surfaced on the services handle.
  3. also { registry.register(it) } to publish. The ModuleRegistry is the in-process discovery mechanism; modules that resolve peers by interface (e.g. for cross-module observers) read from it.
  • A module that needs more than three runtime inputs is a smell: the extra dependencies belong upstream (in Modules(cfgProvider)) or downstream (in another module that already has them). Adding a fourth parameter to the canonical signature should be the last resort.
  • A module that cannot be constructed lazily — i.e. its construction requires the I/O to succeed — is fine; build the resources eagerly and accept that the test-only entry-point will have to construct them too. Don’t paper over a genuine eager dependency with a lazy wrapper that crashes on first access.
  • A module whose services handle leaks an implementation class signals incomplete encapsulation: extract an interface, then re-type the field.
  • cards.arda.operations.shopaccess.email.Module.kt — three-parameter entry-point, lazy httpClient and accountToken, emailServicesForTest(...) for unit tests.
  • cards.arda.operations.reference.item.item(...) — older module with extra collaborators (printService, baSvc); illustrates how peer-service injection is layered through Main.kt rather than added to the canonical signature.
  • cards.arda.operations.procurement.orders.orders(...) — consumes itemService and kanbanService, returned by their respective entry-points.
  • For the MultiEndpointKtorModule mounting pattern (DSL-based endpoints without raw Ktor routing), see EmailJobEndpoint.kt, PostmarkEventsEndpoint.kt, and Module.kt in the email module. See also Endpoint Definition DSL for the full DSL pattern.