Kotlin
A concise multiplatform language developed by JetBrains
Modular Ktor: Building Backends for Scale
Ktor offers a lightweight, flexible approach to building web applications that differs from more opinionated all-in-one frameworks. While Ktor’s minimalist design might seem challenging at first, a little experience using our modules can go a long way towards building for scale.
In this article, I’ll show you some techniques for introducing modularity to your Ktor projects.
Why modularity matters
One of the reasons Ktor remains a popular choice for server-side development is the directness and transparency of its implementations. Without the need for meta-programming or a host of configuration files, you can write simple services constrained to just a few Kotlin source files and it’s always clear that you’re working with an HTTP server.
This approach is great for simple applications, but when growing projects, it is important to anticipate future needs and mitigate their impact. The best way to accomplish this is break up your application into small, isolated parts, so that you can replace relevant pieces of functionality without modifying the whole thing. In other words, in order to gracefully scale the complexity of our project, we introduce modularity.
Most application frameworks include some first-class support for baking in modularity — usually by having some central registry for including bits of functionality. The Ktor server framework is no exception to this, and in the next section I’ll introduce the concept of application modules in Ktor.
Modules in Ktor
To start, let’s cover the basics.
1. What is a Ktor module?
To put it simply, it’s one of these:
fun Application.moduleName() { // Add a little functionality }
And with Ktor 3.2, you’ll also be able to use suspend functions as modules:
suspend fun Application.moduleName() { // Add some functionality with coroutines! }
Either way, a module is just an extension function for an Application
that runs as the server starts.
Now, you might be wondering…
2. What is an Application?
This is the container that holds all the hooks for business logic that execute when a request is handled. For example, we can inject some logging at the start of our pipeline, or we can insert some logic for serializing objects in the response.
When the server is restarted, the Application
is created anew and all the Ktor modules are reapplied. This also happens after changes are made while running in development mode, with the environment and engine configuration remaining untouched.
3. What can I do with Ktor modules?
You can reference modules directly from the context of the Application
from an EmbeddedServer
instance:
fun main() { embeddedServer(CIO, port = 8080, host = "0.0.0.0", module = Application::module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respond("Hello, World!") } } }
Or you can reference modules from your configuration file:
# application.yaml ktor: application: modules: - com.example.ApplicationKt.module
Referencing modules from our configuration allows us to change the behavior of our server during runtime. For example, we can include some stronger security features only in production, or include some debugging hooks for our test environments. To properly leverage this functionality, it’s best to split your modules up into smaller units.
Granular modules provide greater flexibility, but can present a challenge when many of them rely on some specific piece of code. These common requirements can be duplicated across modules, or we can introduce dependencies between our modules by sharing this data through the Application
instance.
Modules will often be related via dependencies, but there’s no obvious way to apply these connections. In the next section, I’ll describe how to hook modules together.
Connecting modules
Reviewing the signature of the module function, it is difficult to see how modules can relate to one another:
fun Application.module(): Unit
There aren’t any arguments or return types, so how can the platform possibly piece them together?
1. Use the stack
The simplest and most natural way to relate modules is by treating them exactly as you would any other function call:
fun main() { embeddedServer(CIO, port = 8080, host = "0.0.0.0") { // Instantiate your dependency val myService = MyService(property<MyServiceConfig>()) // Inject it into your modules as a parameter routingModule(myService) schedulingModule(myService) }.start(wait = true) }
This approach can work well for applications of any size, with the sole caveat that our modules are now strongly coupled at compile time, so they’re no longer interchangeable at runtime.
When working in a single repository, you’re unlikely to need interchangeable modules, but when scaling out to multiple repositories and teams, using them can be an asset for reducing coordination overhead. To get around this rigidity, we can consider removing inter-module references by passing dependencies through a common container.
2. Application Attributes
Ktor includes a built-in container for passing references between modules and plugins. You can find it in the Application.attributes
property, which is really just a Map<String, Any>
that retains some extra type information.
Here is an example to show how the attributes map can be used:
val connectionFactoryKey = AttributeKey<ConnectionFactory>("ConnectionFactory") // Declaring fun Application.database() { attributes[connectionFactoryKey] = PostgresConnectionFactory() } // Resolving fun Application.service() { val connectionFactory = attributes[connectionFactoryKey] // Use the connection factory in our service }
With this method of connecting different modules, we create loose coupling by avoiding direct references between our modules. We can achieve this using parameterized attribute keys for sharing implementations through a common map. This qualifies our attributes property as a basic inversion of control (IoC) container.
This technique can be fairly versatile, but it has a few disadvantages:
- It requires a shared “key” variable that lives outside the scope of our server.
- Modules are now temporally coupled, i.e., they must be referenced in a specific order.
- There is no tracking of what goes into or comes out of the map.
- Everything is manual, i.e., there is no support for reflection.
- There is no way to manage the lifecycle of these services.
In summary, you can do quite a bit with a simple map of instances, but it falls short when working with larger projects.
3. External libraries
To address some of these pain points, we can look at some popular Kotlin IoC libraries. In this article, I won’t go into the details of which library solves which problems, but there are plenty of options when working in Kotlin.
Here are some examples of IoC libraries that allow sharing between modules in Ktor using a declarative approach:
Kodein
An open-source library developed by KodeinKoders to provide a simple declarative means for sharing instances.
// Declaring fun Application.database() { di { bind<ConnectionFactory> { singleton { PostgresConnectionFactory() } } } } // Resolving fun Application.service() { val connectionFactory by closestDI().instance<ConnectionFactory>() // Use the connection factory in our service }
Koin
A popular and pragmatic open-source framework by Kotzilla that uses a fairly similar approach.
// Declaring fun Application.database() { koinModule { singleOf<ConnectionFactory> { PostgresConnectionFactory() } } } // Resolving fun Application.service() { val connectionFactory by inject<ConnectionFactory>() // Use the connection factory in our service }
Apart from these declarative, multi-platform libraries, there are also several libraries made for injecting classes using annotations and compiler plugins:
At this point, you might feel a little overwhelmed with options. After all, when using all-in-one frameworks, everything you need is built in, and you don’t have to worry about choosing some external means for sharing code. This is why in our latest release, we’ve worked to resolve this mental overhead by introducing a new DI plugin for Ktor server applications.
Ktor’s dependency injection
The goal of the new plugin is to simplify the management of inter-module dependencies. To facilitate this, we focused on improving the developer experience and introducing deep platform integration.
With the new plugin, there are a few options for sharing instances between your modules:
- The plugin DSL
- File configuration
- Module parameters
Here are some examples of how each of these techniques work.
Plugin DSL
If you’re accustomed to the libraries described above, the DSL will probably be most familiar to you.
Here’s the same example from above using the Ktor DI plugin:
// Declaring fun Application.database() { dependencies { provide<ConnectionFactory> { PostgresConnectionFactory() } } } // Resolving suspend fun Application.service() { val connectionFactory: ConnectionFactory = dependencies.resolve() }
We also allow for declaring types from class references. This way, types are created automatically, and their constructor arguments will be populated from other declared types. Any of the class’s covariant types can also be resolved from the registry this way, so you don’t have to worry about the abstraction on the declaration side.
Here’s how to declare your instances from class references:
fun Application.database() { dependencies { provide(PostgresConnectionFactory::class) } }
Now, we’ll show how to use these references for changing functionality at runtime!
File configuration
As shown earlier in the article, you can modify the behavior of your server by swapping modules in your file configuration. The DI plugin accommodates a similar approach, but for targeting specific types.
Considering our last connection factory example using class references, we can now do the same from our configuration file:
# application.yaml ktor: application: dependencies: - com.example.PostgresConnectionFactory
You can also reference functions that return the types you’re interested in:
ktor: application: dependencies: - com.example.Postgres#createConnectionFactory db: url: postgres://localhost:3456
Where the actual function looks something like:
fun createConnectionFactory(@Property("db.url") url: String): ConnectionFactory { return PostgresConnectionFactory(url) }
As you can see in the example function, properties from your configuration can now be imported directly as parameters. You can import basic types like String and Int, or you can import more complex data types marked with the @Serializable
annotation.
Module parameters
Not only can you inject the parameters of instance-providing functions, but you can also inject the module functions themselves.
For any module, you can import parameters from:
- Your file configuration, using the
@Property
annotation. - Special types, like
Application
,ApplicationEnvironment
, orApplicationConfig
- Any declared dependency.
Here’s how our connection factory example looks:
fun Application.service(connectionFactory: ConnectionFactory) { // Use the connection factory in our service }
The platform will handle the injection of connectionFactory
automatically, and it doesn’t matter what order you reference your modules in. The same injection works with dependencies referenced from your file configuration, so you can chain them together to construct more complex dependency trees.
It’s important to note that, since this injection relies on type reflection, it will only be available for the JVM.
Testing with DI
One major advantage of decoupling your modules is that it simplifies testing.
Using the DI plugin in Ktor 3.2, you can mock out components with ease:
fun `can fetch messages`() = testApplication { // replace the production implementation with a mock dependencies { provide { MockConnectionFactory() } } // load modules from your default application.conf configure() val messageList: List<Message> = client.get("/messages").body() assertEquals(listOf(expectedMessage), messageList) }
This works by providing an alternative conflict policy in the test application context, so initial dependencies are retained. All you need to remember is that your mocks should go in before your production modules.
Scaling with DI
Now that we have a reliable way to share code between modules, we can easily start splitting our projects into small Gradle modules. Instead of keeping all of our server logic in a single project, we can isolate different integration points, providing the opportunity to use domain-centric architectures, like the hexagonal or clean.
If we continue with our previous example, we can consider excluding the postgres
connection factory from our compilation classpath entirely.
Let’s say we have a project structure like this:
db ├─ core ├─ postgres └─ mongo server ├─ core ├─ admin └─ banking
Now, we can share some common server modules under server/core
and focus more strictly on domain logic in our smaller services.
Under server/banking
, for example, our build.gradle.kts
file would look like this:
plugins { id("io.ktor.plugin") version "3.2.0" } dependencies { implementation(":server:core") implementation(":db:core") runtimeOnly(":db:postgres") runtimeOnly(":db:mongo") }
Then we can defer choosing our storage implementation right up until our application is deployed and reads the configuration file.
For this, we simply edit application.yaml
like so:
ktor: application: dependencies: # Taken from modules db/* - com.example.MockConnectionFactory # - com.example.MongoConnectionFactory # - com.example.PostgresConnectionFactory modules: # Taken from server/core - com.example.Authentication.configureAuth # My domain service - com.example.Banking.configureBankRoutes
Since the implementations are hidden from our compile classpath, we can avoid polluting our services with implementation details while introducing several options for how our application operates.
Wrapping up
In this article, we’ve covered the basics of working with modules in Ktor and how to leverage dependency injection tools for sharing code between them.
To summarize:
- Ktor modules are replaceable extension functions for the
Application
class. - Modules can be used to manage complexity in a growing application.
- Code can be shared between modules indirectly via attributes or a DI library.
- Ktor now includes a robust DI plugin out of the box.
Here is an example of a project that uses the new tooling:
https://github.com/ktorio/ktor-chat/
If you have any thoughts on any of the topics in this article, you can leave a comment here or find us in the official Kotlin Slack channel.