Dienstag, 24. November 2020

Kotlin dependency injection and modularization without a framework

Recently I found myself in yet another discussion about dependency injection frameworks. The internet showed me that there is a weird tension and a lot of discussions about runtime vs compile time di, reflection usage, compile time safety, service locator vs di pattern and many more.

Here's what I think: The only acceptable ... actually good implementation of a di framework is Scala implicits. The reason why the JVM world is so obsessed with di frameworks is because Java is such a limited language, that implementing things with the language only is simply not feasible.

Pure di in Kotlin

It doesn't need many features, but those we need are key to make frameworkless di (I will call it pure di from now on) practical: Primary constructors, default arguments and smart constructors. Implicit parameter passing like in Scala would be an optional bonus on top - this feature is too controversial to just require it for pure di.

About the "testability" requirement

So first, the elephant in the room: You don't need interfaces to create testable implementations for something. Mocking frameworks like mockk can just mock any class you have and replace the implementations. Conclusion: Hiding things behind an interface is a good idea for a lot of reasons, but di doesn't care about them. You decide what you accept as a dependency in your class and that's it. No drawbacks for testability when you aren't able to use the default implementation for testing.

No annotations 

I know there's a standard on the JVM, but as I said in the introduction, we should question that. When a class is declared, just from a domain driven perspective, why on earth should we annotate our constructor with @Inject for example? It's a technical detail of a framework my caller may or not may not use. And even if he uses it, why is the declaration of the constructor not sufficient for anyone else to use it, be it automatically or not by hand? From my pov, using annotations on the dependency is a code smell that we got used to because of CDI. Even worse when configuration file keys are added into the annotation...

Module definitions

A module itself doesn't have to be interfaced. The components of a module can be. The module itself is just a plain old class that defines related components.


Note how Kotlin's primary constructor with default arguments completely replaced the need for any complex override framework sometimes needed for testing or bean definition overrides. Smart constructors (operator fun invoke on an interface companion here) don't exactly relate to dependency injection but can serve as a factory for default implementations.

Multiple module definitions

Using multiple modules with frameworks is often not too easy because of a single container or service locator, that flattens all definitions into a single pool which is used for service retrieval. Service locator will be talked about in the next paragraph, now lets take a brief look how simple multiple modules can and should look in your applications:


Note that it's not necessary to bundle all modules into a single super module - you can group whatever is meaningful for your domain, not what the framework requires you to do. When you really want to squash all definitions, all components of all of your modules into a flat facade, you can either use Kotlin's built-in delegation and interfaces, like so



Or you can use... Scala 3 that has a feature called exports - just kidding, we're doing Kotlin right - or something like what I implemented with this one https://github.com/hannespernpeintner/kotlin-companionvals .

Factories, lazy, optional

All those features di frameworks offer are already built-in in the Kotlin language. Singletons are given by just using val properties. Take a look at this example how factories, lazy things and optional things can be implemented

  

Those features automatically work with IDE features such as auto completion and refactoring, which is one of the most important things in projects and the reason Kotlin is so successful. Also you don't get runtime errors for example for optional dependencies, as Kotlin's built-in nullability gives you compile time errors. An additional bonus is that you can have nullable dependencies on the interface and override with non-nullable implementations in a module. Using those modules non virtual when it's okay to rely on the implementation (for example in testing) safes you from using the double bang operator all over the place.

Service locator

Finally, the probably most important aspect of di frameworks, the piece of code that is the surface your application and your components are allowed to rely on (are they? :) ). The implementation of the service locator is the source of problems in most frameworks, as it always generifies your module graph into something unnecessary generic that works more or less like a big map of types/names to instances/factories. This is also where compile time safety is lost.

Without any frameworks, you can just pass around the module (interface when given) instance you want to use somewhere. I found the best strategy to just use the smallest possible dependencies in your components, even though that may make your primary constructors big - it's just cleaner and more appropriate than passing context objects aka modules directly. For the caller's convenience - which is not an unimportant aspect! - you can provide a smart constructor that takes a complete module.

This is the point where manual declarations are more verbose than the magic wiring frameworks do for you. But hey, that's code. Plain old code. Everyone can go to declarations, refactor them, add more smart constructors, know how they work without having to know any framework. This approach has proven to be appropriate for even big module graphs in my applications.

Inner-module dependencies in components

What if a component that is part of a module needs a component from that very module? Most frameworks solve this problem by making everything lazy. In code, we would reorder statements - with constructors, we have to either pull out default arguments and wire and pass arguments explicitly or change the properties order like so

Not too worse, I think.

Bonus Round 1: Constructor vs field injection

You may have noticed that I did only write about constructor injection. The short reason is, that everything else should never be used as it introduces mutable and invalid state in your application. Whenever you have to deal with an environment that requires you to use such a lifecycle, Kotlin offers the lateinit keyword that can be used perfectly with pure di - but more important it depends on the foreign framework whether it's simple, robust and important to implement. When your environment requires you to use CDI, you should probably stick to it. Or not use those frameworks any more :)

Bonus Round 2: Quasi mixins

Kotlin doesn't allow for multiple inheritance of state, but interfaces and default implementations can become quite powerful and useful for a mix of data driven design and modularization. The idea is to place implementations into interfaces, writing dependencies as abstract state. Interfaces can leverage multiple inheritance and what's left is the implementation of state that can be done declaratively.

Let's consider you have a typical webapp Controller class that fetches some Products and needs some dependencies for that because it's not trivial.

Using interface inheritance could be seen as an abuse of the language feature here, but let's try to stay pragmatic. Using it automatically brings local extensions into scope, enabling implicit parameter passing of contexts, hence dependency injection. This approach gives you the freedom of just not caring about modules at all and just think about fine grained dependencies. Pretty much what di frameworks give you, but without any runtime errors because the source code is your module graph that is already validated on the fly by the compiler :) This approach can also be combined with pure di - you can define generic implementations in interfaces and deliver some default implementations as final classes, just as you wish, I can't see any borders here.

Keine Kommentare:

Kommentar veröffentlichen