Giving Go a Try
In this post, I'll document my experience building an app in Go, which is a new-to-me language. Keep reading if you want to know more about how I built this, and my first impressions of Go.
If you just want to see the code, hop on over to the repository.
Background
If you know me, or you've read my writing before, you'll know that I'm a Java developer by day, and a Rustacean by night. I've also done some Python here and there as the situation called for, but that's been about all I've worked with in the past 5 years or so. While I don't think Java will ever go away, and I still love Rust, I was itching to try something new.
For a moment, let's rewind back to late 2019, I was working with a small group on a project that I was pretty excited about. It was a Python app that, at the time, we felt had a lot of potential. Unfortunately, however, that project ended up fizzling out, but I still remember one thing from back then. The software we had been building processed near real-time data from multiple feeds, and we were quickly outgrowing Python. We'd been talking for some time about a Go port, because one of our contributors was familiar with Go.
I never got a chance to explore Go back then, and I don't really like leaving things unfinished.
Now that I was looking for something new, I decided to just Go for it.
Deciding What to Build
I remember I had a professor back in undergrad that loved to use the "Lazy developers are good developers" line. That professor would be proud because I got a bit lazy when picking a project.
I fired up a chat session with an LLM and asked for a project plan to learn about Go. Specifically, I requested approximately 8 hours of work, basic but flexible requirements, and some focus areas for my learnings. My plan was to treat this as I would any LLM output: a starting point that I would then workshop with my own human intuition.
The plan that I received was remarkably mundane: a simple TODO app with a CLI. I could have come up with this on my own, sure, but I was still thankful to have some notes to go off of. So there I had my plan, I knew what I was going to build.
I was going to make a simple TODO CLI that stored tasks as a JSON document and had basic CRUD operations - not bad.
The rest of this post details my experience building the application.
Getting Started
I began my Go journey at the docs. There happens to be a conveniently-placed "Get Started" button which links to learning resources.
Two that I found particularly helpful were:
I've always been an experiential learner, so I was thrilled to see that there's two example-based resources to learn from. After a brisk pass through the tour, and a couple of cherry-picked examples, I decided it was time to write some code.
Implementation Experience
NOTE: I've kept the plan docuement that I AI-generated in the repository as a reference. I've deviated from it in my actual implementation, but it still served as a decent scaffold.
Creating the Task struct
The first step of the plan is to define the Task
struct for storing our TODOs.
This sounds perfectly reasonable because this is going to be the primary data type we pass around.
I settled on the following fields:
Id
: a unique integer ID for tasksTitle
: the name of the taskPriority
: an enum denoting the low/medium/high priorityDueDate
: when this task is dueCategory
: an optional user-provided categoryStatus
: an enum denoting pending/complete state
This was my first encounter with Go-specific behaviors.
Enums
Go has no built-in "enum" type.
Instead, enums are just constants with an opaque type
alias.
They also typically use a built-in called iota
which is a language feature more-or-less purpose-built for enums.
Go by Example does a good job of explaining the enum pattern.
Iota
If you click through links from the "Go by Example" link above, you'll undoubtedly end up at the Go blog post on iota
.
The iota
build-in is essentially an incrementing counter that resets inside a new const
declaration block.
The idea is that it's easy to assign incrementing values to enums:
type Priority int
const (
Low Priority = iota // iota -> 0
Medium // iota -> 1
High // iota -> 2
)
It works well for this purpose, but it also has tons of other uses, and my first impression is that it may be too many.
Iota Iteratively Evaluates Expressions
The initial value can be an expression using iota
.
This expression gets re-evaluated on each line, with the incrmented iota
value:
type DoubleX int
const (
Zero DoubleX = 2 * iota // iota -> 2 * 0 = 0
One // iota -> 2 * 1 = 2
Two // iota -> 2 * 2 = 4
)
This is convenient, but it's also a lot of functionality packed into one feature.
Iota - Conclusion
I think that iota
is definitely an interesting convenience feature, but I'm not sure yet how I feel about how clever it allows developers to get.
Field Naming
My first inclination with the Task
struct was to use lower-case names for the fields.
I understood that Go treats these as package-private and though it would be fine.
I was operating under the impression that I'd start with private fields and make them public (i.e. use a TitleCase
name) as needed.
One thing I didn't immediately realize, however, is that Go's built-in json
module would ignore them.
In retrospect, this is a reasonable design decision. You probably want your data objects to make all their meaningful fields public anyway; it also makes sense with how the Go implementation works (more on that later).
JSON Field Names
One potential issue with this approach is that it couples the JSON field name with Go's convention-based access modifier system.
Luckily, there's a way to decouple these.
The json
module also uses something called "struct tags".
These are string literals that can appear next to struct
fields.
Other modules can access these values via reflection.
The json
module has a DSL for controlling the conversion to JSON.
Speaking of JSON marshalling, I really like how the Go standard library makes it easy to define custom output formats for types. Since enums are actually just integers, by default they get written as integer literals in the JSON output. Two easy function implementations can change that:
var priorityToString = map[Priority]string
var stringToPriority = map[string]Priority
func () ([]byte, error)
func (b []byte) error
The Final Task Struct
After all was said and done, I had a Task
struct that:
- has all fields listed above
- uses
camelCase
naming for JSON marshalling - uses custom marshal/unmarshal
string
representation for all enums
It didn't take me very long, and the code was pretty straightforward and fun to write.
I also went ahead and added some tests, because I had been abusing my poor main()
function to manually test.
Creating the Task Manager
The plan also mentioned creating a task manager class for providing the CRUD operations. Based on the generated examples, I came up with a design for the API.
I originally started off with a Manager
struct, but I switched to an interface for a couple of reasons.
My primary reason is that I like test driven development (TDD).
Interfaces also make it quick and easy to make mock dependencies for testing very specfic scenarios.
Here's what I ultimately ended up with:
type Manager interface
The implementation is fairly simple and just uses a slice of Task
and linearly iterates them for list/search.
I could have done something more sophisticated like using maps to store indices, but I kept it simple.
My goal was just to learn Go, not to make the ideal TODO CLI.
There are a couple of things I do think are noteworthy about this design.
The getDueDate
Function Pointer
The design doc examples showed being able to specify strings like "today" and "tomorrow" for the due date.
I decided to capture these cases elsewhere in the code, so instead of having an API to specify "special" dates, the Manager
just takes a function and passes its own now
.
This also makes testing pretty comfy.
Pointers for Status and Priority
The ListTasks
API is kind of interesting because it accepts status/priority for "is equal to"-style filtering.
There isn't a built-in Option
type in Go, so I used pointers because their "zero" value is nil
.
This works really nicely with how struct
s use the zero value for unspecified fields.
It means it I were to, for example, parse a "command" struct from the program arguments, I could leave the unspecified filtering arguments as nil
very easily.
Speaking of, let's talk about the Command
interface.
Creating the Commands
For my CLI commands, I created a very simple interface, it has a single Execute
method which takes a Manager
and returns (string, error)
.
Here's what that looks like in code:
type Command interface
Then, in the same cli.go
file, I stubbed out a top level Parse
function, with this signature:
func Parse(a *[]string) (Command, error)
I also stubbed out individual unexported helpers for parsing the individual commands.
Commands more or less just delegate to Manager
, but I do want to circle back one last time to AddCommand
.
The struct
looks like this:
type AddCommand struct
The IntoDueDate
type is a second interface I made:
type IntoDueDate interface
This is how I manage computing the timestamp for the Manager.Add
API!
Here's an example with DueTomorrow
:
type DueTomorrow struct
func (t time.Time) tasks.DueDate
Once I had all of the commands implemented, wiring everything up was easy!
Wiring it Up
One thing I really like about how this program turned out is just how simple everything is once it's all put together.
The entire main
method is very digestable and doesn't even require scrolling the page on my monitor:
func main()
Just like that, my first Go program was written!
Conclusion
My overall impression of Go is that it's a language written for getting things done quickly. The language feature set is small. Default behaviors are something you should be aware of and rely on. The standard library is robust enough to get you writing code ASAP.
What Go lacks in feature surface, it makes up for in having mostly sensible defaults. That being said, there were some things I found odd or surprising. Here's a couple of highlights of things I liked and things I didn't.
What I Liked
Most defaults are sensible: do you have an int
? Your zero value is 0
.
A string
is empty by default, so there's no need for a dedicated empty-checking function, just compare equality with ""
.
Pointers are nil
by default, so just be careful before trying to derefernce them.
Most things behave the way you'd expect them to by default.
Table driven testing: I said earlier that I love TDD. Table-driven tests are, in my opinion, tragically underutilized. The fact that Go makes them so easy to write is a dream.
Extremely strong standard library: I mentioned earlier that the Go standard libary is really nice.
I never needed to leave the standard library while building this app.
While the app isn't perfect, it is completely usable and didn't take long to build.
I didn't mention earlier, but there's even a text/tabwriter
utility (link) for printing out tables.
I used text/tabwriter
for printing the list
and search
command results.
Other honorable mentions not used in this project include slog
for structured logging and a fully-featured template engine.
That's just plain awesome.
What I Disliked
Date formatting: Go uses an example-based system for date format strings.
Instead of yyyy-MM-dd
, you say 2006-01-02
, and therein lies the problem.
Go by Example has some great, well, examples where you can see this in action.
I stared at this reference time for a long, well, time.
I eventually found out that this SO answer - as it turns out, the Go reference time is 1 2 3 4 5 6 7
in the form month day hour minute second year zone-offset
.
I really do not like this.
It feels like an Easter egg hidden in the standard library.
I don't mind the example-based format, but 1970-01-01 was already a perfectly decent epoch.
No Built-in Equals: Go does not allow types to redefine the behavior of ==
.
You have to write your own Equals
method.
I found this a bit frustrating when writing tests, but luckily there's reflect.DeepEqual
, which uses reflection, so it shouldn't be used in Production code.
I do still find it a bit strange though, given how ergonomic it is to opt-into things like custom JSON marshalling.
This isn't something I'm losing any sleep over, though.
Final Thoughts
The obvious question is whether or not I'd use Go again. In short, yes, I would.
Depsite the small number of things I disliked, there was a lot to like. Go is small and fast, and that simplicity feels really nice. In the past I had a habit of prototyping an idea in Python and then actually implementing it for real in Rust or Java.
With Go, I feel like I can skip the Python step.