Ktor 3.4.0 Help

Application structure

Ktor applications can be organized in several ways depending on project size, domain complexity, and deployment environment. While Ktor is intentionally unopinionated, there are common patterns and best practices that help keep your application modular, testable, and easy to extend.

This topic describes common structures used in Ktor projects and provides practical recommendations for choosing and applying one.

Default project structure

When you generate a Ktor project using the Ktor project generator, the resulting project uses a single-module structure. This layout is minimal and intended to get you up and running quickly with a working Ktor application.

project/ └─ src/ ├─ main/ │ ├─ kotlin/ │ │ └─ Application.kt // Application entry point │ └─ resources/ │ └─ application.conf // Application configuration └─ test/ └─ kotlin/ // Unit and integration tests ├─ build.gradle.kts // Gradle build file └─ settings.gradle.kts // Gradle settings file

Although suitable for small applications, this structure does not scale well as the project grows. For larger projects, it is recommended to organize functionality into logical packages and modules, as described in the following sections.

Choosing an application structure

Selecting the right structure depends on the characteristics of your service:

  • Small services often work well with only a few modules and simple dependency injection.

  • Medium-sized applications typically benefit from a consistent feature-based structure that groups related routes, services, and data models together.

  • Large or domain-heavy systems can adopt a domain-driven approach, which provides clearer boundaries and organizes business logic around domain concepts.

  • Microservice architectures normally use a hybrid style, where each service represents a domain slice and is internally modular.

It’s worth noting that these structures are not mutually exclusive. You can combine multiple approaches — for example, using a feature-based organization within a domain-driven architecture, or applying modularity in a microservice-oriented system.

Layered structure

A layered architecture separates your application into distinct responsibilities: configuration, plugins, routes, business logic, persistence, domain models, and data transfer objects (DTOs). This approach is common in enterprise applications and provides a clear starting point for maintainable code.

src/main/kotlin/com/example/app/ ├─ config/ // Application configuration and environment setup ├─ plugins/ // Ktor plugins (authentication, serialization, monitoring) ├─ controller/ // Routes or API endpoints ├─ service/ // Business logic ├─ repository/ // Data access or persistence ├─ domain/ // Domain models and aggregates └─ dto/ // Data transfer objects

Modular architecture

Ktor encourages modular design by allowing you to define multiple application modules. A module is a function extending Application that configures part of the application:

