Search code examples
dependency-injectionreader-monadarrow-kt

Arrow KT: Reader Monad vs @extension for Dependency Injection


I've read about Reader Monad from this article by Jorge Castillo himself and I've also got this article by Paco. It seems that both tackles the idea of Dependency Injection just in a different way. (Or am I wrong?)

I'm really confused whether I understand the whole Reader Monad and how it relates to the Simple Depenency Injection that Paco is talking about.

Can anyone help me understand these two things? Would I ever need both of them in one project depending on situations?


Solution

  • Your doubt is understandable, since yes both approaches share the same outcome: Passing dependencies implicitly for you all the way across your call stack, so you don't need to pass them explicitly at every level. With both approaches you will pass your dependencies once from the outer edge, and that's it.

    Let's say you have the functions a(), b(), c() and d(), and let's say each one calls the next one: a() -> b() -> c() -> d(). That is our program.

    If you didn't use any of the mentioned mechanisms, and you needed some dependencies in d(), you would end up forwarding your dependencies (let's call them ctx) all the way down on every single level:

    a(ctx) -> b(ctx) -> c(ctx) -> d(ctx)
    

    While after using any of the mentioned two approaches, it'd be like:

    a(ctx) -> b() -> c() -> d()
    

    But still, and this is the important thing to remember, you'd have your dependencies accessible in the scope of each one of those functions. This is possible because with the described approaches you enable an enclosing context that automatically forwards them on every level, and that each one of the functions runs within. So, being into that context, the function gets visibility of those dependencies.

    Reader: It's a data type. I encourage you to read and try to understand this glossary where data types are explained, since the difference between both approaches requires understanding what type classes and data types are, and how they play together:

    https://arrow-kt.io/docs/patterns/glossary/

    As a summary, data types represent a context for the program's data. In this case, Reader stands for a computation that requires some dependencies to run. I.e. a computation like (D) -> A. Thanks to it's flatMap / map / and other of its functions and how they are encoded, D will be passed implicitly on every level, and since you will define every one of your program functions as a Reader, you will always be operating within the Reader context hence get access to the required dependencies (ctx). I.e:

    a(): Reader<D, A>
    b(): Reader<D, A>
    c(): Reader<D, A>
    d(): Reader<D, A>
    

    So chaining them with the Reader available combinators like flatMap or map you'll get D being implicitly passed all the way down and enabled (accessible) for each of those levels.

    In the other hand, the approach described by Paco's post looks different, but ends up achieving the same. This approach is about leveraging Kotlin extension functions, since by defining a program to work over a receiver type (let's call it Context) at all levels will mean every level will have access to the mentioned context and its properties. I.e:

    Context.a()
    Context.b()
    Context.c()
    Context.d()
    

    Note that an extension function receiver is a parameter that without extension function support you'd need to manually pass as an additional function argument on every call, so in that way is a dependency, or a "context" that the function requires to run. Understanding those this way and understanding how Kotlin interprets extension functions, the receiver will not need to be forwarded manually on every level but just passed to the entry edge:

    ctx.a() -> b() -> c() -> d()
    

    B, c, and d would be called implicitly without the need for you to explicitly call each level function over the receiver since each function is already running inside that context, hence it has access to its properties (dependencies) enabled automatically.

    So once we understand both we'd need to pick one, or any other DI approach. That's quite subjective, since in the functional world there are also other alternatives for injecting dependencies like the tagless final approach which relies on type classes and their compile time resolution, or the EnvIO which is still not available in Arrow but will be soon (or an equivalent alternative). But I don't want to get you more confused here. In my opinion the Reader is a bit "noisy" in combination with other common data types like IO, and I usually aim for tagless final approaches, since those allow to keep program constraints determined by injected type classes and rely on IO runtime for the complete your program.

    Hopefully this helped a bit, otherwise feel free to ask again and we'll be back to answer 👍