FsAdvent 2020 - Dependency Injection Using Flexible Types and Type Inference
Dependency Injection Using Flexible Types and Type Inference
F# Advent 2020
This is a post for F# Advent 2020 facilitated by Sergey Tihon. Visit the link to see many more posts about F#.
Motivation
When I read Bartosz Sypytkowski's article on Dealing with complex dependency injection in F#, I knew I had to try out his method. I think his article shows a promising alternative to the "standard" dependency injection approaches you see in C# while using core F# features. This post is about my experience using what he calls an "environment parameter" for dependency injection. In short, I found the experience refreshing, and I am eager to see how the environment parameter handles changes in my application. First, I should explain why "standard" dependency injection is not enough for me.
.NET Dependency Injection is Boring and Repetitive
The dependency injection I see most often in C# (.NET Core / .NET 5) looks and feels mechanical - use interfaces and instantiate the dependencies at startup yourself or register the interfaces in some dependency injection container. Then, you find out at runtime if you, or your dependency container, missed an interface or implementation. This approach looks like the default way to encapsulate and manage dependencies in .NET with fair reasons - it sounds simple, looks unsurprising (at least before runtime), and C# tooling makes it feel natural. It is boring and repetitive.
Can F# make dependency injection less mechanical for the developer? Can the language figure out what dependencies you need based on how they are used?
If you already read Bartosz's article, you should not be surprised that I think the answer is "yes, probably". The rest of this post will assume you have not read the article, but you really should. If you do read the post, then there will be some questions that sound rhetorical. In this case, try not to roll your eyes too hard. This post is my way of comprehending Bartosz's method.
What Does F# Offer?
Advocates for F# like to mention the type system, partial application, and type inference. Partial application is a tempting approach, and it seems like an answer to my questions from the previous section. Broadly speaking, you write a function and type inference figures out the types of the arguments and return value based on usage elsewhere in the codebase.
Partial Application
Unfortunately, I do not think this is less mechanical in practice than the "standard" C# approach.
If you create and use a new dependency, you must add another field or constructor argument to services consuming the new dependency. If an existing dependency needs another capability, you will probably add another parameter and update all services that use this dependency. This feels like something the compiler and type inference can handle for us, but how do we make that happen?
Flexible Types
Refer to the F# Language Reference for Flexible Types.
This type annotation allows us to specify that a "a parameter, variable, or value has a type that is compatible with a specified type". My understanding is that this annotation combined with two interfaces is what enables F# type inference to work. Why two interfaces? One interface is for methods tailored to your application logic, and the other interface is to isolate a particular choice of infrastructure (logging, database, some API). Your "environment parameter" will expose the interfaces tailored to your application core logic.
An Example
I made an internal dotnet cli tool to perform some specific tasks against my company's Stash (Bitbucket) REST API. The cli should apply certain repository permissions sourced from a settings file in a central repository. In other words, the tool supports an infrastructure as code workflow for development teams for their source code repository settings. It was a personal project with simple requirements, so I used it to try out the "environment parameter" approach.
The cli needed a few dependencies: logging, an authenticated http client, and an API to perform the necessary Stash REST API operations. Let's finally see some code trimmed down to show just the environment parameter, so no validation or Result
.
Logger Dependency
/// Application will log via these methods.
[<Interface>]
type IAppLogger =
abstract Debug: string -> unit
abstract Error: string -> unit
/// env object will use this interface
[<Interface>]
type ILog =
abstract Logger: IAppLogger
/// #ILog means env can be any type compatible with ILog interface.
/// This is the 'flexible type' annotation and where type inference
/// resolves a compatible interface - it figures out the dependency for us at compile time!
module Log =
let debug (env: #ILog) fmt =
Printf.kprintf env.Logger.Debug fmt
let error (env: #ILog) fmt =
Printf.kprintf env.Logger.Error fmt
// Adapt the dependency to IAppLogger.
// Here I am lazy and log to console, but you can use Microsoft ILogger, NLog, or whatever.
// if the logger needs configuration, I recommend making any config objects be parameters to `live`.
let live : IAppLogger =
{ new IAppLogger with
member _.Debug message = Console.WriteLine ("DEBUG: " + message)
member _.Error message = Console.WriteLine ("ERROR: " + message) }
Next, let's see how a findUser
function looks that only uses ILog
.
// val findUser:
// env : ILog ->
// searchTerm: string
// -> unit
let findUser env = fun searchTerm ->
Log.debug env "Searching for user with search term: \"%s\"" searchTerm
This function does not do anything useful, and the function signature is not surprising. This is just the usual type inference you would expect to see. We need to use another dependency to see an interesting difference in the signature.
Users API Dependency
Next, let's define the IStashUsers
and IStashApi
. If the need for the two logging interfaces was clear, then we can say the two Stash interfaces are analogous to IAppLogger
and ILog
interfaces respectively. The first is what the application logic needs, and the second is what the "flexible types" annotation uses to enable the compiler to infer the correct interface and implicitly add the dependency to the environment type definition. At least, that is how I understand it. Hopefully not wrong!
// I decided to go perhaps a little too far by isolating the serializer dependency too.
// With System.Text.Json, this may not be remotely useful anymore.
[<Interface>]
type ISerializer =
abstract Deserialize<'t> : HttpContent -> Async<'t>
abstract Serialize : 't -> string
module Serializer =
open Newtonsoft.Json
open Newtonsoft.Json.Serialization
let private settings = JsonSerializerSettings()
settings.ContractResolver <- CamelCasePropertyNamesContractResolver()
let live =
{ new ISerializer with
member _.Deserialize<'t> httpContent =
async {
let! stringContent = httpContent.ReadAsStringAsync() |> Async.AwaitTask
let deserialized = JsonConvert.DeserializeObject<'t>(stringContent, settings)
return deserialized
}
member _.Serialize toSerialize =
JsonConvert.SerializeObject(toSerialize, settings)
}
[<Interface>]
type IStashUsers =
abstract GetByUserName: string -> PageResponse<Incoming.UserDto>
[<Interface>]
type IStashApi =
abstract Users: IStashUsers
module StashUsers =
let getUserByUserName (env: #IStashApi) searchTerm =
env.Users.GetByUserName searchTerm
let live (serializer: ISerializer) stashApiUrl accessToken : IStashUsers =
{ new IStashUsers with
member _.GetByUserName userName =
async {
let! response =
FsHttp.DslCE.Builder.httpAsync {
GET (sprintf "%s/rest/api/1.0/admin/users?filter=%s" stashApiUrl (Http.encode userName))
Authorization (sprintf "Bearer %s" accessToken)
}
return! serializer.Deserialize<PageResponse<Incoming.UserDto>> response.content response
}
}
Using Two Dependencies Together
Notice how env
changed to require both ILog
and IStashApi
once findUser
uses Log.debug
and StashUsers.getUserByUserName
. Again, this type inference works because the Log
and StashUsers
modules use the #ILog
and #IStashApi
flexible type annotations respectively.
// val findUser:
// env : 'a (requires :> ILog and :> IStashApi )->
// searchTerm: string
// -> option<UserDto>
let findUser env = fun searchTerm ->
Log.debug env "Searching for user with search term: \"%s\"" searchTerm
// PageResponse<UserDto>
let x = StashUsers.getUserByUserName env searchTerm
// option<UserDto>
let user = x.Values |> Array.tryHead
Log.debug env "Best match for %s is %s" searchTerm user.Name
user
Does Environment Parameter Answer My Questions?
The questions were:
- Can F# make dependency injection less mechanical for the developer?
- Can the language figure out what dependencies you need based on how they are used?
I think the answer is yes, probably.
If I take away all uses of the Log
module from findUser
then env
type signature is only IStashApi
.
If I create a third module SomeOtherDependency
following the same two interface pattern with #ISomeOtherDependency
flexible type annotation pattern and use that module in findUser
, then env
will automatically be inferred to require the third interface. Pretty convenient!
I do not depend on some library or framework. Type inference and flexible type annotations are standard F# language features. If the environment type does not meet the needs of some function in some module, the code will not compile.
You still need to provide proper configurations, connection strings, etc at startup. The compiler does not check that, unless you are willing to add in a type provider. SQLProvider for example checks queries against a real database at compile time. Maybe there is a type provider or similar tool to do that for your configured dependency? That does not sound worth the effort and is beyond the scope of this post.
Remaining Questions
So far this post sounds like I am totally sold and have no other concerns. That is not true. I have some unanswered and untested questions.
- How to handle service lifetime and scoping, if at all?
- Can this approach be accomplished in C#?
- Perhaps by using type constraints, but I think C# would need type inference. No idea.
- Is this easier than "standard" C# Microsoft.Extensions.DependencyInjection?
- I think so, but my application is still simple compared to other codebases I work with.
Links and Contact
View the other F# Advent 2020 posts!
I would like to thank Bartosz for his post. I think it showed me a middle ground between partial application and a reader monad that I would not have found by myself.
Links:
- Dealing with complex dependency injection in F# - Bartosz Sypytkowski, 22-Mar-2020
- F# Language Reference: Flexible Types
Contact:
I do not have a comments section, so please use @garthfritz on Twitter or @garth
on the F# Software Foundation Slack (slack access requires free F# Software Foundation membership) to contact me with feedback or clarification.