Erik Straub

Go Project Structure

A lot has been written about organizing Go projects. While there is no “official” Go project layout, there are a few practices that make sense to me and have served me well. This post describes a sort-of minimal amount of organization to help me be productive and organized. It also describes some of the things I like to have around from the start of a project in order to help with some of the everyday development tasks.

These are just my opinions. It is the current state of my brain when it comes to organizing a Go project. I reserve the right to change these opinions later ;) Also, it’s totally fine if you prefer something different. I’d love to hear or read about why you feel differently.

If you’d like to do some homework, here is some light pre-reading on the topic:

The TL;DR of this entire post is that I make use of a cmd directory for housing my main application packages and a pkg directory to contain my library code. That’s it. Simple and to the point. Until the project evolves, this seems like a great base to start from.

Below you can see the general layout of a project directory. We’ll go through each of these files and directories and describe their uses.

├── .dockerignore
├── .editorconfig
├── .gitignore
├── .gitlab-ci.yml
├── .golangci.yml
├── Makefile
├── README.md
├── cmd
│   └── main.go
├── deployments
│   ├── Dockerfile
│   └── docker-compose.yml
├── go.mod
├── go.sum
└── pkg
    ├── config
    │   └── config.go
    ├── db
    │   ├── db.go
    │   └── db_test.go
    ├── handler
    │   ├── handler.go
    │   └── handler_test.go
    ├── mailer
    │   ├── mailer.go
    │   └── mailer_test.go
    ├── router
    │   ├── router.go
    │   └── router_test.go
    ├── submission
    │   └── submission.go
    └── templs
        ├── layout.templ
        ├── layout_templ.go
        └── types.go

Root-level Files

.dockerignore

This file contains references to anything I do not want copied into containers when using the COPY command inside a Dockerfile

Typically this includes secrets, local/development-only configuration, random binaries, and any other files not directly responsible for building the app into a container.

.editorconfig

EditorConfig is helpful for maintaining consistent code style across different editors and IDEs.

.gitignore

There’s no strict guide here, but essentially, avoid checking in binaries, zips, certs, locally generated things that you are not specifically vendoring, code editor or IDE-specific directories and configuration files (think .vscode or .idea). There are of course exceptions to these loosely defined rules, but until exceptions present themselves, these are decent guidelines to follow.

Generally, it is GitHub’s Go.gitignore template and adding a few things like:

.vscode
.idea

/bin
coverage.html
/test-reports
*.tar
*.zip
.DS_Store

.gitlab-ci.yml

This is configuration for GitLab CI/CD pipelines. Typically I will want to run tests, lint code, build binaries and/or containers, and push an image on each pushed commit to GitLab.

Swap this with your CI/CD automation of choice.

.golangci.yml

I use golangci-lint to aggregate the various linters that are used to check the code.

The general configuration I use:

run:
  modules-download-mode: readonly
  skip-files:
    - ".*\\.pb\\.go$"
linters:
  fast: true
  enable:
  - errcheck
  - godot
  - gosimple
  - govet
  - ineffassign
  - staticcheck
  - typecheck
  - unused
  - whitespace
  - revive

I usually ignore linting files generated by external tools as I’ve found that some generators don’t create code that necessarily aligns with how strict I like to be for commenting or handling errors. There are sometimes other quirks when applying these broad rules on code that is generated by external tooling.

Makefile

The Makefile is made up of targets that you can think of as individual development tasks that are made up of commands that might have many arguments or flags, or be dependent upon previously run commands. Think the Makefile as a list of more convenient and easy to remember commands.

For example, think about building a binary. In local development, we may be able to get away with simply running go build main.go, but can we do the same in a CI job? Are there additional compile-time flags you want to use? Would your development binary be able to run in a container or someone else’s development machine? Perhaps, without additional input.

You may want to start with a simple run target defined like so:

.PHONY: run
run:
    go run ./cmd

A more complex example of a build target:

BRANCH?=$(shell git rev-parse --abbrev-ref HEAD)
SHA:=$(shell git rev-parse --short HEAD)

GOOS?=$(shell go env GOOS)
GOARCH?=$(shell go env GOARCH)

BINDIR:=./bin
BINARY_PATH ?= ${BINDIR}/${GOOS}-${GOARCH}
BINARY_NAME ?= app

