A little bit of scope
The Go language was designed with large, complex use cases in mind. My experiences have lead me to believe “large and complex” occurs the moment a codebase has more than one developer working on it. At this point, the code can no longer be a palace dedicated to the creator. It takes on the responsibility to be readable, maintainable, and extendable by another human who thinks differently. The best way to achieve this is to keep the required cognitive load for developers of the codebase low. Programmers do this through interfaces and abstractions. A function call might do thousands of things under the hood, but if a user of the function only needs to understand that it produces
x when given
y, then the function has a low cognitive load requirement. It hides the underlying complexity. This is really the only way to keep a large and complex project from bogging down in its own chaos.
Go has a few tools which help achieve this. The two I have been digging most recently are package exposure and type definitions.
Package exposure is not a Go specific concept. Defining whether a function or type is exposed from a package is seen in most programming languages. If a function or a type is exposed, it is part of the package’s interface. Exposing the minimal amount of things makes the interface small and the cognitive load to use the package is usually lower. In Go, a function or type is exposed if it begins with a capital letter. It is easy to quickly glance through a package and determine if it has a large or small interface.
A pattern I have been trying to follow is only unit testing the exposed functions of a package. This locks in the interface without locking in the implementation. If you feel the need to test an unexposed function, it should probably be exposed in another package.
It is still possible for a package to require a large cognitive load even if it only exposes one function. For example, a function might return an encoded id as a string. It is pretty clear what this returned string is at the line the function is called, but if that string is then passed around to a few other packages, it becomes less and less clear. Another developer might stumble upon it and ask themselves “What is this string? Is it special?”. This is cognitive load.
One way to mitigate this in Go is with type definitions. Type definitions are a quick and cheap way to give more meaning to an existing type limiting its cognitive load.
// UserID in this large and complex system. type UserID string
defining a type for that vague string
UserID is still technically a string from a performace perspective, but developers now know instantly what they are working with and the compiler is leveraged to make sure no misundertandings are introduced. This could be also be achieved by defining a new
struct with a string property, but that is enough code and layers that developers (including me) tend to skip it and just sling the string. Type definitions on the other hand have a great cost to benefit ratio.
Minimizing a packages exposed functions and types + returning defined types limits the cognitive load to use a package effectively.