Go With Examples: Keeping Your Modules Compatible

Ng Tze Yang
Aug 9, 2020 · 6 min read

TLDR;

  • When extending a function, NEVER change the function signature
  • Make sure you have thought about the future uses before exposing an Interface to public, because you’re NEVER gonna change that
  • There’s Backward Compatibility (code can compile) and Behaviour Compatibility (code acts the same way) to consider

Background

In this world, we have two kinds of repositories:

  1. Application Repositories — repositories that is only used & seen by you and your team that contains the business logic of your App
  2. Shared Libraries — Repositories that are used by many other application repositories (think any standard library, SQL package, Redis, any open source pkg etc…)

Because many application repositories rely so heavily on Shared Libraries, we need to make extra care when writing them to 1) maintain readability and most importantly, 2) ensure backward compatibility.

This blog post hopes to provide junior devs with an illustration of the concepts taught in the Golang blog’s latest post: https://blog.golang.org/module-compatibility. I will first show simple examples of the patterns discussed and also point to some examples already illustrating these points.

Isn’t she Go-regeous?

Part 1: Extending Functions

Business requirements always change. More often than not, requirements are added and increase complexity to our code.

How can we add more parameters to existing functions without breaking backwards compatibility for callers already using the function?

Simple Example

Let’s say you have a coffee machine that makes plain black coffee.

func MakeCoffee(){
fmt.Println("coffee!")
}

How can we extend the function to add sugar to our coffee?

Bad — adding variadic arguments

https://play.golang.org/p/clVdxH5N78Efunc MakeCoffee(withSugar ...bool){
if withSugar != nil && withSugar[0]{
fmt.Println("coffee with sugar!")
} else {
fmt.Println("coffee!")
}
}

While this simple example may work in practice, there are many reasons why we should not do this:

  1. It is only extensible once, we cannot extend it again with another variadic argument.
  2. Call compatibility != backward compatibility. By changing the function signature we will always break backward compatibility shown here:
https://play.golang.org/p/WAK_RL4Ze0vfunc main(){
var coffeeFunc func() = MakeCoffee //compile err
coffeeFunc()
}

Good — create a new function

A better idea is to create a new function to implement the more complex logic. We can then change the implementation of the first function with the default false option.

https://play.golang.org/p/55zmgeaoolFfunc MakeGoodCoffee(withSugar bool) {
if withSugar {
fmt.Println("coffee with sugar!")
} else {
fmt.Println("coffee!")
}
}
func MakeCoffee(){
MakeGoodCoffee(false)
}

Better — plan for extension from the start

As a developer, if you know that more complicated logic may come in the future, it would be useful to design your functions to accommodate the changes easily with the following examples

(a) Use a config struct

https://play.golang.org/p/7NnZHUE16Bxtype CoffeeConfig struct{
WithSugar bool
}
func MakeCoffee(config *CoffeeConfig){
if config != nil && config.WithSugar{
fmt.Println("coffee with sugar!")
return
}
fmt.Println("coffee!")
}

With a config struct, you can continually add options for more complicated logic into the config without breaking backward compatibility.

A problem with this is that we have to consider the default option if a nil object is passed into function (see playground).

But Wait!

There is an edge case where changing the config struct will break compatibility, if we are performing comparisons on structs. Here’s what Golang says:

Struct values are comparable if all their fields are comparable. Two struct values are equal if their corresponding non-blank fields are equal.

- Golang Reference Specification

In other words, if you add on a slice, map or function field into a struct, we will not be able to perform a comparison on the struct. Here’s an example:

https://play.golang.org/p/j533RPDkOSo//OK!
type ComparableStruct struct {
i int
s string
b bool
}
//Unable to perform == or != operation on this struct
type UncomparableStruct struct {
i int
s string
b bool
v []int
}

This is a rarer case but it is still important to keep in mind!

(b) Make config struct a method receiver

https://play.golang.org/p/W5TepucGkm0type BeverageMaker interface{
MakeBeverage()
}
type CoffeeOrder struct{
WithSugar bool
}
func (order *CoffeeOrder) MakeBeverage(){
if order.WithSugar{
fmt.Println("coffee with sugar!")
return
}
fmt.Println("coffee!")
}

Unlike (a), this allows more flexibility in the larger application context if we are dealing with interfaces (in this case, DrinkDispenser) but at the cost of more difficult readability. Both (a) and (b) are useful in their own specific situations.

(c) Functional options pattern

You could also use the functional options to extend your function too. Click on this webpage if you are unfamiliar with the pattern. Also while you are on your detour, search for decorator patterns too! It’s very similar to what we’ve been discussing thus far.

https://play.golang.org/p/8BFPMGpeo_Qtype CoffeeOptions func()stringfunc WithSugar()string{
return "with sugar"
}
func MakeBeverage(options ...CoffeeOptions){
coffee := "coffee"
for _,opt := range options{
coffee += opt()
}
fmt.Println(coffee)
}

Problem with this is that with many options to specify, your function call grows really long like this:

