Go Struct (Callout) Functions
Go struct Callout functions are a powerful way to create highly configurable, easily testable, future proof structs.
This article should provide a brief overview of what struct Callout functions are, how they work, and when they might be useful. I recently answered a question asking about this, and have been seeing this pattern in many projects including the standard library, and I really haven’t seen much written about it.
Struct Functions or “Callout” functions are powerful tool which provide a flexible configurable struct, and allow multiple implementations or functional options. Abstracting to a callout function can future proof a specific slice of functionality by defining a clear relationship between a structure and a functional dependency. Once this functional dependency is defined it allows for different implementations to be provided. It is very similar to hiding a dependency behind an interface but is a much smaller scope, as it is only a single function, instead of an interface with potentially multiple methods.
I have found go struct Callout functions to be a powerful way to introduce a “seam” into a go struct in order to provide multiple implementations. Using a Callout function allows an alternative implementation to be provided during testing.
Consider a car struct that offers an outside temperature dashboard reading.
The outside temperature gauge is a common on dashboards the car “owns” but may not be directly dependent on the state of the car.
The callout function above allows us to offer many different implementations without having to change anything but the
car struct initialization. The following will walk through how a different Callout function implementation can
be configured for a variety of different contexts:
Let’s imagine that we have a car company that offers a built in thermometer as a standard offering.
Now in the public initialization of car the thermometer can be configured and used to get the temp reading.
Now consider that we need to pin down the temp for a test, or to trigger different display scenarios, ie the dash display will show a red background if temp is greater than 100F or a blue background if lower than 32F.
Suppose we’d like to add a cutting-edge cellular based temperature gauge to all new vehicles. Instead of monitoring the temperature using gauges locally the car is now offered with cellular weather monitor, which will reach out and talk to the cellular network.
The car class itself is abstracted from the temperature implementation. Providing a new way to get a temperature requires no changes to the car!
Functional Options is a design pattern which allows an initializer to expose certain configuration of a struct. Dave Cheney provides an awesome outline of it on his blog. Functional Options allow the caller to set properties on a struct. Exposing functionality through a Callout function works very cohesively with
functional options. If
outsideTempF is exposed as an option callers can easily define their own temp functions and pass them in.
Why the struct Callout function level of abstraction?
The examples above configure structures and then just passes one of those structures methods to the
car. We could have just as easily defined functions to handle the thermometer and cell based temp lookups. This article provides an overview of the technical details of single method interfaces vs closure based interfaces. Some
suggestions from the article above:
Some of the benefits of using a struct as an interface implementer are:
The logic could be decomposed into several functions for complex implementations.
A struct can implement different related interfaces: a solution that reinforces cohesion.
A struct could expose other functions, such as a fluent API to build the struct (aka Builder pattern)
A higher-order function could be more elegant if the implementation is straight-forward.
I personally favor structure based encapsulation as I find it more intuitive to hide information inside of a structure over hiding it inside of closures.
The largest caveat when deciding to go with a Callout function is around the level of coupling. Should the Callout functionality be coupled to the struct? Does it truly not require any of the struct state? Should the Callout actually be an interface?
Overall, I’ve really found Callout functions to be a powerful tool for creating testable and extensible go structures, and hope you have fun with experimenting with them too!