In a recent design session, we were discussing the API for an event bus that supports asynchronous RPC. A colleague of mine proposed a fluent interface. A simplified version of it looked roughly like this:
bus.for(event).replyTo(handler).withATimeOutOf(500).Send(); |
The proposal lead to some debate because some members of the team liked the idea and others preferred the more traditional approach of using overloaded methods as follows.
bus.Send(event, replyHandler); bus.Send(event, replyHandler, timeout); |
In the end it was agreed that the traditional API was essential, and if some of the team wanted a fluent API, one could be written as a wrapper around it.
I think this was the right call and the point of this post is to explain why. One of the reasons that was given against the fluent interface was that they are very hard to extend if you do not have the ability to modify the original source. In short, they violate the Open/Closed Principle, one of the core principles of SOLID object oriented design.
Lets take a look at why.
Here is a pseudo implementation of the fluent API.
class Builder { Builder for(IEvent event) { ... ; return this } Builder withReplyTo(IHandler handler) { ... ; return this } Builder withATimeOutOf(int milliseconds) { ... ; return this } void send() { ... } } |
Omitted is the operative code that would collect the various arguments and ultimately invoke the base API in Send(). What remains is just what is needed to do the fluent call chaining, namely returning an instance of the builder itself. It is this return value that violates OCP.
Suppose a third party wants to add a second event handler to be invoked in case of a timeout. We will presume the base Bus class is otherwise well factored and adheres to OCP. Because of this, adding a new Send overload to the traditional API can be achieved by subclassing the base implementation. Invoking the new method will look like this.
bus.Send(event, replyHanlder, timeout, timeoutHandler); |
But what happens if we try to extend our fluent API by subclassing the Builder?
class NewBuilder : Builder { NewBuilder andHandleTimeoutsWith(ITimeoutHanlder handler) { ... return this; } } |
Now we see the problem with the base class methods returning Builder. From our subclass, even if the return value is actually an instance of MyBuilder, it is returned by the inherited members as Builder. In a statically typed language, the new method is not accessible without a very un-fluent explicit cast.
A Generic Solution?
In a simple API, if the original designers are prescient enough, one might get around this by using generics. Suppose the orignal builder had been written generically.
class Builder<T> where T : Builder { T for(IEvent event) { ... ; return this } T andReplyTo(IHandler handler) { ... ; return this } T for(int milliseconds) { ... ; return this } void send() { ... } } |
As can be seen, we still need the non-generic Builder so that the generic one can be instantiated in terms of it. If we would normally access the fluent API like so: bus.GetFluentBus<Builder>(), we could then access our extended API like this: bus.GetFluentBus<MyBuilder>().
This approach gives us a builder that complies with OCP. The down side is we have muddied up our GetFluentBus method with a generic argument just to allow for what is likely to be an edge case. But supporting edge cases is what extensibility is all about.
A bigger problem is that the generic approach only works well for a simple, single-builder API. Most fluent interfaces use a set of builder classes and/or interfaces in order to limit the methods available at any point in the call chain to those that make sense. In this scenario, genericizing the API gets ugly. Every builder will need to be generic not only with respect to itself but with respect to every builder that it returns directly or indirectly, and our GetFluentBus method is going need generic arguments for all of them. Consequently, generics are not a good general purpose solution.
Conclusion
Fluent APIs do have an appeal and a legitimate place in software design. However, they are not extensible and therefore should never be the only means of doing something. Rather they need to be seen as an augmentation and approached with the understanding that their purpose is to make the common use cases fluent for those who want the option.