Coffee := coffee.MakeBeverage(coffee.WithSugar(), coffee.WithIce(), coffee.WithCream(), coffee.WithMilk(), coffee.WithStraw(), …)

It also adds more lines and complexity into your package. Not something you need for a simple cup of coffee.

Part 2: Extending Interfaces

Now on to extending interfaces. It should be obvious that adding a new method directly to an existing interface will break compatibility.

In our coffee example, let’s say we now want our coffee stirred:

https://play.golang.org/p/9pdm1CshkMntype BeverageMaker interface{
MakeBeverage()
StirBeverage() //BAD
}
//no longer implements DrinkDispenser interface
type CoffeeShop struct{
CoffeeMaker BeverageMaker
}
func (shop *CoffeeShop) Serve(){
shop.CoffeeMaker.MakeBeverage()
}

OK — Create new interface, dynamically check against new interface

We can fix the problem with the following steps:

  1. Create a new interface, BeverageStirrer
  2. In our original Serve function, dynamically check if our CoffeeMaker can stir
  3. If it can, stir the drink! else, never mind
https://play.golang.org/p/uuHDA7vo8Pstype BeverageMaker interface{
MakeBeverage()
}
//New interface
type BeverageStirrer interface{
StirBeverage()
}
type CoffeeShop struct{
CoffeeMaker BeverageMaker
}
func (shop *CoffeeShop) Serve(){
shop.CoffeeMaker.MakeBeverage()
//only stir if your coffeeMaker has the ability to do so!
if stirrer, ok := shop.CoffeeMaker.(BeverageStirrer); ok{
stirrer.StirBeverage()
}
}

Better — Don’t use an interface if you don’t need to

It is very difficult to change interfaces once implemented. Unless you are confident in the future uses of your interfaces, it would be better to stick with concrete interfaces.

https://play.golang.org/p/xSvTRvFWjfqtype SimpleCoffeeShop struct{
CoffeeMaker SimpleCoffeeMaker
}
//this simple coffee maker only able to make beverages
func (shop *SimpleCoffeeMaker) Serve(){
shop.CoffeeMaker.MakeBeverage()
}
//TODO: implement supporting function
//SimpleCoffeeMaker.MakeBeverage()
type LuxuryCoffeeShop struct{
CoffeeMaker LuxuryCoffeeMaker
}
func (shop *LuxuryCoffeeShop) Serve(){
shop.CoffeeMaker.MakeBeverage()
shop.CoffeeMaker.StirBeverage()
}
//TODO: implement supporting function
//LuxuryCoffeeMaker.MakeBeverage() and
//LuxuryCoffeeMaker.StirBeverage()

As you can see, the downside to this is that the code size increases to a larger extend than using interfaces. But on the good side, less complexity + more clarity!

Even Better — force embedding with private methods

If you have an interface that you don’t want others implementing, such as if you are still in early stages of developing your interface, you can add any private method to the interface. Doing so will force others to embed your own implementations and are unable to create their own implementations!

https://play.golang.org/p/1FKMJcvnix2type BeverageMaker interface{
MakeBeverage()
private()
}
//BeverageMaker Embedded into CoffeeShop
type CoffeeShop struct{
BeverageMaker
}
//Only CoffeeMaker allowed to be embedded into CoffeeShop
type CoffeeMaker struct{}
func (cm *CoffeeMaker) MakeBeverage(){
fmt.Println("Make Coffee!")
}
func (cm *CoffeeMaker) private(){
//nil fn
}

Because the signature of coffee.private() cannot be replicated outside of the coffee package, users cannot write their own implementations of BeverageMaker. Thus they are forced to embed a the CoffeeMaker object into a CoffeeShop, or any other implementations you have written in the coffee package

Behaviour Breaking Changes:

In all of the above example we discussed backwards compatibility on your functions and interfaces (changes that cause code to fail compilation). Even if user code is able to compile, your new implementation may have behavioural breaking changes, when results of the function call do not align with the user’s previous expectation.

The Golang blog has a nice example of this. Typically, users expect json.Decoder to ignore fields in the JSON that is not in the argument struct.

https://play.golang.org/p/CQx6XShpew-const jsonStream = `
{"Name": "Ed", "Text": "Knock knock.", "Email": "abc@gmail.com"}
`type Message struct {
Name, Text string
}
...after decoding...fmt.Printf("%+v", message) // {Name:Ed Text:Knock knock.}//opt in to new logic
dec.DisallowUnknownFields()
fmt.Printf("%+v", message) // err: json: unknown field "Email"

With new changes, they wanted to return an error if there were such fields. What they ended up doing was you had to opt-in to the new behaviour by calling DisallowUnknownFields(). Otherwise, behaviour will default back to the original.

Conclusion:

With more devs using your library, comes great responsibility. Be extra careful when writing code that other people use!

Do your best when thinking about Compatibility, Simplicity and Readability.

Doing so will save us all a ton of effort and time!

The Startup

Get smarter at building your thing. Join The Startup’s +787K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Ng Tze Yang

Written by

Core Server Software Engineer @ Shopee

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Ng Tze Yang

Written by

Core Server Software Engineer @ Shopee

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store