In my May/June 2016 article in CODE Magazine, I covered a few key features of F#, and discussed how and why it can be beneficial to consider F# as your primary programming language. In this issue, I'll expand on that, and talk about how we decided to use an F# and microservices architecture at Jet.com.

Who is Jet.com?

I work at Jet.com, which is an e-commerce startup, competing in the US with Amazon.com, and with a main goal of surprising and delighting customers, particularly with regard to our pricing. We use a complicated pricing algorithm that's able to make smart sense of your shopping cart - it combines the items in your cart and ensures that you're purchasing from merchants who are closer, faster, and cheaper, so that they can ship to you in one package. This saves money because we don't break the cart up into many packages that are more convenient to the distributor than to the customer.

From a technology standpoint, we're heavy users of F#, Go, Azure, Kafka, EventStore, and microservices. In fact, Jet.com only launched a year ago and we already have over 700 cloud-based, event-driven, functional microservices in production. Using this architecture, we were able to scale from 30,000 customers in July 2015 to 2.5 million in October 2015, and barely stress our systems.

Microservices are intentionally small, but people argue about how small is small enough.

What are Microservices?

First, let me define what I'm going to be talking about. Over the last year or so, as I've started to read about microservices, I've come to realize that there's no one definition for what they are. Even how large a single microservice should be is a contentious topic, with several different camps arguing for vastly different sizes. One thing we know: Microservices are intentionally small, but how small are they? Some recent guidelines put forth by teams currently using a microservices approach include:

  • Being able to rewrite each service in fewer than six weeks
  • Using DDD, wherein there should be one service per bounded context
  • That each microservice has fewer than 300 lines of code
  • That each microservice is a single function

There are additional metrics that should be considered; some folks have offered up the size of the team that builds and maintains each service as relevant (the two-pizza-team rule), the complexity of the code within the microservice, as well as the intended usage for the service.

Jet.com takes a stance in all of this. We define the 700 microservices that we've successfully put into production as having a couple of important features:

  • It's a short script file with well-defined inputs and outputs.
  • It should be an application of the Single Responsibility Principle, applied at the service level.

If you haven't heard of The Single Responsibility Principle, it means that a microservice should have one, and only one, reason to exist. This doesn't necessarily mean that there should be only one function within the service, only that the microservice should have only one job.

Why Use F#?

Early on in Jet.com's history, we hadn't fully decided yet on either microservices or F#. Our CTO, Mike Hanrahan, had attended one of the early F# conferences in NYC and decided that F# would be a good fit for our pricing engine, and some of the early hires were F# engineers. As the site started to develop and the early engineers had some thoughtful conversations, it became clear that F# fit into more places than the team had originally anticipated. After exploring F# even more, they decided to split and start working on two completely separate solutions, one entirely in C# and one entirely in F#, to compare approaches. Eventually (and obviously), the F# solution won out. Several key reasons drove this choice.

Fewer Lines of Code

In general, F# has significantly fewer lines of code than C#. In some cases, this is merely a reflection of the lack of curly brackets and explicitly written types, but in many cases this is more about the correct use of some standard F# patterns. For example, discriminated unions (covered in my last article). I showed how just a few lines of code can be used to represent a simple object hierarchy in C#. There are other patterns though, such as partial application. You can think of partial application as inheritance for functions. Consider a very simple, if somewhat contrived case: a function that adds two numbers.

let add num1 num2 = num1 + num2

This Add function has a type signature showing that it takes in two integers (num1 and num2), and returns an integer. Look at Figure 1 to see it in action.

Figure 1: The type signature for the Add function.
Figure 1: The type signature for the Add function.

Once you've constructed this Add function, you can think of it as a “base” function. Then, making use of a partial application, it's easy to create additional functions similar to these:

let add1 = add 1
let add2 = add 2

Even though these two functions take no explicit parameters, their type signatures (see Figure 2) show that they still expect the (now unnamed) num2 parameter. The second parameter (num2) is inherited from the original Add function. Just as inheriting a class can save you from repeating several lines of code, inheriting a function can as well.

Figure 2: Type signature for add2 function.
Figure 2: Type signature for add2 function.

Although having fewer lines of code isn't necessarily an end goal in and of itself, of course, it does mean that you'll be able to more easily keep track of several key pieces of code at once. This leads to an increased understanding of the code, which leads directly to fewer bugs. It just makes sense: If you can understand and process more code at once, you'll naturally make fewer mistakes.

Correct Code

