Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

F# analog of dependency injection for a real project

The question is based on a great F# / DI related post: https://fsharpforfunandprofit.com/posts/dependency-injection-1/

I tried to post the question there. However, it appears that due to some glitches on the site the posts can no longer be registered. So, here it is:

I wonder how the scenario described in that post would work / translate into a more real-world example. The numbers below are a little bit from the sky, so, please, adjust them as it feels necessary.

Consider some reasonably small C# based DI / TDD / EF Code First based project:

Composition root: 20 interfaces with 10 methods (on average) per each interface. OK, this is probably too many methods per interface, but, unfortunately, they often tend to bloat as the code develops. I’ve seen much more. Out of these, 10 are internal services without any IO (no database / "pure" functions in func world), 5 are internal IO (local database(s) and similar), and the last 5 are external services (like external database(s) or anything else that calls some remote third-party services).

Each interface has a production level implementation with 4 injected interfaces (on average) and uses 5 members of each interface for the total of 20 methods (on average) used per implementation.

There are several levels of tests: Unit Tests, Integration Tests (two levels), Acceptance Tests.

Unit Tests: All calls are mocked with the appropriate mock setup (using some standard tool, like Moq, for example). So, there are at least 20 * 10 = 200 unit tests. Usually there are more because several different scenarios are tested.

Integration Tests (level 1): All internal services without IO are real, all internal IO related services are fakes (usually in-memory DB) and all external services are proxied to some fakes / mocks. Basically that means that all internal IO services, like SomeInternalIOService : ISomeInternalIOService is replaced by a FakeSomeInternalIOService : ISomeInternalIOService and all external IO services, like SomeExternalIOService : ISomeExternalIOService is replaced by FakeSomeExternalIOService : ISomeExternalIOService. So, there are 5 fake internal IO and 5 fake external IO services and about the same number of tests as above.

Integration Tests (level 2): All external services (including now the local database related ones) are real and all external services are proxied to some other fakes / mocks, which allow testing failures of external services. Basically that means that all external IO services, like SomeExternalIOService : ISomeExternalIOService is replaced by BreakableFakeSomeExternalIOService : ISomeExternalIOService. There are 5 different (breakable) external IO fake services. Let’s say that we have about 100 of such tests.

Acceptance Test: Everything is real, but configuration files point to some “test” versions of external services. Let’s say that there are about 50 of such tests.

I wonder how that would translate into F# world. Obviously, a lot of things will be very different and some of the things may not even exist in F# world!

Thanks a lot!

PS I am not looking for exact answer. A "direction" with some ideas would suffice.

like image 325
Konstantin Konstantinov Avatar asked Sep 03 '18 22:09

Konstantin Konstantinov


2 Answers

Just to add to Tomas' excellent answer, here are some other suggestions.

Use pipelines for each workflow

As Tomas mentioned, in FP design, we tend to use pipeline oriented designs, with one pipeline for each use-case/workflow/scenario.

What's nice about this approach is that each of these pipelines can be set up independently, with their own composition root.

You say you have 20 interfaces with 10 methods each. Does every workflow need all these interfaces and methods? In my experience, a individual workflow might only need a few of these, in which case the logic in the composition root becomes much easier.

If a workflow really does need more than 5 parameters, say, then it might be worth creating a data structure to hold these dependencies and pass that in:

module BuyWorkflow =

    type Dependencies = {
       SaveSomething : Something -> AsyncResult<unit,DbError>
       LoadSomething : Key -> AsyncResult<Something,DbError>
       SendEmail : EmailMessage -> AsyncResult<unit,EmailError>
       ...
       }

    // define the workflow 
    let buySomething (deps:Dependencies) = 
        asyncResult {
           ...
           do! deps.SaveSomething ...
           let! something = deps.LoadSomething ...
        }

Note that the dependencies are generally just individual functions, not whole interfaces. You should only ask for you need!

Consider having more than one "composition root"

You might consider having more than one "composition root" -- one for internal services and one for external.