AWS_ACCT ?= XXXXXXXXXX
AWS_REGION ?= us-east-1
IMG_NAME ?= mybinary
DOCKER_REPO ?= $(AWS_ACCT).dkr.ecr.$(AWS_REGION).amazonaws.com/$(IMG_NAME)
DOCKER_BRANCH = $(subst /,-,$(BRANCH))
DOCKER_TAG ?= $(DOCKER_BRANCH)-$(SHA)
DOCKER_IMG ?= $(DOCKER_REPO):$(DOCKER_TAG)

.PHONY: build
build: ## Build the go binaries
	GOOS=${GOOS} GOARCH=${GOARCH} go build -o $(BINARY_PATH)/$(BINARY_NAME) -ldflags "-X 'main.version=${DOCKER_TAG}' -X 'main.desc=branch:${BRANCH},commit:${SHA}'" ./cmd
endif

With this make build command, I have a lot more control over how the binary is made, mainly via environment variables. I have sensible defaults for ensuring the build command works as expected in local development. It builds a binary for our local OS and architecture and puts it in our ./bin directory at the root of the project.

In CI, I am probably going to want to be explicit about the OS/architecture for the resulting binary. In this case I simply define the appropriate environment variables and can use the same make build that I would locally.

This is obviously a very involved example. Make it super simple until otherwise needed.

The same sort of ideas carry over into other tasks as well. Running tests, generating code from protobuf files, linting code, building and pushing application containers to a repository - the list goes on.

README.md

The README should contain a high-level overview of the project, instructions on fully setting up a development environment, and any other information about how to interact with the code repository. In other words, it’s an instruction manual for someone who is brand new to the repo. It should contain everything they need to know about the development, build, and deploy experience.

go.mod / go.sum

Do people write Go without modules anymore? These should be straightforward, but if not:

The go.mod file defines the module import path, the minimum version of Go required to create the module, and dependency requirements with version locks.

The go.sum file contains the checksum of the dependencies along with the version. It’s used to confirm that none of the dependencies have been modified.

Root-level Directories

bin

The bin directory is where local builds will be output. The majority of the time I simply have a single binary there after running my go build... command.

A more complex example would be using additional directories that are named by the OS and architecture of the binary files that they contain.

In the example below, you can see that there are two binaries. One is for MacOS on Apple Silicon and the other is for 64-bit Linux.

./bin
├── darwin-arm64
│   └── app
└── linux-amd64
    └── app

I like to include the bin directory itself in Git, but not its contents.

cmd

The cmd directory contains the main application(s) for this project.

For simple projects, I’ll probably just have a single file in there called main.go.

In more complex situations where I have more than one binary that is built within a project, I use a separate directory for each application. The name should match the name of the executable you want to have.

In the example below, I have a server and a cli application.

./cmd
└── server
│   └── main.go
└── cli
    └── main.go

deployments

I tend to keep any IaaS, PaaS, system and container orchestration related files, deployment configurations and templates, and build/deploy scripts here. Basically anything outside of the code itself that would be needed to run the code elsewhere.

I’ve worked on a bunch of different projects that are deployed and configured in many different ways. Generally, a root-level deployments directory would contain my Dockerfiles, bash scripts (useful when Make isn’t enough, container entrypoints, or there are complex one-off tasks), configuration files for deployments, sidecar containers, etc.

Sometimes I keep environment-specific configuration here. Sometimes it’s defined elsewhere. Depends on the project and what the infrastructure is like.

No secrets, though. Never, never, ever commit secrets to your repo. The thought sends chills down my spine. Please let me sleep at night and take proper care of your important information. Your SREs appreciate it as well.

pkg

Normally, a pkg directory would only contain library code that is OK to be used by external applications and anything private to this repository would live in an internal directory.

In order to simplify the project structure, I ususally start by only using the lone pkg directory to group Go library code in one place. It helps to not clutter our root directory and really limits the places you need to look around for code you’re importing in other places.

Again, simple until it needs to be otherwise.

Post-Viewing

Did you think you were done? If you’ve made it this far, you’re either quite passionate about making software, slightly out of your mind, quite bored, or some combination of all of the above. Either way, if you really want to press on, there are a ton of others who have opinions on the topic as well.

Conclusion

To me, this is the minimal amount of ceremony to be productive and organized in a small-to-medium sized Go project. I don’t take a hard line on any of these opinions. If I’m working with others, chances are they also have opinions and I’d rather get things done than die on the mole hill I’ve described above.

Also, I get this isn’t the sexiest topic in the world, but I appreciate you sticking around and reading through. I’m hoping that by forcing myself to write about some of the things I find myself doing on a daily basis, it will help me realize whether or not I’m doing them for good reasons.

Reply to this post by email ↪