Skip to main content
Preslav Rachev

Photo Credits: Wim Arys

Before we move on, there are three disclaimers I want to start with:

  • These are different from the step functions you might be familiar with from AWS. The name helps explain the idea, but everywhere else in this post, I would mean plain old Go code (functions, structs, errors) whenever I talk about step functions.
  • You won’t see this pattern in virtually 99% of the Go code out there. You won’t see it in most of my code either because I think it is unnecessary for small or mid-sized functions. However, it can really make a difference in that remaining 1%, which is why I thought it would be worth sharing.
  • This pattern is essentially the non-generics version of the generic pipeline I once wrote about. By taking generics out of the way, I hope to make it more appealing to a larger group of Go applications.

The What and the How #

There are good examples of this across popular modern software. Take Kubernetes or frameworks like Flutter, SwiftUI, React, or any piece of software that distinguishes between logic and configuration. All of those have a declarative part (whether a YAML manifest, a JSX file, or a SwiftUI/Flutter view) that dictates what needs to happen. In crucial points, the declarative part delegates to the imperative part to do the actual how (spin containers, draw buttons on the screen, handle a click, etc.). Keeping those separate makes it easier to extend the process without dealing with the technical details all the time.

OK, things became too abstract. Back to Go. As a language, Go falls in the category of imperative languages - it thrives in getting the how done, but the what sometimes remains an afterthought - an exercise for the reader to piece together by reading the code.

A good case in point is the explicit error handling. For the most part, handling errors in Go is less frightening than many newcomers would think. Once you get used to writing Go, error checks blend in and, as I alluded a few weeks ago, become a form of natural documentation about what could possibly go wrong.

The problems begin with longer functions or methods. Suppose you have the following piece of business logic:

var customer Customer
row := db.QueryRow("select * from customers where id = $1", customerID)
if row := row.Err(); err != nil {
	// handle the error
}

err = row.Scan(&customer.id, &customer.name)
if err != nil {
	// handle the error
}

// Good! Now fetch a product
var product Product
// Omiting the rest of the code for brevity ...
// ...

// Perfect! No create an order and store it
order := {
	CustomerID: customerID,
	ProductID: productID,
	ShippingAddress: customer.Address
	Quantity: 10
}
// Omiting the rest of the code for brevity ...
// ...

You don’t need a degree in Computer Science to see that with the added code, seeing the actual steps (the what) - fetching a customer and product and storing an order, becomes obscured by the how - dealing with row scanning and handling errors.

Naturally, one bulletproof way to deal with this is to abstract the repetitive code in functions. Thus, the code above can turn into:

customer, err := store.FindCustomerByID(customerID)
if err != nil {
	// handle the error
}

product, err := store.FindProductByID(productID)
if err != nil {
	// handle the error
}

order := {
	CustomerID: customerID,
	ProductID: productID,
	ShippingAddress: customer.Address
	Quantity: 10
}

err := store.CreateOrder(&order)
if err != nil {
	// handle the error
}

Much better, but we could go even further. What if we could squash all those error checks into a single one that happens at the very end? Our code would then turn into something like this:

steps := CreateOrderSteps {
	customerID: customerID
	productID: productID
	// an initialized tmp attribute to use as intermediate storage
}

err := StepFunc{}.
		Next(steps.FindCustomer).
		Next(steps.FindProduct).
		Next(steps.SaveOrder).
		Do()
if err != nil {
	// handle the error
}

below is a sample implementation of one of the “steps”. The others are analogous.

func (s *steps) FindCustomer() error {
var customer Customer
row := db.QueryRow("select * from customers where id = $1", customerID)
if row := row.Err(); err != nil {
return err
}

    err = row.Scan(&customer.id, &customer.name)
    if err != nil {
    	return err
    }
    
    s.tmp.customer = &customer
    return nil
}

Step Functions #

A step function is nothing more than a simple struct that holds a reference to a list of funcs (the steps), and an error:

type StepFunc struct {
	funcs []func() error
}

The Next method is equally simple:

func (sf *StepFunc) Next(f func() error) *StepFunc {
	sf.funcs = append(sf.funcs, f)

	// return the step func to allow for chaining
	return sf
}

Note that the Next method does not execute the functions but simply appends their references to the funcscollection. To keep things as generic as possible (without using generic type parameters), I found the simplest possible function that could represent a step to be func() error. In case you are already asking yourselves how the function will get the necessary inputs it needs, that’s what the auxiliary stepsstruct was for. The struct will serve as a shared scratch space that each step writes its results to so that it can be read from the next one. Not exactly pretty and, most definitely, not thread-safe, but it should be sufficient for most simple use cases.

The last missing piece of our step function is a way to execute our steps in order, stopping early in case of an error. This is what the Domethod would do:

func (p *StepFunc) Do() error {
	for _, f := range p.funcs {
		if err := f(); err != nil {
			// stop the chain prematurely
			return err
		}
	}
	return nil
}

There you have it - a way to semi-declaratively list the steps that you want to happen, focusing on the happy path of execution without undermining the power of explicit error handling.

I would leave the case with asynchronous execution as an exercise for the reader. We can look at the idea in a follow-up blog post if there is genuine interest.

Have something to say? Join the discussion below 👇

Want to explore instead? Fly with the time capsule 🛸

You may also find these interesting