F#'s strict typing makes writing correct code easy. With each new line of code that's added, the compiler infers the types for the new code, and then double-checks that these types perfectly fit into your program. If there's a mismatch of types, you'll know at design-time rather than run-time. F# and the other related languages in the ML family of programming languages were originally created to work closely with mathematical theorem-proving software, which has led to a type inference system that can be mathematically proven. There's a saying in the F# community: Once your code compiles, it works. Although that isn't always true because humans can get in the way, once your code compiles, it's passed several rigorous mathematical tests of consistency, and it's much more likely to be correct. Bonus: You don't need to understand the mathematics involved!

There's a saying in the F# community: Once your code compiles, it works.

Cross-Cutting Concerns

What are cross-cutting concerns? Consider issues such as logging and validation, that necessarily touch several parts of the system and often require code to be duplicated across these pieces of the system. There's often no easy way to isolate this code nicely. Perhaps most importantly to Jet.com, we needed a way to handle our cross-cutting concerns. In ASP.NET MVC, this is traditionally done through the use of Action Filters. To apply a filter to a single function (or a controller), you decorate it with the relevant attribute. ASP.NET injects an extra call before your function call to handle authorization. ASP.NET Web API takes a similar approach, using attributes to inject an extra function call before your function is called.

In Jet.com's case, we needed this same ability to handle cross-cutting concerns for services not based on HTTP. Unfortunately, after several attempts, we discovered that this proved extremely difficult to do in a generic way. Once we began to look to F#, we realized that we could handle cross-cutting concerns with no difficulty. Plus, we realized that we could do it in a way that neither polluted the codebase with fabricated interfaces nor required adapting a specific architecture. We simply took a more functional and composable approach. Any filter in our module can be used with functions that have type 'Input > Async<'Output>, and all filters in our module are composable, using our andThen operation, like so:

let compositeFilter = filter1
    |> Filter.andThen filter2

For example, if you have a service that simply echoes its output:

let echoService : Service<string, string> = fun str -> async.Return str

And you have a filter that will print messages before and after a service is run:

let printBeforeAfter:Filter<string, string> = Filter.beforeAfterSync
    (fun _ -> printfn "before")
    (fun _ -> printfn "after")

Then, you can simply compose the two, and the messages print before and after the service is invoked:

let filteredEchoService:Service<string, string>=
    printBeforeAfter
    |> Filter.apply echoService

This is clearly a much simpler, more intuitive, and effective way of handling filters than creating and implementing a series of custom filters for either ASP.NET MVC or ASP.NET Web API.

Why Did Jet.com Choose Microservices?

There are two answers to the question of why Jet.com chose microservices. First, because they're a perfect fit for a functional-based architecture. Second, well, the original team didn't actually choose microservices. They chose F#. They intentionally wrote idiomatic F#, in the form of small, functional scripts with well-defined inputs and outputs. Rather than naming the scripts with titles similar to SkuScript or SkuService, they named them ImportSkus. The team didn't aim for a microservices architecture, and because Jet.com is a new product, they weren't breaking pieces off of a larger monolithic app to work their way into a microservices architecture. The team just woke up one day and realized that they had been naturally building toward one all along.

There has been much discussion around whether to create a microservices architecture from scratch or to create a monolith as your first version and then refactor it into relevant services a few at a time. We've ended up in the first group, but only by accident.

Microservices First

The “microservices-first” folks warn that splitting up a monolithic application is a huge amount of effort, especially if you're splitting one that wasn't designed to be loosely coupled. If your application has messy service boundaries, you'll need to tighten these up first before you can even begin to work on a conversion path for microservices, and that work is best done at the very beginning of the project so that it can be architected in correctly.

If your application has messy service boundaries, you'll need to tighten these up first before you can even begin to work on a conversion path for microservices.

Monolith First

The “monolith-first” camp tends to caution developers about two things. First, that because there are layers of unnecessary complexity that microservices generate, this slows down your project in the early stages, when rapid iteration is the most important. Second, that there is tremendous difficulty in determining, at the beginning of a project, where the natural microservice boundaries should lie. Because of these two concerns, breaking apart an established monolithic application at the now stabilized, natural microservice boundaries becomes a much easier approach.

Benefits of Using a Microservices Architecture

The benefits of using a microservices architecture tend to fall into three main groups: easy scalability, independent releasability, and a more even distribution of complexity.

Easy Scalability

Easy scalability is a matter of scaling a single service as needed. Did we receive a large shipment to the warehouse today, putting the related services and people under heavy load? We can scale only those services without affecting the rest of the system.

Independent Releasability

Although it's possible to release a single service at once, we organize ourselves into teams, and our microservices into groups within our teams. Because we're using F#, each group of microservices is usually within the same Visual Studio solution. When we talk about independent releasability, we usually mean that we'll promote an entire solution of related services - often a group of five to ten - at the same time.

More Even Distribution of Complexity

