Constructors in Go
When I first started working with Go, I wrote a blog post on "bypassing Go's lack of constructors", or initialisers as they're known in Go.
Go doesn't support traditional default constructors, but it does provide constructor-like factory functions useful for initialising types.
This post is effectively a part II where I basically do a 180 on my original idea in the post linked above. But first, why the original post?
Avoiding nil pointers
Go has a lot of strengths as a language, from a simple userland concurrency API to being very quick to set up a microservice communicating over HTTP with only the built-in webserver, which is production ready, or very easily over RPC with other microservices with something like protobuf.
For me, the fact that Go is a strictly typed language is also a strength - you have to be explicit in what you are building and this actually makes your programs easier to read. I'm also starting to see the benefits of only needing small, implicit interfaces defined next to the consumers. This is a big part of SOLID - interface segregation. In fact, my team and I are planning on removing all explicit interfaces from the codebase.
On the flipside, as all languages have, Go has some weaknesses too. One of these is the ability to initialise a struct in an 'invalid state', leading to the potential of accidental nil pointer dereferencing further down the line.
Note how in the above example, the use of Service{} allows the Service to be initialised without dependencies. And there's no valid reason these dependencies should not exist.
This can be mitigated in part through a technique called "making the zero value useful" and involves actually allowing your code to call methods on nil pointers and returning an empty string, or struct, or whatever your return type is. So now if, for some reason, your Service is nil or the repository within it is nil, you just return a default value instead.
So instead of finding out what exactly is wrong with your code at compile-time, a real strength of using a compiled language like Go, you find out not only that something is wrong somewhere in the application, but also that you're not quite sure where or why it is and so debugging throughout the whole stack ensues.
"Useful", it seems, is either in the eye of the beholder or the evangelist; not necessarily in the eye of the software engineer.
Enforcing valid state
The ideal scenario would be to never even have the option of nil pointers for dependencies in the first place, so you never need to write pointless filler code to check if anything that is supposed to exist anyway might be nil. A runtime dependency injector could enforce this, but again this comes with the drawback of losing all those valuable compile-time guarantees. There are no compile-time dependency injectors in Go, only pre-compile-time object graph generators.
Initially in my experiments with Go, I realised that initialisation of structs in a valid state could be enforced at the language level through elaborate use of factory methods, private structs and interfaces, with the factory method returning the interface. Returning an interface is shown in the manual as okay given the case that both the interface and the implementing struct have the exact same methods defined.
With the above, you can no longer write s := Service{} or r := Repository{} because these structs are not exported. The only way to access them is with a call to New(), which returns only the exported interface containing the exact same methods the struct has. An additional point here is that the Service cannot exist without it's non-nillable dependency (we're not allowing a pointer to be passed in here).
This strategy effectively enforced constructor-like semantics one can see in other languages - a single, tightly controlled entrypoint for intialisation that cannot be bypassed (except via the reflect and unsafe packages, which is beyond the scope of this article and likely wouldn't easily sneak past code review anyway).
There were a few drawbacks with this approach. One of them was the introduction of duplicate code because we were creating an explicit interface next to every single struct in the codebase, just to enforce something that should have been in the language by default and may not even happen that regularly. Another drawback was godoc didn't work properly with the methods documented on the interface.
In fact, when using this on a team, it ended up being more trouble than it was worth. Everyone agreed that we had no use for "useful zero values", as the business had no concept for them, and so the only reason we would use "zero values" would be to satisfy the compiler and drawbacks of the language, and would simply consist of 'filler code' - so we opted to leave them out in many cases. This was actually the best outcome for us as we didn't need to care about nils any more unless they were absolutely required and intentional.
What worked best for us
The end result agreed on was: always use the New convention for every single struct. Unfortunately this could not be enforced at the language level, but simple static analysis on CI and a linter could check for initialisation of structs that didn't use a factory method in any file that was not a test, and this ended up providing the best balance for us (tests can use shortcuts when needed).
This approach had four benefits:
- Every single struct had one point of initialisation and validation, so would always be valid once initialised, simply via focussing on New().
- Chances of nil pointers found only at runtime were significantly reduced.
- The "default value" approach would never need to be considered throughout the codebase which removed a lot of the associated cognitive overhead required to work with something that is effectively proprietary in Go.
- New Go developers were able to on-board and work with the codebase faster because they didn't need to understand any unnecessary intricacies.
At the end of the day you should be using a language to bring value and further something. Perfectionism for the sake of it only hinders this progress and being pragmatic when working with programming languages and their drawbacks only gets you where you want to be faster. The extra effort required to try and force the language to do something it couldn't easily handle at the language was an interesting experiment, but that was about it. We solved this with process, agreement and a tiny bit of automation at the CI level to provide us that extra guarantee and that was enough for us.
It has been suggested that I should take a look at Rust next. This language will also have it's drawbacks I'm sure, but perhaps it might align more with the philosophy of 'safety by default' which I'm starting to see real value in, and is something I definitely think should be a goal for many languages to strive towards.
If you're interested in my first blog post where I argue for using the private struct, public interface technique, you can read it here. Otherwise, going forward, using the New convention wherever possible I think is a wiser and more pragmatic decision.