Module anatomy
A module is a trait that mixes in a few Provider interfaces, declares the resources it needs as abstract vals, and wires up its routers/services/templates with macwire. ApplicationLoader extends every concrete module trait, satisfies the abstract dependencies, and gathers the contributions through the providers’ super chain.
This doc is the reference for what’s available. For a hands-on tutorial walking through a new feature end to end, see adding-a-module.md.
The shape of a module
Section titled “The shape of a module”trait AuctionModule extends RouteProvider with AuthRouteProvider with WsRouteProvider with RecurringTaskProvider with OneTimeTaskProvider with MailPreviewProvider {
// 1. Dependencies the module needs from outside, declared as abstract vals. given telemetryContext: TelemetryContext val transactor: Transactor val objectStore: ObjectStore val schedulerClient: SchedulerClient // …
// 2. Internal wiring. private val auctionRepository = wire[AuctionRepository] private val auctionService = wire[AuctionService] private val auctionRouter = wire[AuctionRouter] // …
// 3. Provider contributions: how this module participates in each // cross-cutting list (routes, tasks, mail previews). override abstract def route(auth: AuthContext): Route = super.route(auth) ~ auctionRouter.authedRoutes(auth)
override abstract def recurringTasks: List[Task[?]] = super.recurringTasks :+ auctionService.closeExpiredAuctionsTask
// …}Three things to notice:
override abstract. Each provider method callssuper.<thing>and concatenates the module’s own contribution. The whole mixin chain inApplicationLoaderends in theApplication…Providerumbrella trait, which supplies the empty default. You can read every module’s body without reading the others.- Dependencies are abstract
vals, not constructor parameters. This is what makes the trait composable:ApplicationLoader’s constructor receives the runtimes once and thevals land in scope for every module that mixes in. - Internal wiring uses macwire for non-trivial constructors and plain
newfor the rest. Either is fine; macwire saves typing when there are five-plus constructor params, plainnewreads better when there are two.
Providers
Section titled “Providers”A provider is a tiny trait with one or two abstract methods. Modules mix them in to declare what they contribute.
| Provider | Contributes | Module overrides |
|---|---|---|
RouteProvider |
Public routes (no auth gate) | def route: Route |
AuthRouteProvider |
Routes that require an authenticated user (AuthContext) |
def route(auth: AuthContext): Route |
WsRouteProvider |
Public WebSocket routes | def wsRoutes(wsb: WebSocketBuilder2[IO]): Route |
AuthWsRouteProvider |
Authenticated WebSocket routes | def wsRoutes(auth: AuthContext, wsb: WebSocketBuilder2[IO]): Route |
RecurringTaskProvider |
Tasks the scheduler runs on a schedule (cron, fixed delay, …) | def recurringTasks: List[Task[?]] |
OneTimeTaskProvider |
Tasks scheduled in response to events (analyze, send mail, …) | def oneTimeTasks: List[OneTimeTask[?]] |
CustomTaskProvider |
Tasks that compute their next run dynamically | def customTasks: List[CustomTask[?]] |
MailPreviewProvider |
Renderable email previews for the dev-mode /admin/mail-previews UI |
def mailPreviews: List[MailPreview] |
LifecycleProvider |
Background processes / startup–shutdown effects that run for the app’s lifetime | def lifecycles: List[Resource[IO, Unit]] |
Most providers are grouped under an Application… umbrella. ApplicationRouteProvider extends the four route providers and supplies RouteDirectives.reject as the default. ApplicationTaskProvider extends the three task providers and supplies Nil. MailPreviewProvider and LifecycleProvider stand on their own and inline Nil as their default. ApplicationLoader mixes them all in, so the chain always terminates cleanly even if no module implements a given provider.
A LifecycleProvider contribution is a Resource[IO, Unit] — acquire at boot, release at shutdown. Main runs them (application.lifecycles.sequence_) inside its resource scope, after the runtimes are up and before the HTTP server binds. Use it for a module’s app-lifetime background work: the outbox recovery loop and the feature-flag cache-invalidation subscription are the two worked examples. Keep them independent of each other; if one needs another to be running first, compose that ordering inside a single Resource.
Acquisition starts a lifecycle; it does not wait for it to become ready. A .background contribution forks its fiber and returns immediately, so the server can bind before, say, an event-bus subscription has actually registered. Treat lifecycles as fail-open: the feature-flag invalidation tolerates a brief startup window where a peer update is missed and the cache falls back to its TTL. If a contribution genuinely must be ready before boot proceeds, encode that wait in the Resource’s acquire step rather than assuming acquisition implies readiness.
A module mixes in only what it contributes. The auction module mixes in six providers; HealthCheckModule mixes in only RouteProvider.
Dependencies
Section titled “Dependencies”The convention is to declare module-level dependencies as val (or lazy val, when initialization order matters) and given-providing context as given:
given telemetryContext: TelemetryContext // implicit context for logging/tracingval transactor: Transactor // database accessval objectStore: ObjectStore // file storageval schedulerClient: SchedulerClient // task schedulingval cacheRuntime: CacheRuntime // runtime — the module picks one cache from itlazy val mailer: Mailer // shared infrastructure constructed in ApplicationLoaderlazy val httpClient: … // dittolazy val userRepository: UserRepository // cross-module access (auction needs users)ApplicationLoader either receives these in its constructor (transactor, runtimes, scheduler client) or constructs them once as lazy val (mailer, http client, repositories shared across modules). Every concrete value lands in scope for every module trait that declares it abstractly — the compiler enforces that nothing is missing.
If you find yourself adding a dependency to a module that no other module uses, hold it inside the module rather than promoting it to ApplicationLoader. Promote when a second consumer appears.
Cross-module dependencies
Section titled “Cross-module dependencies”Modules can also expose values for other modules to reuse — typically repositories or services that crop up in more than one feature area. The pattern is symmetric to external dependencies:
- The owning module declares it as a concrete
lazy val.UserModuleexposeslazy val userRepository: UserRepository = wire[UserRepository]. - Consuming modules redeclare it as an abstract
lazy val.AuctionModuleandAuthModuleboth havelazy val userRepository: UserRepository. ApplicationLoadermixes both modules in. Scala’s trait linearization makes the concrete definition fromUserModulesatisfy the abstract declaration in the consumers. No constructor parameter, no manual passing, no macwire awareness.
The convention: the module that owns a domain concept exposes its repository (and any cross-cutting service) on the trait so other modules can declare it abstractly. Don’t reach into ApplicationLoader to thread it through.
A few things to keep in mind:
- Use
lazy valon both sides. Eagervals in traits can initialize in the wrong order. - Match the type exactly. If the owner exposes
lazy val userRepository: UserRepository, the consumer must declare the same type — not a supertype, not an alias. - Don’t expose internal services (e.g. an
AuctionServicethat another module calls into). That’s a sign two modules want to be one. Repositories cross module boundaries because they’re the data layer; services are the seam where features start to fuse.
It’s three small pieces:
-
Define the trait. A provider is a small
traitdeclaring what a module contributes — usually one method. If it belongs to a category that has several shapes (routes, tasks), put it next to the others (utils.http.ApplicationRouteProviderfor HTTP-shaped,utils.task.ApplicationTaskProviderfor task-shaped, …) so the umbrella file has them together. A standalone provider (likeMailPreviewProviderorLifecycleProvider) just lives on its own.trait FeatureFlagProvider {def featureFlags: List[FeatureFlag]} -
Give it a default so the
super.<thing>chain terminates. For a category with an umbrella, the umbrella trait extends every provider in the category and supplies the no-op default;ApplicationLoaderextends the umbrella.trait ApplicationFeatureProvider extends FeatureFlagProvider {override def featureFlags: List[FeatureFlag] = Nil}A standalone provider has no umbrella — it inlines the default on the trait itself, and
ApplicationLoadermixes the provider in directly.trait LifecycleProvider {def lifecycles: List[Resource[IO, Unit]] = Nil} -
Wire the contributions somewhere they get used. A provider only matters if something downstream consumes it. For routes that’s
routes(wsb)inApplicationLoader; for tasks it’sscheduler.run(...)inMain; for lifecycles it’sapplication.lifecycles.sequence_inMain; for mail previews it’s theMailPreviewRouterconstructor. Find or add the consumer that walks the list.
That’s the whole pattern. The reason there aren’t more providers in the template is that the existing nine (four route, three task, one mail preview, one lifecycle) cover almost every cross-cutting concern a typical web backend has.