A balancing act

Eric Torreborre
barely-functional
Published in
9 min readApr 7, 2019

It would be so nice to have a set of exact rules on how to design software. With Functional Programming we can get the impression that everything can be normalized, formalized, algebraized to the point where only one implementation remains. I don’t think this is the case though and our mental model should be the one of security experts. What is our “threat model”? What are the things that we are really trying to prevent? And what does it cost us? (that looks like a “Freedom vs Security” debate)

I want to illustrate this dilemma with three ongoing discussions that I am having with my colleagues at work:

  1. Part 1: what should be the interfaces between our components (we are using Haskell)?
  2. Part 2: should we throw exceptions?
  3. Part 3: should we use Template Haskell to remove boilerplate?

Part 1: Interfaces

We are currently implementing an application in Haskell using “records-of-functions” (this is known as the “Handle pattern”). I have a strong preference for using this way of structuring Haskell applications because:

  • The “MTL” approach using monad transformers does not give us easy ways to fully model a graph of components and to swap full parts of that graph
  • I don’t recommend effect libraries anymore
  • I think that the ReaderT design pattern is also too limited because it only allows us to switch leaves in our “application graph”
  • We have an easy way to assemble/re-assemble “records-of-functions” at our disposal
  • “records-of-functions” are very easy to understand and provide a natural interface/implementation separation which is really what we are after when building applications

I want to stress that last point. I am not against sophisticated techniques: type systems, specification languages, verification tools. But when they get in the way of understanding our system they are not helping us. And if a fancy effect library gets in the way of refactoring the system so that we have less moving parts, it is not working for us. It is also all the more important in my current company where some non-Haskell experts need to be able to read the code and modify some parts of it to implement larger features.

That still leaves some open questions:

  1. what kind of monad should we use as return types for those functions?
  2. passing down the dependencies (“handles”) in the implementation is tedious and possibly error prone (you could accidentally pass a no-op implementation for example)

I will deal with the second point in part 3 of that post, let’s focus on point n.1 for now.

My proposal for “records-of-functions” is to use IO as the return type. This might be a shocking proposal for some functional programmers. Why do I want this? For two reasons:

  • simplicity of integration: a component having an IO interface can always interact with another component having an IO interface. No type juggling required here, easy refactorings
  • maximum encapsulation: a component using IO is basically telling you nothing about its implementation. This means that it is free to do things differently, like running some code concurrently or access a database instead of storing values in-memory and your code won’t be impacted

I’m really favouring composition and modularity here. But this doesn’t come for free. Interfacing with IO means that indeed anything can happen when using such a component, launching all the nukes you can think of: exceptions, reading the file system, maintaining global state. This is why this is a balancing act. We can’t win them all.

Let’s investigate some consequences of that choice. Actually I propose to return IO only when constructing a specific component, not when using it. For example here is a logging component

The data types just know about a type m but their constructors are more specific and require IO. Also note that in the implementation of Authenticator we only need to know about Logger using a Monad. No need for IO there.

You might wonder why not MonadIO m for the Logger constructor?

My question back to you is “Why would you need it?”. Having MonadIO is only useful if we need to layer, later on, additional capabilities on top of that IO monad. But what could we really need? A few possibilities off the top of my head:

  1. ExceptT to add error management: jump to part 2 of this post where we deal with errors and exceptions
  2. ReaderT to add a way to inject “capabilities”: we already have a way to inject capabilities
  3. ReaderT to get some context about the context of the caller, for example access a RequestId. More on that in a minute
  4. StateT for testing. This way we can have “pure” components for testing where the component state is managed through a state monad

Pure testing

Let’s talk about “pure testing” for a moment. You will find many articles telling you that parametrizing your code with a monad m gives you the ability to replace that m with either Identity or State when testing in order to avoid using IO. I don’t find this to be a compelling argument because

  • using mutation for testing is not necessarily harmful especially if those components already represent some effectful behaviour like Database, Logging, S3Client, ...
  • using State or StateT puts the burden on the client of the component to deal with state. It has to provide a valid initial state and eventually call runState. And if you have several components offering a State interface you have to find a way to run all those states together, providing a valid initial state for each of them even if some of those components are not particularly meaningful for the current test

On the contrary a mock offering an IO interface is can be used independently from anything else. For example you can implement a Logger component which will collect all the log messages in an IORef variable to do later checks.

This example does not seem to have less boilerplate than a similar example using State however it can easily be extended if Authenticator needs new dependencies like Time IO in order to check that a user can only access a service from 9 to 5 independently from checking the logs. In the test above, if the time behaviour has no influence on the logging, we can just modify:

let authenticator = newAuthenticator logger newFixedTime

We don’t have to create a special state construct unifying the state for the Logger and the state for Time.

Bottom line: we only need IO as the interface of our components.

Well… almost, there is still the question of RequestIds.

Accessing a context