fun Application.customerModule() { //… }

Each module can install plugins, configure routes, register services, or integrate infrastructure components. Modules can depend on each other or remain fully independent, which makes this structure flexible for both monoliths and microservices.

Dependencies are typically injected at module boundaries:

fun Application.customerModule(customerService: CustomerService) { routing { customerRoutes(customerService) } }

A modular structure helps you:

  • Separate concerns and isolate feature logic

  • Enable configuration or plugin installation only where needed

  • Improve testability by instantiating modules in isolation

  • Support microservice-friendly or plugin-friendly code organization

  • Introduce dependency injection at module boundaries

A typical multi-module structure might look like this:

db/ ├─ core/ // Database abstractions (interfaces, factories) ├─ postgres/ // Postgres implementation (JDBC, exposed) └─ mongo/ // MongoDB implementation server/ ├─ core/ // Shared server utilities and common modules ├─ admin/ // Admin-facing domain and routes └─ banking/ // Banking domain and routes

Below is an example build.gradle.kts file for the server/banking module:

plugins { id("io.ktor.plugin") version "3.3.3" } dependencies { implementation(project(":server:core")) implementation(project(":db:core")) // Storage implementations are loaded at runtime runtimeOnly(project(":db:postgres")) runtimeOnly(project(":db:mongo")) }

In this structure, the banking module does not compile against any database implementation. It only depends on db/core, keeping the domain separate from infrastructure details.

Feature-based modules

Feature-based organization groups code by feature or vertical slice. Each feature becomes a self-contained module, containing its routes, services, data transfer objects (DTOs) and domain logic.

app/ ├─ customer/ │ ├─ CustomerRoutes.kt // Routing for customer endpoints │ ├─ CustomerService.kt // Business logic for customer feature │ └─ CustomerDto.kt // Data transfer objects for customer feature └─ order/ ├─ OrderRoutes.kt // Routing for order endpoints ├─ OrderService.kt // Business logic for order feature └─ OrderDto.kt // Data transfer objects for order feature

This structure scales well in medium-to-large monoliths or when splitting individual features into microservices later. Each feature can be migrated or versioned independently. A typical feature module may look like this:

fun Application.customerModule(service: CustomerService) { routing { route("/customer") { get("/{id}") { call.respond(service.get(call.parameters["id"]!!)) } post { val dto = call.receive<CustomerDto>() call.respond(service.create(dto)) } } } }

In the above example, the module has no knowledge of how CustomerService is created — it only receives it, which keeps dependencies explicit.

Domain-driven design (DDD) approach

A domain-driven structure organizes your application around the core business capabilities it represents. For large projects with complex business rules, it is helpful to separate domain logic from transport, persistence, and infrastructure concerns:

domain/ ├─ customer/ │ ├─ Customer.kt // Domain entity │ ├─ CustomerService.kt // Domain service │ ├─ CustomerRepository.kt // Domain repository interface ├─ order/ │ ├─ Order.kt │ ├─ OrderService.kt │ └─ OrderRepository.kt server/ // Ktor server application (depends on domain and infrastructure) ├─ Authentication.kt // Cross-cutting concerns as separate server modules ├─ Customers.kt // Customer HTTP routes └─ Orders.kt // Order HTTP routes

Domain layer

The domain layer remains independent of Ktor. It defines the business rules through the following elements:

  • Entities represent identifiable domain objects:

data class Customer( val id: CustomerId, val contacts: List<Contact> )
  • Value objects express immutable concepts such as identifiers or validated fields:

@JvmInline value class CustomerId(val value: Long)
  • Aggregates group related entities under a single consistency boundary:

class CustomerAggregate(private val customer: Customer) { fun addContact(contact: Contact): Customer = customer.copy(contacts = customer.contacts + contact) }
  • Repositories abstract persistence and expose operations for retrieving or saving aggregates. Their implementations live in the infrastructure layer, but the interfaces belong to the domain.

interface CustomerRepository { suspend fun find(id: CustomerId): Customer? suspend fun save(customer: Customer) }
  • Domain services coordinate business logic that spans multiple aggregates or does not naturally belong to a single entity.

class CustomerService( private val repository: CustomerRepository, private val events: EventPublisher ) { suspend fun addContact(id: CustomerId, contact: Contact): Customer? { val customer = repository.find(id) ?: return null val updated = CustomerAggregate(customer).addContact(contact) repository.save(updated) events.publish(CustomerContactAdded(id, contact)) return updated } }
  • Domain events represent meaningful business changes. They allow other parts of the system to react to these events without directly coupling to the service that produced them.

interface DomainEvent data class CustomerContactAdded( val id: CustomerId, val contact: Contact ) : DomainEvent

These elements together support a rich domain model while keeping infrastructure details separate.

Application and routing layer

You expose each domain through its own route file or module function, injecting services that manage both logic and state:

// server/CustomerRoutes.kt fun Application.customerRoutes(service: CustomerService) { route("/customers") { post("/{id}/contacts") { val id = call.parameters["id"]!!.toLong() val contact = call.receive<Contact>() val updated = service.addContact(CustomerId(id), contact) call.respond(updated ?: HttpStatusCode.NotFound) } get("/{id}") { val id = call.parameters["id"]!!.toLong() val customer = service.findById(CustomerId(id)) call.respond(customer ?: HttpStatusCode.NotFound) } } }
// Application.kt fun Application.module() { val customerRepository: CustomerRepository = ExposedCustomerRepository() val eventPublisher: EventPublisher = EventPublisherImpl() val customerService = CustomerService(customerRepository, eventPublisher) routing { customerRoutes(customerService) } }

Microservice-oriented structure

Ktor applications can be organized as microservices, where each service is a self-contained module that can be deployed independently.

Microservice repositories often use a hybrid of modular architecture, DDD for domain isolation and Gradle multi-module builds for infrastructure isolation.

service-customer/ ├─ domain/ // Domain models and aggregates ├─ repository/ // Persistence layer for customer service ├─ service/ // Business logic ├─ dto/ // Data transfer objects ├─ controller/ // Routes or API endpoints ├─ plugins/ // Ktor plugin installation for this service └─ Application.kt // Entry point for the service service-order/ ├─ domain/ // Domain models and aggregates ├─ repository/ // Persistence layer for order service ├─ service/ // Business logic ├─ dto/ // Data transfer objects ├─ controller/ // Routes or API endpoints ├─ plugins/ // Ktor plugin installation for this service └─ Application.kt // Entry point for the service

In this structure, each service owns an isolated domain slice and remains modular internally, integrating with service discovery, metrics, and external configuration.

Entry points

Ktor provides ready-made engine entry points, such as:

io.ktor.server.cio.EngineMain

When using a canned engine main function, you do not need to define a custom main() method or a dedicated Application.kt entry-point file.

Application modules can be defined in any source file and are loaded by the engine based on configuration.

Modulith deployment

Instead of fully independent microservices, multiple Gradle modules representing services can be packaged independently but deployed together in a single Ktor application. This approach is commonly referred to as a modulith.

Each Gradle module remains internally isolated and exposes an application module that can be loaded through configuration:

# application.yaml ktor: deployment: port: 8080 application: modules: - com.example.customer.customerModule - com.example.order.orderModule
23 January 2026