Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 002 / 100

Setting up a Go project: real-world lessons and examples

Modules, layout and tooling.

Expona Admin6 min read

Getting started with Go for production APIs

When you're building APIs in Go, the first days matter. Getting your project layout, module structure and tooling right from day one saves weeks of refactoring pain later. Setting up a Go project: real-world lessons and examples means understanding modules, layout conventions and the tools that keep your code clean—before you write a single handler function.

We've seen teams skip these fundamentals and pay for it when they're scaling from prototype to production. This guide walks you through the essentials we use across our e-commerce, SaaS and trading systems.

Installing Go and verifying your environment

Start with a clean Go installation. Head to go.dev and download the latest stable release for your operating system. The installation is straightforward on macOS, Linux and Windows; follow the platform-specific instructions and ensure GOPATH and GOBIN are set if you're on an older setup.

Once installed, verify everything is working:

go
go version

You should see output like go version go1.22.0 linux/amd64. This confirms your Go binary is accessible and your environment is configured. If you see "command not found," check your PATH variable and restart your terminal.

We recommend using a version manager like mise or asdf if you're working across multiple projects. This keeps your Go versions pinned per project and prevents "works on my machine" surprises when a teammate is on a different minor version.

Creating your first module with go mod init

Every Go project starts with a module. The module path uniquely identifies your code and defines how imports work. Use go mod init to create the go.mod file that declares this.

Here's the real-world workflow:

go
mkdir api
cd api
go mod init github.com/acme/api

The module path typically follows your VCS (version control system) hosting—GitHub, GitLab, or your internal Git server. If you're building an internal tool, use your company domain: go mod init acme.internal/api works just fine. The path doesn't need to match a real remote until you push; Go just needs it to be unique within your workspace.

After running go mod init, you'll see a go.mod file:

go
module github.com/acme/api

go 1.22

This declares your module name and the minimum Go version. The go.mod file is the single source of truth for dependencies—always commit it to version control.

Now you can run code immediately:

go
go run .

This compiles and executes the package in the current directory. We use this constantly during development, and it beats the classic ./build && ./run cycle from other languages. When you're ready to build a binary for deployment, go build creates an optimized executable.

Structuring your project with cmd/ and internal/

Go doesn't enforce directory structure, but real production systems need conventions. The standard layout we use across trading platforms, ride-hailing backends and SaaS products follows this pattern:

Directory Purpose
cmd/ Executable binaries (entry points)
internal/ Private packages (API, database, domain logic)
pkg/ Public packages (if you're building a library)
go.mod, go.sum Module and dependency metadata
Makefile or Justfile Common tasks (build, test, lint)

Here's a typical API project layout:

go
api/
├── cmd/
│   └── server/
│       └── main.go          # HTTP server entry point
├── internal/
│   ├── api/
│   │   └── handler.go       # HTTP handlers
│   ├── domain/
│   │   └── order.go         # Business logic
│   ├── storage/
│   │   └── postgres.go      # Database layer
│   └── config/
│       └── config.go        # Configuration
├── go.mod
├── go.sum
├── Makefile
└── README.md

The cmd/ directory holds your binary entry points. A typical cmd/server/main.go is minimal—it reads config, initializes dependencies and starts the HTTP server. Nothing else. This keeps your executable lean and testable.

The internal/ directory is Go's way of enforcing privacy. Any package under internal/ can only be imported by code in the same module. This prevents external code from depending on your internal implementation details. We structure it by feature or layer: internal/api/ for HTTP handlers, internal/domain/ for business logic, internal/storage/ for database access. This mirrors how we organize other services in NestJS or Django, and teams find it immediately familiar.

If you're building a reusable library (not just an API), use pkg/ for public packages that other modules can import. Most internal APIs skip this—everything lives in internal/.

Setting up tooling from day one: gofmt, go vet and linters

Code formatting, static analysis and linting catch bugs before review and enforce consistency. Set these up in your first hour, not after 50 commits.

gofmt is Go's built-in formatter. It's opinionated and non-negotiable—the entire ecosystem uses it. Run it automatically in your editor or CI:

go
gofmt -w .

The -w flag rewrites files in place. Every file is formatted the same way, eliminating whitespace debates entirely.

go vet catches common mistakes: unreachable code, struct field mismatches, unused variables. It's lighter than a full linter but catches real bugs:

go
go vet ./...

The ./... pattern means "this package and all sub-packages." Run it during development and in CI.

golangci-lint is the production-grade linter we use in every backend. It combines dozens of linters and is configurable without being overwhelming. Install it once:

go
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

Create a .golangci.yml in your project root:

go
run:
  timeout: 5m
linters:
  enable:
    - errcheck
    - staticcheck
    - unused
    - gosimple
    - ineffassign
output:
  sort-results: true

Run it locally:

go
golangci-lint run ./...

And in CI as a required check. We've caught nil pointer panics, unhandled errors, and dead code this way before they hit production.

Add these to a Makefile so the team runs them consistently:

go
.PHONY: fmt vet lint test build

fmt:
	gofmt -w .

vet:
	go vet ./...

lint:
	golangci-lint run ./...

test:
	go test -v ./...

build:
	go build -o api ./cmd/server

Now make fmt && make vet && make lint is a single command. We run these pre-commit and every developer knows the drill.

Common mistakes

  • Skipping module setup: Using GOPATH workspaces or running code without go.mod is painful. Use modules from the start—they're the default and the right choice.
  • Mixing binaries and libraries: Putting main() in your business logic package defeats Go's visibility model. Always separate binaries in cmd/ and logic in internal/ or pkg/.
  • Ignoring linter output: Disabling linter rules instead of fixing them creates technical debt. Fix the code; the linter is usually right.
  • Inconsistent formatting: Running gofmt manually before commits is fragile. Use pre-commit hooks or editor integration to format on save.
  • Not committing go.sum: Your go.sum file ensures reproducible builds. Always commit it alongside go.mod.

Key takeaways

  • Install Go from go.dev and verify with go version before you start.
  • Initialize every project with go mod init and commit the resulting go.mod and go.sum files.
  • Structure projects with cmd/ for binaries, internal/ for private packages, and pkg/ only if building a library.
  • Enable gofmt, go vet and golangci-lint in your first hour—they prevent bugs and enforce consistency.
  • Automate these checks with a Makefile and run them in CI as required steps before merge.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles