Before module() Becomes a Monster


Ktor is unopinionated about project structure, which is freeing right up until your module() function is two hundred lines of installs and routes. Since the framework won’t impose an architecture, you impose a light one yourself. These are the conventions that keep a Ktor app readable as it grows.

The core move: configuration as extension functions

module() is just a function, and install(...) works on any Application. So split each concern into its own extension function on Application, each in its own file:

// plugins/Serialization.kt
fun Application.configureSerialization() {
    install(ContentNegotiation) { json() }
}
// plugins/Routing.kt
fun Application.configureRouting(repo: TaskRepository) {
    routing {
        taskRoutes(repo)
    }
}

Now module() reads like a table of contents — you can see the whole shape of the app at a glance:

fun Application.module() {
    val repo = TaskRepository()

    configureSerialization()
    configureRouting(repo)
    // configureStatusPages()
    // configureSecurity()
    // configureMonitoring()
}

Each configureX() owns one concern and lives in one file. Adding a feature means writing one extension function and adding one line here. This is the single most important structural habit in a Ktor codebase, and we’ll add to that commented-out list across the rest of the series.

A package layout that scales

A layout that’s worked well for services of real size:

src/main/kotlin/com/example/
  Application.kt          ← main() and module()
  plugins/               ← configureSerialization(), configureRouting(), ...
  models/                ← Task, NewTask, ErrorResponse
  routes/                ← taskRoutes(), userRoutes(), ...
  repository/            ← TaskRepository and friends

Nothing here is enforced by Ktor — it’s a convention you adopt. The point is that someone new to the code can guess where a thing lives: plugins configure the server, routes map HTTP to logic, repositories own storage, models are the data. Group by what a thing does, not by what HTTP verb touches it.

Reading configuration

Hard-coded values — ports, database URLs, a JWT secret, feature toggles — belong in application.yaml, not scattered through code. Add your own keys alongside Ktor’s:

ktor:
  deployment:
    port: 8080

app:
  taskLimit: 100
  greeting: "Welcome to the Task API"

Read them from environment.config:

fun Application.module() {
    val config = environment.config
    val greeting = config.property("app.greeting").getString()
    val taskLimit = config.property("app.taskLimit").getString().toInt()

    // use greeting, taskLimit ...
}

property(path) walks the dotted path and throws if the key is missing — which is what you want for required config, since the app fails loudly at startup instead of mysteriously at request time. For optional values, propertyOrNull(path) returns null instead:

val analyticsKey = config.propertyOrNull("app.analyticsKey")?.getString()

Why config-in-a-file matters

The same JAR runs in dev, staging, and production — only the config differs. Keeping values in application.yaml (and overriding them with environment variables, which we’ll do at deploy time) means you never recompile to change a port or point at a different database. We’ll lean on this hard in the deployment post.

Multiple modules

application.yaml can list more than one module function, and Ktor runs each against the same application:

ktor:
  application:
    modules:
      - com.example.ApplicationKt.module
      - com.example.MonitoringKt.monitoringModule

Useful for genuinely independent concerns — say, keeping monitoring setup in its own file and module. Most apps need just one; reach for several only when a slice of setup is truly standalone.

Final thoughts

The whole game here is fighting entropy. Ktor hands you a blank module() and total freedom; the discipline that keeps a project healthy is small and consistent — one extension function per concern, one file each, config in application.yaml, module() as a table of contents. Adopt it on day one and the codebase stays legible at fifty routes the same way it did at five.

Next: error handling — replacing all that repetitive ?: return@get call.respond(NotFound) with one place that turns exceptions into clean JSON responses.

Comments