By a “more even distribution of complexity,” I mean that using microservices generally makes it much more simple to create, maintain, and update your services, but also that it tends to be much more difficult to manage the infrastructure needed to handle all of your services. This is more of a trade-off than a direct benefit, but I argue that it ultimately wins over some especially large projects I've worked on. Think of 46 projects loaded into a single solution that were created by a team of 27 developers over three years, all to be released one dark and stormy evening, if the fates allowed.

As I've mentioned, we happened into the “microservices first” camp. Regardless of which side you choose, it's crucial that you at least have your management story thought through, if not completely determined, right up front before you start to create or break off those services, because of the additional layers of complexity generated with needing to manage everything.

Show Me the Code!

Let's check out an example microservice. I've created one here that performs a price comparison check for a specific product on a made-up “Nile” website.

Inputs and Outputs

I mentioned previously that an important part of the definition of a microservice at Jet.com is having well-defined inputs and outputs. So let's define some F# types to use for the inputs and outputs for this microservice.

You'll need to input some product information, so that you know which product you're comparing. Set up a Product type that has a SKU, an ID, a description of the product, and a cost for a single unit of the item.

type Product = {
    Sku : string
    ProductId : int
    ProductDescription : string
    CostPer : decimal
}

You'll also need a failure type, ProductCheckFailed, that will contain the product ID and a message indicating the type of failure, and containing additional information about the failure.

type PriceCheckFailed = {
    ProductId : int
    Message : string
}

Using these two, you can construct our inputs. In this case, a single Product:

type Input = 
    | Product of Product

Now, you'll need to construct the outputs. In most cases, you'll want a discriminated union. The first potential return type, ProductPriceNile, is the typical successful return. It's a tuple type that returns the original Product information back to you, with a decimal that contains the price on the Nile site. The second, ProductPriceCheckFailed, is the failure case, and contains the PriceCheckFailed type that you created above.

type Output =
    | ProductPriceNile of Product * decimal
    | ProductPriceCheckFailed of PriceCheckFailed

Transforming Inputs into Outputs

Once you know the inputs and outputs, you need to think about how to transform the inputs into the outputs. You next create the transform function. Remember that to use the filters module described above, you want the functions to have type 'Input -> Async<'Output>. In this case, you're constructing a successful case to return, but this is really the meat of the microservice. Here, you might connect out to an API, then process some information, or otherwise do the work of converting from your inputs into your outputs.

let transform input =
    async {
        return Some(ProductPriceNile(
            {Sku="343434";
             ProductId = 17;
             ProductDescription="My amazing product";
             CostPer=1.96M},
        3.96M))
    }

Note that you're also returning an Option type. This is similar to C#'s Nullable, but more powerful in the following ways:

  • Although C#'s nullable can be used on an Integer, a Boolean, or a few other fundamental types, Option is available to use on any type: a string, a custom type, or even a whole function.
  • It's possible to nest Option, and create an Option<Option<string>>.
  • You can use Map, Filter, and other functions to operate on your Option type.
  • Possibly most importantly, in order to use the value that's returned, you must pattern match on the Option type. In doing so, you have a forced built-in null check that can save you from NullReferenceExceptions.

Interpreting the Output

Now that you've obtained output from the transform function, you need to process, interpret, and resolve it. Was it successful or was there a failure? Here's where the Option type comes in handy. The resolve function isn't much more than one large pattern match statement.

The first case that you want to check, Some (Output.ProductPriceNile (e, price)), asks if you received a response that indicates a successful case. A standard result of this would be to write to EventStore or Kafka, in order to make an appropriate update in the expected system.

The second case, Some (Output.ProductPriceCheckFailed e), asks if you received a response that indicates a failure case. Because you'll have access to the ProductPriceCheckFailed information, you log the failure, then check to determine if there's a way to recover. Maybe there was a standard error that you understand and you can attempt a recovery, depending on the information in the contained Message.

Finally, None asks if you received no response at all. In this case, you still might want to log the failure, but you wouldn't have the additional information contained in the Message statement.

let resolve id output =
    match output with
    | Some (Output.ProductPriceNile (e, price)) ->
        async {()} // write to event store
    | Some (Output.ProductPriceCheckFailed e) ->
        async {()} // log, and attempt to recover
    | None -> async.Return () // log failure

By using the Option type here, you're able to handle an additional failure case, the one where you receive no response at all, very naturally.

Consuming the Service

Finally, you need to gather the decoded input and the two functions to send to a consume function that you've created internally. This function will then be called for each event in an event stream to which you're subscribed.

consume (decode Input.Product) resolve interpret

Conclusion

The tools and techniques outlined in this article have introduced you to functional programming and microservices and helped clear up some misconceptions you may have had. Ideally, they've also helped show you a successful, real-world case of functional programming that you can use as an example to help your company get started with microservices in F#. Good luck!