This is the fourth article in the "Clean Code in Go" series.
Previous Parts:
- Clean Code: Functions and Error Handling in Go: From Chaos to Clarity [Part 1]
- Clean Code in Go (Part 2): Structs, Methods, and Composition Over Inheritance
- Clean Code: Interfaces in Go - Why Small Is Beautiful [Part 3]
Why Import Cycles Hurt
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.
Common package organization mistakes I've seen:
- Circular dependencies attempted: ~35% of large Go projects
- Everything in one package: ~25% of small projects
- Utils/helpers/common packages: ~60% of codebases
- Wrong interface placement: ~70% of packages
- 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.
Anatomy of a Good Package
Package Name = Purpose
// 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
Project Structure: Flat vs Nested
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
Internal: Private Project Packages
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!
Rule: internal for Business Logic
// 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
}
Dependency Inversion: Interfaces on Consumer Side
Rule: Define Interfaces Where You Use Them
// 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
}
Import Graph: Wide and Flat
Problem: Spaghetti Dependencies
// 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
Solution: Unidirectional Dependencies
// 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
}
Organization: By Feature vs By Layer
By Layers (Traditional MVC)
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
By Features (Domain-Driven)
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
Hybrid Approach
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
Dependency Management: go.mod
Minimal Version Selection (MVS)
// 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/[email protected]
Replace for Local Development
// 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
)
Code Organization Patterns
Pattern: Options in Separate File
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
Pattern: Facade for Complex Packages
// 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
}
Testing and Packages
Test Packages for Black Box Testing
// 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
}
Anti-patterns and How to Avoid Them
Anti-pattern: Models Package for Everything
// 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 {}
Anti-pattern: Leaking Implementation Details
// BAD: package exposes technology
package mysql
type MySQLUserRepository struct {}
// BETTER: hide details
package storage
type UserRepository struct {
db *sql.DB // details hidden inside
}
Practical Tips
1. Start with a monolith— don't split into micropackages immediately
2.internal for all private code— protection from external dependencies
3.Define interfaces at consumer— not at implementation
4.Group by features, not by file types
5. **One package = one responsibility \ 6.Avoid circular dependenciesthrough interfaces
7.Document packages in doc.go
Package Organization Checklist
- Package has clear, specific name
- No circular imports
- Private code in internal
- Interfaces defined at usage site
- Import graph flows top to bottom
- Package solves one problem
- Has doc.go with examples
- Tests in separate test package
Conclusion
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.
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.
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!
