Our journey to F#: bye-bye fluent syntax

In C#, our preferred way to define a builder is using a fluent syntax. Whether it is setting up the system under test in our specifications by defining how we want external calls to be faked, setting up complex test data or defining the steps to be executed in an operation with automatic compensation in case of an exception.

In F#, this works, but feels weird, because a fluent syntax typically uses classes and/or extension methods and there are a lot of . involved.

So we experimented with a different style to define the above-mentioned things, and we found a way that better matches our taste of simple code (yes, this post is about taste 🙂 ).

Update 2020-07-21: Thanks to Steve Gilham and his comments, I found a simpler solution to write fluent syntaxes in F#. I’ll write about it in a future blog post and leave this one here as it reflects my learning-curve.

A fluent syntax in C#

A fluent syntax in C# looks normally something like this:

var result = Builder
    .Run(() => this.RunFirstThing(firstData))
    .Run(() => this.RunSecondThing(secondData))
    .Save(dataToBeSaved)
    .Execute()

A Builder is used to define the steps (Run and Save) that are afterwards executed by Execute. Internally, the execute method typically wraps the passed actions or functions with some common code, like error handling and logging. Using this builder prevents code from being repeated and still allows a flexible usage.

Modelling the steps with discriminated unions

In F#, we switched to a different design for such builders. We use a discriminated union to model the steps and pass them to a function for execution:

type Data = ...
type Steps =
    | Run of unit -> unit
    | Save of Data

module Runner = 
    let saveData data = ...
    let execute steps =
        for step in steps do
            match step with
            | Run action -> action ()
            | Save data -> saveData data

Runner.execute
    [
        Run (runFirstThing firstData)
        Run (runSecondThing secondData)
        Save dataToBeSaved
    ]

Note that we can specify for every step what data it needs to be executed in the discriminated union case.

Adding error handling and logging

The common functionality is added in the execute function – as it is in C#:

module Runner = 
    let saveData data = ...
    let execute steps =
        logStart ()
        try
            for step in steps do
                match step with
                | Run action -> action ()
                | Save data -> saveData data
            logSuccess ()
        with
        | _ -> 
            logError ()
            rollback ()

Passing dependencies

If we want to implement meaningful logging and rollback function, we need some more data to be passed to the runner:

type Data = ...
type Saver = ...

type Steps =
    | Run of unit -> unit
    | Save of (Saver: Saver*Data)

module Runner = 
    let saveData saver data = ...
    let execute operationId logger sendRollbackMessage steps =
        logger.LogStart operationId
        try
            for step in steps do
                match step with
                | Run action -> action ()
                | Save saver, data -> saveData saver data
            logger.LogSuccess operationId
        with
        | _ as e -> 
            logger.LogError operationId e 
            sendRollbackMessage operationId

Let’s assume that

  • operationId is an unique identifier for the operation executed
  • logger is a dependency that provides the logging functionality and knows where to write the logs
  • sendRollbackMessage is a dependency that writes a message to a service bus that the operation has to be rolled back.
    How we roll back operations in real is a topic of its own.
  • saver is a function that knows how to save the data

Dependencies and data that are shared by most steps are passed directly into the execute function. Dependencies and data used by single steps are better passed to the step itself.

And our F# runner is finished and can be used like this:

Runner.execute
    operationId
    logger
    sendRollbackMessage 
    [
        Run (runFirstThing firstData)
        Run (runSecondThing secondData)
        Save (saver, dataToBeSaved)
    ]

Almost as easy to use as the fluent syntax in C#, but with less syntax noise. I write “almost” because IntelliSense can’t quite help as much as with a fluent syntax. But it reads better in my opinion.

A side note on computation expressions

I found a couple of examples that handle such scenarios with computation expressions and custom operations. We are not using this approach because our runners are nested in async workflows, I find nested computation expressions a pain.

Feedback, please!

You know a better way to achieve the same? Please let me know in the comments!!

Find the next post in this series here.

Find all blog posts about our journey to F# here.

This blog post is made possible with the support of Time Rocket, the product this journey is all about. Take a look (German only).

About the author

Urs Enzler

7 comments

  • Piping with the |> operator is really the F# equivalent of fluent syntax; implemented in that style there’d be a Runner module with a number of functions to start a sequence, run a step and then save off the result, used something like

    Runner.start operationId logger sendRollbackMessage
    |> Runner.run runFirstThing firstData
    |> Runner.run runSecondThing secondData
    |> Runner.save saver dataToBeSaved

  • Thanks for your feedback!
    But how could I execute something at the end of the runner executing (like the `logger.LogSuccess`) or catch exceptions?

  • One way of doing a fluent interface with the logging and error handling is to use a lightweight version of the State monad. It’s just a minor refactoring of the existing code, like

    open System

    // stub types here
    type Data =
    | PlaceHolder of Object

    type Saver = Data -> unit

    type OperationId =
    | Id of Object // placeholder type

    type ILogger =
    abstract LogStart : OperationId -> unit
    abstract LogSuccess : OperationId -> unit
    abstract LogError : OperationId -> Exception -> unit
    // end of stubs

    type RunState =
    | Context of OperationId * ILogger * (OperationId -> unit)
    | Failure // passthrough state

    module Runner =
    let private saveData (saver:Saver) data =
    saver data

    let private execute action runState =
    match runState with
    | Failure -> Failure
    | Context (op, log, rollback) ->
    try
    action ()
    log.LogSuccess op
    runState
    with
    | _ as e ->
    log.LogError op e
    rollback op
    Failure

    let start operationId logger sendRollbackMessage =
    Context (operationId, logger, sendRollbackMessage)

    let run action runState =
    execute action runState

    let save saver data runState =
    execute (fun () -> saveData saver data)

  • Many thanks for the clarification.
    I like your proposal.

    I’m thinking about a different implementation, however:
    Instead of executing the steps right away, I collect them and execute them on the last call to the run (`save` in the example). That’s where the success logging should happen (only once).
    Alternatively, just move the success logging to the `save` function. The “client” won’t forget about it because otherwise, the return value has to be ignored. The second option looks simpler and more flexible to me.

    Thanks again!

  • Yes, that’s another entirely valid way of doing things —

    Runner.start (unit -> RunState) almost as above, but with an extra empty queue in the Context payload;

    Runner.run and Runner.save (RunState -> RunState) just add actions i.e. (unit -> unit functions) to that queue (Runner.save creating a closure as above), returning a Context with that updated queue;

    and a final closing Runner.execute (RunState -> unit) takes the queue and associated data and runs it inside the try/with, and then tidies up, without needing to return anything.

By Urs Enzler

Recent Posts