I normally break my code into a "Core" assembly with only pure code and an "API" or "WebService" assembly which reads the configuration and sets up the external services. The "internal" composition root lives in the "Core" assembly and the "external" composition root lives in the "API" assembly.

For example, in the "Core" assembly you could have a module that bakes in the internal pure services. Here's some pseudocode:

module Workflows =

    // set up pure services
    let internalServiceA = ...
    let internalServiceB = ...
    let internalServiceC = ...

    // set up workflows
    let homeWorkflow = homeWorkflow internalServiceA.method1 internalServiceA.method2 
    let buyWorkflow = buyWorkflow internalServiceB.method2 internalServiceC.method1 
    let sellWorkflow = ...

Then you use this module for your "Integration Tests (level 1)". At this point the workflows are still missing their external dependencies, so you will need to provide the mocks for testing.

Similarly, in the "API" assembly you can have a composition root where the external services are provided.

module Api =

    // load from configuration
    let dbConnectionA = ...
    let dbConnectionB = ...

    // set up impure services
    let externalServiceA = externalServiceA(dbConnectionA)
    let externalServiceB = externalServiceB(dbConnectionB)
    let externalServiceC = ...

    // set up workflows
    let homeWorkflow = Workflows.homeWorkflow externalServiceA.method1 externalServiceA.method2 
    let buyWorkflow = Workflows.buyWorkflow externalServiceB.method2 externalServiceC.method1 
    let sellWorkflow = ...

Then in your "Integration Tests (level 2)" and other top level code, you use the Api workflows:

// setup routes (using Suave/Giraffe style)
let routes : WebPart =
  choose [
    GET >=> choose [
      path "/" >=> Api.homeWorkflow 
      path "/buy" >=> Api.buyWorkflow 
      path "/sell" >=> Api.sellWorkflow 
      ]
  ]   

The acceptance tests (with different configuration files) can use the same code.

like image 132
Grundoon Avatar answered Nov 13 '22 10:11

Grundoon


I think that one key question that the answer depends on is what is the pattern of communication with the external I/O that the application follows and how complex is the logic controlling the interactions.

In the simple scenario, you have something like this:

+-----------+      +---------------+      +---------------+      +------------+
| Read data | ---> | Processing #1 | ---> | Processing #2 | ---> | Write data |
+-----------+      +---------------+      +---------------+      +------------+

In this case, there is very little need for mocking in a nicely designed functional code-base. The reason is that you can test all the processing functions without any I/O (they are just functions that take some data and return some data). As for reading and writing, there is very little to actually test there - these are mostly just doing the work that you'd do in your "actual" implementation of your mock-able interfaces. In general, you can make the reading and writing functions as simple as possible and have all logic in the processing functions. This is the sweet-spot for functional style!

In the more complex scenario, you have something like this:

+----------+      +----------------+      +----------+      +------------+      +----------+
| Some I/O | ---> | A bit of logic | ---> | More I/O | ---> | More logic | ---> | More I/O |
+----------+      +----------------+      +----------+      +------------+      +----------+

In this case, the I/O is too interleaved with the program logic and so it's hard to do any testing of larger logical components without some form of mocking. In this case, the series by Mark Seemann is a good comprehensive resource. I think your options are:

  • Pass around functions (and use partial application) - this is simple functional approach that will work unless you need to pass around too many parameters.

  • Use a more object-oriented architecture with interfaces - F# is a mixed FP and OO language, so it has nice support for this too. Especially using anonymous interface implementations means you often do not need mocking libraries.

  • Use an "interpreter" pattern where the computation is written in an (embedded) domain specific language that describes what computations and what I/O needs to be done (without actually doing it). Then you can interpret the DSL differently in real and test mode.

  • In some functional languages (mostly Scala and Haskell), people like to do the above using a technique called "free monads", but the typical description of this tends to be overly complicated in my opinion. (i.e. if you know what a free monad is, this might be helpful pointer, but otherwise, you're probably better of not getting into this rabbit hole).

like image 39
Tomas Petricek Avatar answered Nov 13 '22 12:11

Tomas Petricek