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.
Canonical signature
Section titled “Canonical signature”package cards.arda.<component>.<area>.<module>
fun Application.<module>( cfgProvider: ConfigurationProvider, authentication: Authentication, registry: ModuleRegistry,): <Module>ServicesThe 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.
Lazy resource construction
Section titled “Lazy resource construction”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.
Test-only entry-point
Section titled “Test-only entry-point”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/authenticationcan pass them through unchanged.
Returning a services handle
Section titled “Returning a services handle”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:
- Interfaces, not implementations. Each field’s type is the service’s public interface;
Implclasses do not leak. - 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.
also { registry.register(it) }to publish. TheModuleRegistryis the in-process discovery mechanism; modules that resolve peers by interface (e.g. for cross-module observers) read from it.
When the rule pushes back on a design
Section titled “When the rule pushes back on a design”- 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
lazywrapper 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.
Worked references
Section titled “Worked references”cards.arda.operations.shopaccess.email.Module.kt— three-parameter entry-point, lazyhttpClientandaccountToken,emailServicesForTest(...)for unit tests.cards.arda.operations.reference.item.item(...)— older module with extra collaborators (printService,baSvc); illustrates how peer-service injection is layered throughMain.ktrather than added to the canonical signature.cards.arda.operations.procurement.orders.orders(...)— consumesitemServiceandkanbanService, returned by their respective entry-points.- For the
MultiEndpointKtorModulemounting pattern (DSL-based endpoints without raw Ktor routing), seeEmailJobEndpoint.kt,PostmarkEventsEndpoint.kt, andModule.ktin the email module. See also Endpoint Definition DSL for the full DSL pattern.
Related pages
Section titled “Related pages”- Module Concept — what a Module is in the functional decomposition.
- Component Concept — how the runtime Component wires Modules together.
- application.conf Structure — where
cfgProviderreads from. - DAG Package Discipline — package-level dependency rules within a module.
Copyright: © Arda Systems 2025-2026, All rights reserved