This is the fourth article in the "Clean Code in Go" series.
Previous Parts:
I've spent countless hours helping teams untangle circular dependencies in their Go projects. "Can't load package: import cycle not allowed" — if you've seen this error, you know how painful it is to refactor tangled dependencies. Go is merciless: no circular imports, period. And this isn't a bug, it's a feature that forces you to think about architecture. \n \n Common package organization mistakes I've seen: \n - Circular dependencies attempted: ~35% of large Go projects \n - Everything in one package: ~25% of small projects \n - Utils/helpers/common packages: ~60% of codebases \n - Wrong interface placement: ~70% of packages \n - Over-engineering with micropackages: ~30% of projects
After 6 years working with Go and reviewing architecture in projects from startups to enterprise, I've seen projects with perfect package structure and projects where everything imports everything (spoiler: the latter don't live long). Today we'll explore how to organize packages so your project scales without pain and new developers understand the structure at first glance.
// BAD: generic names say nothing package utils package helpers package common package shared package lib // GOOD: name describes purpose package auth // authentication and authorization package storage // storage operations package validator // data validation package mailer // email sending
BAD: Java-style deep nesting /src /main /java /com /company /project /controllers /services /repositories /models # GOOD: Go flat structure /cmd /api # API server entry point /worker # worker entry point /internal # private code /auth # authentication /storage # storage layer /transport # HTTP/gRPC handlers /pkg # public packages /logger # reusable /crypto # crypto utilities
Go 1.4+ has a special `internal` directory whose code is accessible only to the parent package:
\
// Structure: // myproject/ // cmd/api/main.go // internal/ // auth/auth.go // storage/storage.go // pkg/ // client/client.go // cmd/api/main.go - CAN import internal import "myproject/internal/auth" // pkg/client/client.go - CANNOT import internal import "myproject/internal/auth" // compilation error! // Another project - CANNOT import internal import "github.com/you/myproject/internal/auth" // compilation error!
// internal/user/service.go - business logic is hidden package user type Service struct { repo Repository mail Mailer } func NewService(repo Repository, mail Mailer) *Service { return &Service{repo: repo, mail: mail} } func (s *Service) Register(email, password string) (*User, error) { // validation if err := validateEmail(email); err != nil { return nil, fmt.Errorf("invalid email: %w", err) } // check existence if exists, _ := s.repo.EmailExists(email); exists { return nil, ErrEmailTaken } // create user user := &User{ Email: email, Password: hashPassword(password), } if err := s.repo.Save(user); err != nil { return nil, fmt.Errorf("save user: %w", err) } // send welcome email s.mail.SendWelcome(user.Email) return user, nil }
// BAD: interface in implementation package // storage/interface.go package storage type Storage interface { Save(key string, data []byte) error Load(key string) ([]byte, error) } // storage/redis.go type RedisStorage struct { client *redis.Client } func (r *RedisStorage) Save(key string, data []byte) error { /*...*/ } func (r *RedisStorage) Load(key string) ([]byte, error) { /*...*/ } // PROBLEM: service depends on storage // service/user.go package service import "myapp/storage" // dependency on concrete package! type UserService struct { store storage.Storage }
\
// GOOD: interface in usage package // service/user.go package service // Interface defined where it's used type Storage interface { Save(key string, data []byte) error Load(key string) ([]byte, error) } type UserService struct { store Storage // using local interface } // storage/redis.go package storage // RedisStorage automatically satisfies service.Storage type RedisStorage struct { client *redis.Client } func (r *RedisStorage) Save(key string, data []byte) error { /*...*/ } func (r *RedisStorage) Load(key string) ([]byte, error) { /*...*/ } // main.go package main import ( "myapp/service" "myapp/storage" ) func main() { store := storage.NewRedisStorage() svc := service.NewUserService(store) // storage satisfies service.Storage }
// BAD: everyone imports everyone // models imports utils // utils imports config // config imports models // CYCLE! // controllers imports services, models, utils // services imports repositories, models, utils // repositories imports models, database, utils // utils imports... everything
// Application layers (top to bottom) // main // ↓ // transport (HTTP/gRPC handlers) // ↓ // service (business logic) // ↓ // repository (data access) // ↓ // models (data structures) // models/user.go - zero dependencies package models type User struct { ID string Email string Password string } // repository/user.go - depends only on models package repository import "myapp/models" type UserRepository interface { Find(id string) (*models.User, error) Save(user *models.User) error } // service/user.go - depends on models and defines interfaces package service import "myapp/models" type Repository interface { Find(id string) (*models.User, error) Save(user *models.User) error } type Service struct { repo Repository } // transport/http.go - depends on service and models package transport import ( "myapp/models" "myapp/service" ) type Handler struct { svc *service.Service }
project/ /controllers user_controller.go post_controller.go comment_controller.go /services user_service.go post_service.go comment_service.go /repositories user_repository.go post_repository.go comment_repository.go /models user.go post.go comment.go # Problem: changing User requires edits in 4 places
project/ /user handler.go # HTTP handlers service.go # business logic repository.go # database operations user.go # model /post handler.go service.go repository.go post.go /comment handler.go service.go repository.go comment.go # Advantage: all User logic in one place
project/ /cmd /api main.go /internal /user # user feature service.go repository.go /post # post feature service.go repository.go /auth # auth feature jwt.go middleware.go /transport # shared transport layer /http server.go router.go /grpc server.go /storage # shared storage layer postgres.go redis.go /pkg /logger /validator
// go.mod module github.com/yourname/project go 1.21 require ( github.com/gorilla/mux v1.8.0 github.com/lib/pq v1.10.0 github.com/redis/go-redis/v9 v9.0.0 ) // Use specific versions, not latest // BAD: // go get github.com/some/package@latest // GOOD: // go get github.com/some/package@v1.2.3
// go.mod for local development replace github.com/yourname/shared => ../shared // For different environments replace github.com/company/internal-lib => ( github.com/company/internal-lib v1.0.0 // production ../internal-lib // development )
package/ server.go # main logic options.go # configuration options middleware.go # middleware errors.go # custom errors doc.go # package documentation
\
// options.go package server type Option func(*Server) func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(timeout time.Duration) Option { return func(s *Server) { s.timeout = timeout } } // errors.go package server import "errors" var ( ErrServerStopped = errors.New("server stopped") ErrInvalidPort = errors.New("invalid port") ) // doc.go // Package server provides HTTP server implementation. // // Usage: // srv := server.New( // server.WithPort(8080), // server.WithTimeout(30*time.Second), // ) package server
// crypto/facade.go - simple API for complex package package crypto // Simple functions for 90% of use cases func Encrypt(data, password []byte) ([]byte, error) { return defaultCipher.Encrypt(data, password) } func Decrypt(data, password []byte) ([]byte, error) { return defaultCipher.Decrypt(data, password) } // For advanced cases - full access type Cipher struct { algorithm Algorithm mode Mode padding Padding } func NewCipher(opts ...Option) *Cipher { // configuration }
// user.go package user type User struct { Name string age int // private field } // user_test.go - white box (access to private fields) package user func TestUserAge(t *testing.T) { u := User{age: 25} // access to private field // testing } // user_blackbox_test.go - black box package user_test // separate package! import ( "testing" "myapp/user" ) func TestUser(t *testing.T) { u := user.New("John") // only public API // testing }
// BAD: all models in one package package models type User struct {} type Post struct {} type Comment struct {} type Order struct {} type Payment struct {} // 100500 structs... // BETTER: group by domain package user type User struct {} package billing type Order struct {} type Payment struct {}
// BAD: package exposes technology package mysql type MySQLUserRepository struct {} // BETTER: hide details package storage type UserRepository struct { db *sql.DB // details hidden inside }
1. Start with a monolith— don't split into micropackages immediately \n 2.internal for all private code— protection from external dependencies \n 3.Define interfaces at consumer— not at implementation \n 4.Group by features, not by file types \n 5. **One package = one responsibility \ 6. Avoid circular dependenciesthrough interfaces \n 7.Document packages in doc.go
- Package has clear, specific name \n - No circular imports \n - Private code in internal \n - Interfaces defined at usage site \n - Import graph flows top to bottom \n - Package solves one problem \n - Has doc.go with examples \n - Tests in separate test package
Proper package organization is the foundation of a scalable Go project. Flat import graph, clear responsibility boundaries, and Dependency Inversion through interfaces allow project growth without the pain of circular dependencies. \n \n In the final article of the series, we'll discuss concurrency and context — unique Go features that make the language perfect for modern distributed systems. \n \n What's your approach to package organization? Do you prefer organizing by feature or by layer? How do you handle the temptation to create a "utils" package? Let me know in the comments!
\


