Practical .NET

Best Practices for Designing a Fluent API

You can't "grow" a fluent API; you need to understand how developers will need (and expect) to use your API. Here's a case study of what the design process for a fluent API looks like.

In my previous column, "When to Build Fluent Interfaces for Re-Use and Clarity," I discussed the design considerations that I would take into account in deciding to build a fluent API. While I appreciate the readability that a fluent interface provides, before deciding to undertake the extra work that implementing a fluent API requires, I look for methods and properties that aren't trivial to implement (so that I save developers time by providing them) and that can be applied to a variety of classes that don't share a common ancestor.

In this column, I'm going to walk through the issues involved in designing the members (methods and properties) that would make up a fluent API. However, I'm going to ignore one of my criteria: I'm not going to worry about whether those methods could be applied to multiple classes. Many developers feel the readability benefits that a fluent API delivers make the extra work required to build a fluent API valuable, even if the methods are only used with a single class -- and who am I to disagree? More importantly, ignoring the need to support multiple classes simplifies this column considerably (something that both you and I will appreciate).

The Problem
I'm going to assume the existence of an OrderLine class that represents the purchase of a quantity of a specific product. That OrderLine class looks like this:

Public Class OrderLine
  Public Property Quantity As Integer = 0
  Public Property ProductId As String = String.Empty
  Public Property Discount As Decimal = 0
End Class

The OrderLine class is used by the SalesOrder class which, coincidentally, also has three properties, including a collection of OrderLines:

Public Class SalesOrder
  Public Property DeliveryDate As Date = Date.Now
  Public Property TotalValue As Decimal = 0
  Public Property OrderLines As New List(Of OrderLine)
End Class

Before starting to design a fluent API for these classes, you need to determine the typical activities that developers will perform using them. For the SalesOrder and OrderLine classes, those are:

  • Add an OrderLine
  • Remove an OrderLine
  • Apply a Discount to an OrderLine
  • Calculate the TotalValue of the Order

Because of the business rules involved, none of these operations are straightforward, and developers could easily get them wrong. Furthermore, the SalesOrder and OrderLine classes are used in many applications throughout the company, so asking developers to constantly recreate the code for these activities would be a burden. It makes sense, therefore, to write the code to support these activities once and require developers to use those methods -- something developers will be happy to do if, in fact, your fluent API makes their lives easier.

An Initial Design
The first step in designing a fluent API is to decide which of those activities may be performed together and, as a result, would provide benefits to a developer by allowing the operations to be chained together.

For instance, the User Interface for the online sales application allows users to create a new OrderLine by typing over an existing OrderLine. It would be convenient for a developer to be able to support that activity by chaining together the AddOrderLine and RemoveOrderLine methods, like this:

Public Function ReplaceOrderLine(soId As String,
                                 oldProductId As String, 
   newProductId As String, 
   quantity As Integer)

  Dim so As SalesOrder
  so = dal.GetSalesOrder(SalesOrderId)
  so.RemoveOrderLine(oldProductId).
     AddOrderLine(newProductId, quantity)

  Return so
End Function

For these methods to work this way, there are a couple of criteria they need to meet. To begin with, a developer might reasonably expect to be able to call the methods in the reverse order -- adding the new OrderLine before removing the first one, like this:

  so.AddOrderLine(oldProductId).
     RemoveOrderLine(newProductId, quantity)

If you believe that's a reasonable expectation on the developer's part, then you'll need to make sure that your methods are independent of each other so that their calling order doesn't matter. In general, in a fluent interface you want to write methods that accept an object, make changes to it and then return the object without making any assumptions about what other operations may or may not be performed on the object.

In addition, the RemoveOrderLine method must return an object that the AddOrderLine method can be used with. There are at least two choices here: The RemoveOrderLine method could return the collection of OrderLines that the AddOrderLine will add its OrderLine to, for instance.

The business has another requirement, however: After an Order's OrderLines collection is changed, the value of the Order needs to be recalculated (this functionality can't be built into the SalesOrder or OrderLine classes because the TotalValue isn't always required; for international orders, for instance, TotalValue is left at 0 until the order ships). It would be helpful if developers could add a method that supports calculating the TotalValue to the chain:

so.RemoveOrderLine(oldProductId).
  AddOrderLine(newProductId, quantity).
 CalculateTotalValue()

If the AddOrderLine method just returns a set of OrderLines, the CalculateTotalValue method may not be able to access the original SalesOrder to update its TotalValue property. It makes more sense, therefore, for the RemoveOrderLine method to return the full SalesOrder object, which the AddOrderLine method can then pass on to the CalculateTotalValue method.

Having a fluent API that consists of independent methods that use the same class both as input and output gives developers the most flexibility in creating chains. In addition, passing the most complete object available (i.e., passing the whole SalesOrder instead of just its OrderLines collection) also increases a developer's flexibility in creating chains.

Breaking the Chain
There are at least two problems with these guidelines, however. The first problem is that not all methods can be called anywhere in the chain. The CalculateTotalValue method, for instance, will only return the correct result after all changes have been made to the OrderLines collection. A developer who calls CalculateTotalValue method somewhere else won't get a valid result. The following code, for instance, won't take the OrderLine added by the AddOrderLine method into account when calculating the SalesOrders' TotalValue:

so.RemoveOrderLine(oldProductId).
	  CalculateTotalValue().
          AddOrderLine(newProductId, quantity)

The simplest way to ensure that the CalculateTotalValue method is always called at the end of the chain is to have it return nothing, effectively making the method act as a "finalizer" for the chain.

The second problem is that not all methods will be able to work with the "most complete" object. For instance, discounts are applied to a single OrderLine, not to the whole collection. To use a SetDiscount method in a fluent way, the API must include a method that returns the single OrderLine that the SetDiscount method applies to. A GetOrderLine method that accepts a product Id and returns the matching OrderLine would allow developers to write code like this:

so.GetOrderLine(productId).
  SetDiscount(discount)

As soon as you have a method return as its output a result different from what other methods are expecting to receive as input, you restrict what methods can be used in the chain. Finalizers are just a more extreme example of how to break a chain.

This becomes an issue when deciding what the SetDiscount method should return. The SetDiscount method could return nothing and act as another finalizer. However, in this business, developers will want to recalculate the value of a SalesOrder after calling SetDiscount, like this:

so.GetOrderLine(productId).
  SetDiscount(discount).
  CalculateTotalValue(
)

That's not going to be possible if the SetDiscount method is a finalizer. Unfortunately, if the SetDiscount method is passed a single OrderLine, the SetDiscount method will probably only be able to pass that OrderLine onto the next member of the chain. That won't support the CalculateTotalValue method, which requires access to the complete SalesOrder.

I don't want to suggest that "breaking the chain" either by including finalizers or returning a "different" result than other methods in your API expect is a bad thing. Having methods that return a different result lets you control what chains the developer using your API can create. There's nothing stopping a developer from simply calling the CalculateTotalValue method in a "non-fluent" way, like this:

so.GetOrderLine(productId).
  SetDiscount(discount)
so.CalculateTotalValue()

While preventing specific chains may not provide developers with as much "fluidity" as they might like, it can simplify the effort required to create your API. You need to decide if the value in providing some "fluidity" is worth the effort.

Restructuring the Objects
However, it turns out that this business would find a fluent way of integrating CalculateTotalValue into a chain with SetDiscount worthwhile: The company has inventory management problems. Because of these problems, it's not unusual for a product a customer has ordered to not be in stock. In those cases, the company replaces the product with an equivalent one, gives the customer a 5 percent discount on the new product, and adds a "free" product to the order (the product isn't quite free: the company is required by law to charge sales tax on the "normal market value" of the product). With a fluent version of CalculateTotalValue that works with SetDiscount, a developer could implement that requirement with code like this:

so.RemoveOrderLine(oldProductId).
  AddOrderLine(ProductIds.GiftProduct, 1).
  AddOrderLine(replacementProductId, quantity).
  GetOrderLine(replacementproductId).
  SetDiscount(.05).
  CalculateTotalValue

If SetDiscount just passes an OrderLine to the next method in the chain, this code won't work. However, rather than concentrating on what you can do with your methods, you can restructure your objects to come up with a fluent solution that will work.

The first step is to give the OrderLine a navigation property that points back to the Order that the OrderLine belongs to:

Public Class OrderLine
  Public Property Quantity As Integer = 0
  Public Property ProductId As String = String.Empty
  Public Property Discount As Decimal = 0
  Public Property Order As SalesOrder = Nothing
End Class

To support this change, some changes are required in the existing methods:

  • The AddOrderLine method will need to be enhanced to set the Order property on each OrderLine.
  • The RemoveOrderLine method will need to set the Order property to Nothing/null as part of removing the OrderLine from the SalesOrder's OrderLines collection.

Fortunately, because your API encapsulates everything necessary to add or remove an OrderLine, developers already using your API won't need to make any changes.

With the Order property in place, the decision about what the SetDiscount method should return is easier to make: The method should return the OrderLine so a developer can use the new Order property to get to the SalesOrder object and call the CalculateTotalValue method. The resulting fluent code looks like this:

so.RemoveOrderLine(oldProductId).
  AddOrderLine(replacementProductId, quantity).
  AddOrderLine(ProductIds.GiftProduct, 1).
  GetOrderLine(replacementProductId).
  SetDiscount(.05).
  Order.
  CalculateTotalOrder()

Revisiting the Design
And there's another question to ask at this point: Will developers find the OrderLine's Order property sufficiently obvious that they'll regard it as helpful? Or would developers be just as happy with the non-fluent solution?

If you do add the Order property, it's worthwhile to reconsider what the AddOrderLine method returns. Earlier, I decided that it would return the whole SalesOrder. However, with the Order property in place, the AddOrderLine method could just return the OrderLine that it adds. With that design in place, adding an OrderLine and setting a discount on it doesn't require a call to the GetOrderLine method:

so.AddOrderLine(productId, quantity).
  SetDiscount(.05)

You'll need to look at all the chains where developers might reasonably expect to use AddOrderLine before deciding what the method should return.

As you've seen, a fluent interface can't be "grown" -- some planning is required. The simplest pattern (just passing the most complete object through the chain) will handle many, but probably not all, of your developers' scenarios. Without planning, you can find yourself with a non-fluent interface because too many methods break the chains that developers would like to build. Not surprisingly, then, designing a fluent interface is an iterative process that may even lead you back to redesigning the original objects if you want to create as fluent an interface as possible.

To put it another way: A fluent interface isn't free. Not only does the design process require time and attention, implementing the API requires some effort. And, of course, once you have implemented that interface, you'll need to both support the requests to extend it and to modify it as your organization changes. Before beginning to develop a fluent API, you'll want to make sure that your interface will save you (or your company) enough time and money down the line to justify the effort in creating and maintaining it.

These last two columns have been an exploration of how to ensure that you're doing the right things with fluent APIs. Next month, I'll show some of the ways you have available to actually implement one.

About the Author

Peter Vogel is a system architect and principal in PH&V Information Services. PH&V provides full-stack consulting from UX design through object modeling to database design. Peter tweets about his VSM columns with the hashtag #vogelarticles. His blog posts on user experience design can be found at http://blog.learningtree.com/tag/ui/.

comments powered by Disqus

Featured

Subscribe on YouTube