Bored with the constant use of Spring in commercial projects, I decided to investigate the use of an alternative tech stack.
One of the prevalent alternatives is Micronaut. We have already tried it out at Evojam and even successfully implemented it in production in a few microservices.
That made me think — what if we use something even smaller? As we are starting to use Kotlin instead of Java boldly, I couldn't help but consider using Ktor.
The minimal requirements that I set for this experiment are as follows:
easy to implement endpoints and test HTTP layer;
a way to inject dependencies into classes;
an alternative for Spring repositories with easy saving and fetching;
easy to implement JWT authorisation;
handling environment variables; and
a possibility to run a container.
Alternative stack
Ktor for handling HTTP
Koin for handling DI
KMongo for working with a database
Stack share comparison
As you can see, Ktor is way less popular than Spring. Micronaut, on the other hand, has similar popularity.
Booting up
In Spring, there is an easy way to start a new project: https://start.spring.io.
Instruction on how to start with Ktor quickly: https://ktor.io/docs/intellij-idea.html#install_plugin
2) Create a new project in IntelliJ.
You can also manipulate an existing Gradle or Maven configuration:
https://ktor.io/docs/gradle.html#create-new-gradle-project
HTTP
“Routing in Ktor is organized in a tree with a recursive matching system. The Tree is built with nodes that contain selector and optional handler. Selectors are used to pick a route based on request.”
fun Route.customerRoutes() { route("/customer") { get { call.respond(HttpStatusCode.OK, customerService.all()) } get("628f3aafd267862301eccac1") { ..
Similar syntax is also possible in Spring 5 if you want to write in a more functional or reactive way.
@Configuration class CustomerController(private val customerService: CustomerService) { @Bean fun getCustomers(): RouterFunction<ServerResponse> = route( GET("/employees/628f3aafd267862301eccac1") ) }
But it’s quickly starting to be ugly when you want to add more routing.
@Bean fun customerRoutes(): RouterFunction<ServerResponse> = route( GET("/customer") ) .and( route( GET("/customer/628f3aafd267862301eccac1"), { req -> ok().body("..") } ) ).and( .. )
Dependency injection
Dependency injection is handled differently in functional languages.
To avoid feeling shocked due to Spring habits, we will use the Koin library to build something "Spring-ish."
Creating singletons:
fun customerModule(dbPort: Int) = module { single { CustomerRepositoryImpl(dbPort) as CustomerRepository } single }
Injecting one singleton into another:
class CustomerService(private val repository: CustomerRepository)
Using it in routing:
fun Route.customerRoutes() { /** We can inject beans into Route thanks to Koin extension methods */ val customerService: CustomerService by inject()
E2E Testing
Ktor provides the withApplication method for testing purposes where you can use TestApplicationEngine to "hook directly into internal mechanisms and processes an application call."
fun get(uri: String): (t: TestApplicationEngine) -> String = { t: TestApplicationEngine -> t.handleRequest(HttpMethod.Get, uri).response.content }
I pulled out HTTP calls and JSON converting methods into another object to have the E2E test quite readable.
// given val newCustomer = Customer("1", "l", "m", "lm@com") // when post("/customer", encode(newCustomer)).invoke(this) // and val getCustomers = get("/customer").invoke(this) val customers = decode<List<Customer>>(getCustomers) // then assertEquals(customers.size, 1)
Security
Working with Ktor is based on extension functions, which may initially shock someone unfamiliar with Kotlin, but the concept is straightforward and explained here:
https://kotlinlang.org/docs/extensions.html
Add routing to application:
fun Application.customerRouting() { routing { customerRoutes() } }
So you can later invoke the function while initializing the application:
fun Application.module() { .. customerRouting() }
Similar to the above, we can add a security configuration:
fun Application.module() { .. configureAuth(secret) customerRouting() }
That is presented, for example, like that:
fun Application.configureAuth(secret: String) { install(Feature) { jwt("auth-jwt") { verifier(JWT.require(Algorithm.HMAC256(secret)).build()) validate { credential -> if (credential.payload.getClaim("username").asString() != "") else null } } } }
By the auth-jwt name, you can then use auth configurations in routing:
route("/customer") { authenticate("auth-jwt") { get { call.respond(HttpStatusCode.OK, customerService.all()) } .. }
Properties
If you are using the HOCON file (more about that here: https://ktor.io/docs/configurations.html#hocon-file), you can easily add new environment variables:
jwt { secret = "secret" }
This JWT secret is fetched from properties in Application.module() like this:
val secret = environment.config.property("jwt.secret").getString()
Database
As mentioned before, I used KMongo to work with persistence.
It may be a bit of discomfort for those working only with Spring Data Repositories But if you’ve used MongoTemplate or MongoOperations, you’re good as it looks quite similar.
class CustomerRepositoryImpl(private val uri: String, private val database: String) : CustomerRepository { private val client = KMongo.createClient(uri) private val database = client.getDatabase(database) private val col = database.getCollection<Customer>() override fun findAll(): List<Customer> = col.find().toList() override fun save(customer: Customer) = col.insertOne(customer).wasAcknowledged() override fun findById(id: String): Customer? = col.findOne(Customer::id eq id) override fun deleteById(id: String) = col.deleteOneById(id).wasAcknowledged() }
Of course, this is just an example, and I propose to pull the configuration into a separate module.
Testing persistence layer
I like working with flapdoodle. It works fast and simulates a real base quite well.
Unfortunately, unlike Spring, you have to configure it ourselves.
Fortunately, it is not very complicated, and you can do it, for example, in this way:
fun setup(application: Application) { val port = application.environment.config.property("database.port").getString().toInt() val instance = MongodStarter.getDefaultInstance() val config = MongodConfig.builder() .version(Version.Main.PRODUCTION) .net(Net(port, Network.localhostIsIPv6())).build() instance.prepare(config).start() }
Containerizing
Nothing crazy here. Here’s the way I containerised the application:
1) I added a plugin application in gradle.build.kts:
plugins { application .. application
2) Then, I used the command for package build:
./gradlew installDist
3) In docker, I added instructions to pull the package and boot up the application:
COPY ../build/install/ktor-sample/ /app/ .. CMD ["./ktor-sample"]
There is a few other ways of doing it, and they’re described right here: https://ktor.io/docs/docker.html
Pros & Cons
In conclusion, I believe that the application has been created quite “Spring-ishly.”
Thanks to this, developers accustomed to Spring won’t die of shock! :)
Pros
The app starts in 0.4s compared to ~ 4s in Spring.
The syntax is quite similar to functional writing in Spring but more readable.
You can compose an application from libraries, e.g. using Koin for DI and KMongo. If there is a better alternative, it should not be impossible to replace it.
Pleasant documentation that allowed me to configure and familiarise myself with the topics mentioned above in about eight hours.
Cons
Embedded MongoDB firing is not automatic on context triggering like in Spring — you need to configure it yourself.
Ktor 2.0 will probably come out soon*, which may introduce breaking changes — until then, I would rather refrain from implementing it in production.
Things that I didn’t check out, but you should:
Websockets
You can find the codebase right here: https://github.com/milunas/kotlin-without-spring.
I encourage you to download the API from GitHub and play with it!
* Actually, it’s already here! :)
https://blog.jetbrains.com/ktor/2022/04/11/ktor-2-0-released/