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.

BirdNest Tech Talk
BirdNest Tech Talk
BirdNest Tech Talk
How to Organize a Go Project for Clean, Maintainable Code

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 overview

Code 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.go

Define 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=vendor

Avoid 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/.gitkeep

Project 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 fmt

Executable 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 support

API 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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Godependency managementbest practicesCode Organizationproject-structure
BirdNest Tech Talk
Written by

BirdNest Tech Talk

Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.