How to Organize a Go Project for Clean, Maintainable Code
This guide explains why structuring Go code by packages, layers, and standard directories—using conventions like cmd, internal, pkg, and clear interface boundaries—improves readability, testability, and long‑term maintainability while avoiding common pitfalls such as circular dependencies and oversized files.
Package‑level organization
Group related constants, types and functions in the same package to achieve high cohesion. Examples from the standard library: io – I/O operations bytes – byte manipulation strings – string utilities net – networking
Package names should reflect their primary type ( hash provides hashing, sync provides synchronization primitives). Keep each package focused on a single responsibility; if finer granularity is needed, create sub‑packages such as net/http and net/url.
Separate frequently changing code from stable code. The database/sql package illustrates this pattern:
// src/database/sql/sql.go – stable public interface
package sql
type DB struct { /* ... */ }
func Open(driverName, dataSourceName string) (*DB, error)
// src/database/sql/driver/driver.go – driver implementation that may change
package driver
type Driver interface {
Open(name string) (Conn, error)
}Use an internal package to hide implementation details. In net/http the internal transport encoder is defined in internal/chunked.go and imported only by the http package, preventing accidental external dependencies.
Directory structure
A conventional Go project layout improves maintainability and scalability. The following directories are commonly used (reference: https://github.com/golang-standards/project-layout): /cmd – entry points for executable commands /internal – private application and library code /pkg – code that can be imported by external projects /api – API protocol definitions and Swagger docs /web – static assets for web UI /configs – configuration file templates /test – additional integration tests
Examples of real‑world layouts:
project/
├── cmd/ # main executables (e.g., api, worker)
├── internal/ # private business logic, repository, API layer
├── pkg/ # reusable libraries (logger, database)
├── configs/ # configuration files
├── scripts/ # build and deployment scripts
├── vendor/ # vendored dependencies (optional)
├── Makefile # unified build commands
└── README.md # project overviewCode layering
For business applications, a clear three‑layer architecture is recommended:
Presentation layer – handles HTTP/gRPC requests, validation and error handling.
Business logic layer – implements domain rules and transactions.
Data access layer – manages persistence, caching and external service calls.
Decouple layers via interfaces. Example interface definitions in the business layer:
package service
type UserRepository interface {
Save(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
}
type UserService interface {
Create(ctx context.Context, user UserDTO) (*User, error)
Get(ctx context.Context, id string) (*User, error)
}Dependencies flow only from upper to lower layers:
package main
func InitializeUserHandler(db *sql.DB) *api.UserHandler {
// data layer
repo := repository.NewPostgresUserRepo(db)
// business layer depends on data layer
service := service.NewUserService(repo)
// presentation layer depends on business layer
handler := api.NewUserHandler(service)
return handler
}Cross‑layer calls must use DTOs:
package dto
type UserDTO struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
func (r *CreateUserRequest) ToUserDTO() UserDTO {
return UserDTO{Name: r.Name, Email: r.Email, Password: r.Password}
}
func (d UserDTO) ToEntity() *User {
return &User{ID: uuid.New().String(), Name: d.Name, Email: d.Email}
}File organization
Keep each source file under 500 lines to avoid excessive responsibilities and reduce merge conflicts. The Go standard library follows this rule; for example, net/http splits functionality across multiple files such as client.go, server.go, etc. Exceptions like runtime/proc.go (7 000+ lines) are allowed for special cases.
Place related small files together. The encoding/json package groups files like decode.go, encode.go, indent.go, etc.
Test files should reside alongside the code they test and end with _test.go. Example layout for the bytes package:
bytes/
├── buffer.go
├── buffer_test.go
├── bytes.go
├── bytes_test.go
└── example_test.goDefine errors in a single errors.go file when possible. The standard library shows both centralized and per‑file error definitions (e.g., io defines ErrShortWrite, fmt defines wrapError).
Code grouping
Declare package‑level symbols in the order: constants, variables, types, functions. Example from io:
package io
// 1. constants
const (
SeekStart = 0 // relative to file start
SeekCurrent = 1 // relative to current offset
SeekEnd = 2 // relative to file end
)
// 2. variables
var (
ErrShortWrite = errors.New("short write")
ErrShortBuffer = errors.New("short buffer")
)
// 3. types
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
// 4. functions
func Copy(dst Writer, src Reader) (written int64, err error) { return copyBuffer(dst, src, nil) }Group struct fields by business purpose and type, as shown in net/http.Server (configuration strings, time‑related settings, feature flags, function types, internal state).
type Server struct {
// configuration strings
Addr string
ErrorLog *log.Logger
// time‑related settings
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
// feature flags
DisableGeneralOptionsHandler bool
// function types
ConnState func(net.Conn, ConnState)
Handler Handler
// internal state
mu sync.Mutex
listeners map[*net.Listener]struct{}
activeConn map[*conn]struct{}
}Group interface methods by functionality (e.g., ReadWriteSeeker groups read, write, and seek methods). Import statements should be grouped and sorted: standard library, internal packages, third‑party libraries.
import (
// standard library
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"path"
"sync"
"time"
// internal packages
"internal/nettrace"
"internal/timeseries"
)
import (
// standard library
"context"
"fmt"
"time"
// third‑party
"github.com/gin-gonic/gin"
"go.uber.org/zap"
// project internal
"myproject/internal/config"
"myproject/pkg/logger"
)Place related constants in a single const block, e.g., HTTP status codes and method strings.
const (
StatusOK = 200
StatusCreated = 201
// ... other status codes ...
)
const (
MethodGet = "GET"
MethodPost = "POST"
MethodPut = "PUT"
MethodDelete = "DELETE"
)Module dependencies
Manage dependencies with go.mod:
module github.com/mycompany/myproject
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-sql-driver/mysql v1.7.1
go.uber.org/zap v1.26.0
)Regularly audit and update dependencies:
# List upgradable modules
go list -u -m all
# Update all direct and indirect dependencies
go get -u ./...
# Update a specific module
go get github.com/gin-gonic/[email protected]When reproducible builds are required, vendor the critical dependencies:
myproject/
├── vendor/ # copies of required modules
│ └── github.com/...
├── go.mod
└── go.sum
# Create vendor directory
go mod vendor
# Build using vendored code
go build -mod=vendorAvoid circular dependencies. Break cycles by introducing an interface layer:
// user package (no direct import of order)
type User struct { Orders []model.BizOrder }
// model package defines the interface
type BizOrder interface {
GetUser() *user.User
GetAmount() float64
}
// order package implements the concrete type
type Order struct {
User *user.User
Amount float64
}Pin exact versions; never use latest or version ranges such as >=v1.0.0.
// Good practice
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-redis/redis/v8 v8.11.5
)
// Bad practice
require (
github.com/gin-gonic/gin latest
github.com/go-redis/redis/v8
)Code generation
Place generated code in a dedicated directory (e.g., /gen or /pb) and add a generation marker:
// Code generated by "stringer -type=Pill". DO NOT EDIT.
package painkiller
type Pill int
const (
Placebo Pill = iota
Aspirin
Ibuprofen
Paracetamol
)Never edit generated files manually; regenerate them with go generate ./.... Version‑control the generation tools via a tools.go file:
//+build tools
package tools
import (
_ "github.com/golang/mock/mockgen" // v1.6.0
_ "google.golang.org/protobuf/cmd/protoc-gen-go" // v1.28.0
_ "github.com/golang/protobuf/protoc-gen-go"
)Add generated directories and file patterns to .gitignore to keep the repository clean.
# .gitignore
/gen/
/mock/
/pb/
*_string.go
*_mock.go
*.pb.go
!gen/.gitkeep
!mock/.gitkeepProject conventions
A unified project template (see the directory layout above) standardises code organisation. Use a Makefile to encapsulate build, test and clean steps:
.PHONY: all build test clean
BINARY_NAME=myapp
VERSION=$(shell git describe --tags --always)
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}"
all: clean test build
build:
@echo "Building..."
@go build ${LDFLAGS} -o bin/${BINARY_NAME} cmd/api/main.go
test:
@echo "Testing..."
@go test -v -race ./...
clean:
@echo "Cleaning..."
@rm -rf bin/Configuration files should be stored under /configs with separate files for each environment (development, staging, production). Use a consistent format (e.g., YAML) and a central loader.
Adopt a unified logging strategy based on go.uber.org/zap with JSON encoding and a default level of INFO (or WARN for low‑volume services). Example initialisation:
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var log *zap.Logger
func Init(level string) {
cfg := zap.Config{
Level: zap.NewAtomicLevelAt(getLogLevel(level)),
OutputPaths: []string{"stdout"},
ErrorOutputPaths: []string{"stderr"},
Encoding: "json",
EncoderConfig: zap.NewProductionEncoderConfig(),
}
log, _ = cfg.Build()
}Enable log rotation to prevent unbounded disk usage (e.g., using rotatelogs).
func initLog(dir string, size int64, count uint) *rotatelogs.RotateLogs {
pattern := dir + "/proxy.log.%Y%m%d"
w, err := rotatelogs.New(
pattern,
rotatelogs.WithLinkName(dir+"/proxy.log"),
rotatelogs.WithRotationTime(24*time.Hour),
rotatelogs.WithRotationSize(size),
rotatelogs.WithRotationCount(count),
)
if err != nil { panic(err) }
return w
}Define a unified error handling package that provides error constants, a wrapper, and an HTTP middleware to convert panics into JSON error responses.
package errors
import (
"encoding/json"
"net/http"
"runtime/debug"
"github.com/pkg/errors"
"go.uber.org/zap"
)
var (
ErrNotFound = errors.New("resource not found")
ErrInvalid = errors.New("invalid input")
)
func Wrap(err error, msg string) error { return errors.Wrap(err, msg) }
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
func ErrorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
zap.L().Error("panic recovered", zap.Any("error", rec), zap.String("stack", string(debug.Stack())))
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(ErrorResponse{Code: 500, Message: "Internal Server Error"})
}
}()
next.ServeHTTP(w, r)
})
}Documentation organization
Every package should contain a doc.go file that provides an overview, usage notes and examples using Go comment conventions.
/*
Package fmt implements formatted I/O analogous to C's printf and scanf.
# Printing
...
*/
package fmtExecutable examples belong in example_test.go and must start with Example so that go test can run and verify them.
func ExampleErrorf() {
const name, id = "bueller", 17
err := fmt.Errorf("user %q (id %d) not found", name, id)
fmt.Println(err.Error())
// Output: user "bueller" (id 17) not found
}The README.md should briefly describe the project purpose, installation steps, a quick‑start example and main features. The CHANGELOG.md records version history with sections for Added, Changed, Fixed, etc.
# Changelog
## [1.1.0] - 2025-01-13
### Added
- Structured logging support
- Log rotation feature
### Changed
- Optimised JSON serialisation performance
- Updated dependency versions
### Fixed
- Fixed time‑formatting bug
- Resolved race condition in concurrent writes
## [1.0.0] - 2024-12-25
### Added
- Initial release
- Basic logging functionality
- Multi‑level logging supportAPI specifications should be provided in OpenAPI/Swagger format (YAML or JSON). Example snippet:
openapi: 3.0.0
info:
title: Logger API
version: 1.1.0
description: Go logging library API documentation
servers:
- url: http://localhost:8080
paths:
/api/v1/logs:
post:
summary: Create a log entry
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
level:
type: string
enum: [debug, info, warn, error]
message:
type: string
fields:
type: object
additionalProperties:
type: string
required: [level, message]
responses:
"200":
description: Log created successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "ok"
"400":
description: Invalid request parameters
content:
application/json:
schema:
type: object
properties:
code:
type: integer
format: int32
example: 40001
message:
type: string
example: "Invalid level value"
required: [code, message]References
https://github.com/golang-standards/project-layout
https://github.com/robpike/ivy
https://github.com/golang/oscar
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
BirdNest Tech Talk
Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
