Mr Rampage
The Rampage

The Rampage

APIs for Critical Systems

Using Fluent Interfaces to enforce Workflow

APIs for Critical Systems
Mr Rampage's photo
Mr Rampage

Published on Aug 20, 2021

9 min read

In 2000, a software system used to plan radiation treatment inadvertently caused the death of 5 patients through radiation overdose. The cause was found to be poor software design and human error. As software engineers, it is vital to always remember:

In engineering, failure equals death. - James Cameron

In web applications, we often talk about building in layers of security to avoid data breaches. In critical systems, we need to consider layers of safety. All components, hardware and software, will require multiple fail-safes to avoid injury. When it comes to software, a safe system would:

  1. Adhere to the requirements
  2. Avoid impossible states

Adhering to the requirements involves a lot of communication and discussion to ensure all stakeholders understand the risks involved and the safe guards to prevent injury. Avoiding impossible states is building the software in a way to avoid unsafe operation. One technique to achieve this is to design a Fluent interface.

Fluent interface

A fluent interface is an interface that enables method chaining. That means methods can be called successively just by adding a dot. The end goal is that it's suppose to make code more readable and easier to understand. However, an alternative goal is to make a domain-specific language (aka DSL). By designing the API to address the business requirements, developers and product owners can reason about the code without needing to understand the low-level details.

Consider the following example for tallying a food bill:

public double GetTotal(Food[] foodItems) {
  var total = 0;

  foreach (var item in foodItems) {
    total += item.Price;
  }

  return total * 1.13;
}

This example is fairly trivial and any junior developer would understand the code, but it still requires some thinking to understand the intent. Imagine if we created extension methods to calculate the gross total and apply tax, then a DSL could be designed to achieve the following:

public double GetTotal(Food[] foodItems) {
  return foodItems
    .CalculateGrossTotal()
    .ApplySalesTax(Province.Ontario);
}

Subjectively, this is much easier to reason about. For what its worth,

fluent interfaces are focused on communicating intent.

Fluent Interfaces and your IDE

fluent.gif

One of the coolest things about a Fluent Interface, is that the IDE can be used as a tool to guide developers. In the video above, we can see that once we have the foodItems, the IDE will suggest possible valid method calls. When the gross total has been calculated, the only thing a developer can do is:

  1. Get the gross total.
  2. Apply the sales tax according to province.

With a procedural approach, applying the sale tax according to province was implicit. Current and future developers have to know that multiplying the total by 1.13 is the way that the sales tax is calculated for the province of Ontario. With the fluent interface, the major difference is that the code becomes declarative. Developers only need to consider what needs to be done instead of how it is done. Furthermore, since the workflow is baked into the interface, a developer cannot calculate the sales tax without first calculating the gross total.

Tradeoffs

Some people think fluent interfaces are evil and some just bad. This is because some tradeoffs have to be made. However, when safety is required, the tradeoffs might be worth it. The general idea is to design the interfaces to be optimized for safety and readability, not developer convenience. If the code is used in an unsafe way, it should not compile. In general, the tradeoffs are:

  1. More time is required for design
  2. "Ugly" code
  3. Creates coupling

For the first tradeoff, Rich Hickey alludes, we should be spending more time with design instead of banging away on the keyboard. Bugs are much cheaper to fix at design time than in production, especially with critical systems.

Fixing a bug after it has killed a patient is much worse than spending several weeks hashing out an interface.

The second tradeoff is more subjective, but given all things being equal, I would rather have a clean interface with an ugly implementation, than a poor interface with a clean implementation. The reasoning is that with a clean interface, the implementation can be refactored in the future with minimal impact. With a poor interface, a refactor would impact all users of the interface.

The third tradeoff is that fluent interfaces often create a certain amount of coupling. However, in this case we want to couple the API with the workflow. What that means is that if the workflow changes, it might require significant work. However, for critical systems, changes to workflow usually have enough impact to warrant significant work in both software design and user process. This coupling is usually the reason why some people think fluent interfaces are bad, but the point people are actually trying to make is that fluent interfaces shouldn't be used everywhere.

Fluent interfaces are not evil or bad, using them in the wrong use case is.

Designing Fluent Interfaces

Let's see how the food total fluent interface could be implemented. To set the record straight, this example is a trivial problem and probably might not warrant a DSL. However, it would be too complex to demonstrate creating a Fluent interface to administer radioactive treatment.

For this example, let's assume all monetary values are in Canadian dollars and that we want to optimize for understanding. To create a fluent interface, we should consider the workflow first. When enforcing a workflow, you can think of it as creating a data pipeline, but using the types to enforce flow. In this case, our data flow can be thought of as a transformation of:

food items => gross total => net total

To enforce this flow, we will need a separate interface for each step. The idea is to chain the methods in such a way that the output of one leads to the input of another. Using the type system, we can enforce what methods can be called and when.

// I really wish .NET had the equivalent of Java JSR 354.
public interface IMoney {
  decimal Value { get; }
  RegionInfo Region { get; }
}

public interface IFood {
  IMoney Price { get; }
}

public interface IGrossTotal {
  IMoney Total { get; }
}

public interface INetTotal {
  IMoney Total { get; }
}

Aren't IGrossTotal and INetTotal the same thing?

It may seem unnecessary to define IGrossTotal and INetTotal because they appear identical, but from a domain perspective they are two completely different concepts. The gross total represents the total before tax and the net total represents the total after tax. This allows both the business and developer to be explicit about a total. In this case, we remove ambiguity to allow for maximum communication at the cost of an extra interface.

Chaining the Workflow

With the interfaces available, we can chain the function calls through extension methods or using regular object oriented programming. CalculateGross operates on IFood items and returns an IGrossTotal. ApplySalesTax operates on IGrossTotal to produce a INetTotal. This ensures that CalculateGross must be called before ApplySalesTax.

public static class SalesExtensions
{
  public static IGrossTotal CalculateGross(this IEnumerable<IFood> foodItems)
  {
    ...
  }

  public static INetTotal ApplySalesTax(this IGrossTotal grossTotal, Province province)
  {
    ...
  }
}

Further restrictions could be embedded in the code by making the visibility of all concrete implementations as internal. In this case, since IGrossTotal and INetTotal are intermediary types, any concrete implementation should be made internal to avoid circumventing the workflow. It's not totally bullet proof because it still can't guard against someone from hacking in their own implementation, but hopefully, the code review process can catch that.

I highly recommend trying to implement this on your own. It should be noted, that this does add a lot more code due to all the interfaces. My final implementation that supported all Canadian provinces ended up being around 100 lines of code. Interestingly, the amount of logic remained the same, but the amount of data types increased. So from a maintainability point of view, the cost is for maintaining more explicit types, but when it comes to optimizing for safety, it may well be worth the cost.

Programming with Seat Belts and Air Bags

For my current project, the API ensures that any developer must work with the system within its boundaries. Consider the following:

var service = new MotorService();

service.SelectMotor(Motor.A);
service.CalibrateMotor();
service.ConfirmCalibration();
service.StartMotor();
var result = service.ApplyAdjustment(new Adjustment(15));
LogResult(result);
service.StopMotor();

This code might seem familiar, but the challenge is that the service needs to hold the state. Run time checks need to be done to ensure the workflow. For example, logic needs to be added to ConfirmCalibration to ensure that that the motor has been calibrated. If a developer accidentally calls ConfirmCalibration before CalibrateMotor, then that could put the service in a bad state. This adds complexity and the potential for bugs. State management should never be under estimated. It is hard!

Using a fluent interface, we can achieve the following:

var service = new MotorService();

service
  .SelectMotor(Motor.A)
  .CalibrateMotor()
  .ConfirmCalibration()
  .StartMotor()
  .ApplyAdjustment(new Adjustment(15))
  .Then(LogResult)
  .StopMotor();

In this case, state management is not needed because each step returns a new type. A developer cannot call ConfirmCalibration without first calling CalibrateMotor. Similarly, the more critical step is that the motor cannot be started without the confirmation. This API is design to optimize for safety and avoid developers from accidentally introducing bugs.

This API took several weeks to flesh out with several meetings with stakeholders and team members. Sequence and state diagrams were done to visually identify any potential safety issues. After all that planning, it took about a day to implement. This is the cost of a fluent interface. Less hands on keyboards, more minds at work.

For fluent interfaces, collaboration == communication > coding

Conclusion

Fluent interfaces is one tool that can be used to create stable and safe software. It is not a silver bullet, but is worth considering when safety and readability are paramount. I recommend trying it out, but before using it in production, work with your team to see if it fits the use case. I also highly recommend reading and understanding the arguments for and against it. It is always vital to understand the tradeoffs and use cases. At the end of the day, knowing how to create Fluent Interfaces is just another tool in the developer's toolkit - neither good nor bad.

References and Resources

 
Share this