When implementing services we need, almost all the time, to track the origin of a given request. For auditing reasons, to trace calls times and diagnose performance issues, for debugging issues. This is an important requirement but also a minor one. What your service does in the first place is a lot more important!

Unfortunately this is one of those requirements that is very invasive. Tracing request ids means that almost all of your code needs to be “request-id aware”. And there aren’t too many ways to do this:

  • you explicitly require almost every function to use a requestId parameter
  • you use ReaderT RequestId IO as a return type

Here is the dilemma. Almost all the functions need a RequestId but not all of them. This means that if we want to be precise in our software we need to have some component constructors with a ReaderT RequestId IO interface, and if we are even more specific, have only some functions of the interface return ReaderT RequestId IO while the other ones return IO.

This now makes the components harder to interoperate because you have to “lift” IOoperations into ReaderT RequestId IO and this also has a “viral” effect meaning that almost all your functions will need to operate in that monad anyway.

What is the alternative? You can define a type alias type AppIO = ReaderT RequestId IO and use it everywhere. That’s simple, we get back an easy way to integrate components. What is the downside? There are few places where no RequestId is available and you will have to provide a fake to make that code run. This seems harmless but that also means that your run the risk of forgetting to provide the proper requestId in places where it is available and must be propagated!

My choice here is again to go for simplicity:

  • use AppIO everywhere, for easy integration
  • carefully test the application to make sure that entry and exit points really get the proper requestIds

I am trading compiler-supported correctness of a minor feature of the service against massive convenience for all the rest of the application. I think this is worth it.

Part 2: exceptions and error management

I have been sitting on a blog post on exceptions and error management for a long time now. I seem to be completely unable to deal with that subject in a concise and structured way, and that reflects the state of my mind about it!

That’s because this is a balancing act as well. Say we settle on using ReaderT Request IO for all of our components. How do we represent errors? Should some components return EitherT DatabaseError (ReaderT RequestId IO) as well? Should theDatabase component functions throw an exception when they can not connect to the database? Should we abstract the constructor of Database to MonadError DatabaseError?

Here is my proposal:

  1. use concrete error types in the interface of components when the domain is involved
  2. throw exceptions when there are “operational issues”
  3. classify “operational issues” based on what the client can do, not what the implementation did
  4. document and test those exceptions

Domain errors

Those are errors related to what a given component is supposed to provide in terms of added value:

  • access a record in the database: was it found?
  • parse some text: are there some errors?
  • download a file: is the checksum ok?

Operational issues

Those errors cover the cases where the service cannot be delivered:

  • a dependency does not respond: because it’s too slow, disconnected, overloaded
  • there is a developer bug: maximum has been called on an empty list
  • one invariant is broken: there should be some reference data in the database but it is missing

There is generally not much we can do about those errors, some of them might be transient or can benefit from some form of “state reset” and can be retried. The others can just be logged. I argue that representing those “operational errors” in the API is not particularly helpful:

  • they are likely to “bubble-up” to the top of the application without any intermediate component being able to do anything anyway
  • we need a top-level strategy to handle exceptions anyway whether they are declared or not
  • if they are describing in types why they are failing: BrokenDatabaseConnection, IncorrectConfiguration, ... then they are exposing implementation details to user components which will have to be recompiled or modified if that implementation changes

This is why the best thing we can do is roughly to classify those errors into: can be retried / cannot be retried and just carefully log any additional information for further investigation.

But this is a balancing act because we lose some knowledge. Now it is harder to know when reading the interface of a component “what can go wrong”. This is why we need to counteract by providing extensive documentation and testing. That’s the price to pay for the convenience of not having to model too much our exceptions.

Part 3: Template Haskell

This is a minor point compared to the other 2 but still significant. The “Handler pattern” is nice because it proposes a very simple way to build and assemble Haskell applications. Unfortunately you have to manually pass down dependencies in the components implementations (cf the use of Logger in Authenticator). This is a bit tedious and possibly error-prone. Can we do better?

Yes we can, we can declare a typeclass that offers the same interface as a component and provide a default instance for that typeclass using the component (using a Reader). This is described here. This can also be automated by a bit of Template Haskell because that code is very mechanical and a machine can totally update the typeclass and the instance when a component is evolving.

But this comes with a downside. Now you have some “magic” code. You have some Template Haskell which doesn’t tell you anything about what it is generating. You have to read the documentation, you have to enable -ddump-splices to see the generated code. This is not very newbie-friendly.

I don’t have a strong preference here, I’m just happy to whatever consensus we can have on the team. I don’t mind passing components manually and I wouldn’t mind using a typeclass. Another option would be to use the Template Haskell once to generate the code, paste it and then modify it manually as a component evolves.

Damn, we can not have it all,… in the current state of the art. We can also imagine an enhanced compiler, or enhanced tools giving us ways to expand or regenerate Template Haskell code. This is interesting. This means that there are some situations where our constraints are in complete opposition with each other: we cannot both encapsulate and expose the implementation of a component. We have to make choices. But there maybe some cases where we can eat our cake and have it too!

--

--