One year of writing software in Go

Pranjal Datta
6 min readJust now

--

Source: https://go.dev/blog/gopher

A year ago, I started writing my first professional project in Go. Until then, I had never written a single line of this programming language. Most of my prior work has always been in Python and Java. This article summarises my experience with the language, peppered with some opinions here and there. However, this is not an opinion and judgement on the language. This is also not a guide on how to learn Go.

So here we go.

Syntax and Go’s simplicity

Go prides itself on being simple. You can pick up the language syntax, idioms and gotchas within a few days at best and a week at worst. If you are coming from a dynamically typed PL background (like Python in my case), it might take you slightly longer, but if you are coming from a statically typed PL background, like Java, skilling up in Go would be a breeze.

This brings me to my second point. It’s very easy to mistake Go’s simplicity for a weakness. Coming from a Python background, I was missing the three ways to set up your environment, the multiple coding standards and packages that did a lot. (Where are my sync and async methods?) At some level, I felt that the language’s simplicity would be the bottleneck in solving the problem(s) at hand.

In hindsight, I was so wrong. Go’s extraordinary power is its simplicity and ease of use. I would highly recommend this talk by Rob Pike, who covers why and how Go is simple.

The fact that Go is simple doesn’t stop you from solving complex problems. The correct way of writing Go code is to think simply and break complex chunks into simpler chunks.

Some of the nastiest tech debt that I introduced in my Go project in the earlier days was because I brought my Python way of thinking into the project.

Consistency

There’s one way of formatting Go code; the specification comes from the language itself (`go fmt`). No matter what codebase we are talking about, they look the same — no debates about camel-case vs snake-case, tabs vs spaces, whitespaces, etc.

There’s also only one way to manage Go packages and projects. Project dependencies are specified in go.mod. Packages are added to a project via `go install`, and that’s it.

I usually work on both Go and Python projects, and every time I use Pip or Conda, I miss Go’s package management.

Also, in a corporate setup, you are more often than not using proprietary packages. In Python, you have two options to accomplish this: you either vendor in the package or host your internal registry. In Go, you can install it straight from your internal Git repository. It makes life so much more enjoyable!

Errors should always be returned as values

This was the first time in my life that I came across the concept of returning errors as values. I love this concept so much that I have a hot take.

Hot take: Try Catches should be illegal.

Returning errors as values tell the programmer exactly which function can error and which cannot. This explicitly forces you to handle errors but also gives you the freedom to decide how to handle the error.

Due to a constraint violation, the DB layer failed to insert a row into the table. Check the error value and return an HTTP status code to the user. Did the CLI get an invalid option? Log the error and return a non-zero exit code.

You can neatly design your package to return from a list of possible errors that the caller can check.

The package bar defines a list of possible errors that any public function can return.

package bar
var (
ErrBadRequest = errors.New("bad_request")
ErrInternal = errors.New("internal_error")
)
func DoSomething() (string, err) {
return "", errInternal
}

On the other hand, the caller package foo can check the error type and handle it appropriately.

package foo
func main() {
s, err := bar.DoSomething()
if errors.Is(err, bar.ErrBadRequest) {
// Do something
}else if errors.Is(err, bar.ErrInternal) {
// Do something else
} else {
// Do a third thing, maybe panic?
}
}

Compare this to the Python equivalent.

def main():
try:
s = bar.do_something()
except Exception as e:
if isinstance(e, bar.ErrBadRequest):
# do something.

This is so much cleaner than the Python equivalent. More importantly, it is easier for a new co-worker to understand. In the Python world, the only way to know whether or not a function can error is by relying on the documentation, which, if we are honest, can be a hit or miss.

I also understand that this is a matter of taste. But my taste in this is strong.

Interfaces and lack of inheritance

Another shocker was the lack of inheritance. As someone whose education, undergrad and otherwise, revolved around OOPs and inheritance, I struggled to grasp the why behind interfaces and why one should accept interfaces and returns structs.

But it is the better way to program. Consumer functions define the interfaces they use, and producers return concrete types.

For example, let’s say we have a function, WriteData, in our service layer that attempts to create a new record in your data storage layer. Note that this layer can change from time to time — today, it is Postgres; tomorrow, it can be Mongo; the day after, it can be MySQL.

WriteData accepts an interface StoreWriter that defines the relevant methods. The function internally doesn’t care who or what type implemented that method as long as the type has defined methods that satisfy the interface.

type StoreWriter interface {
InsertNewData(b []byte) error
}
func WriteData(s StoreWriter) error {
// some other stuff
return s.InsertNewData(b)
}

Another great example is the Marshaller / Unmarshaller interface. Go’s standard library has the encoding/json package, the primary way Go projects convert structs to JSON and vice versa. The reliance on a pre-defined interface makes introducing custom marshalling and unmarshalling behaviour super easy. Simply add an Unmarshaland a Marshalmethod onto your struct that defines the custom behaviour. Another remarkable aspect is that nested structs can have their own custom marshalling and unmarshalling logic!

Writing unit tests with mocking becomes easy when your functions and methods accept interfaces.

I recommend going through the Go Tour’s slides on interfaces.

Concurrency: Goroutines and channels

Ah, concurrency! This is the highlight of articles and videos praising Go. While Go excels at concurrency — especially compared to Python — the key takeaway is the contrasting synchronisation approach.

Go recommends sharing memory and synchronising concurrent processes by passing messages via channels. Note that the standard library also implements a locking mechanism exposed via the sync package, should a use-case demand it.

A lot has been said over the internet about Go’s concurrency, and it’s not productive to rehash all that again. From my experience, the whole message-passing construct is fantastic, simple and easy to reason about and maintain.

Some closing thoughts

Philosophically, programming languages represent the worldview of their creators and long-term maintainers. They are the culmination of a million decisions made by people in the project, each driven by some implicit and explicit factors, often not apparent to a casual user.

Go, above all, is a professional tool. It is designed and expected to make building software more efficient. It is a dead simple language with an extensive standard library and good enough DX.

Like all good and reliable tools, it stops being the concern and fades into the background. There is no bickering about ten different formatting styles (like JS and Python) or a steep learning curve with an almost academic rigour (like Rust).

As a language, Go often doesn’t inspire a lot of hot debate, and that’s a good thing. It’s a good, reliable tool that gets the job done. It also helps by making coding really enjoyable.

--

--

Pranjal Datta

Doing MLE, Backend, and Infra software things. Trying to get better at writing too. ML @PixxelSpace