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. 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.
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 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 Organization Checklist
– 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
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. 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!
