This is the fourth article in the "Clean Code in Go" series.

Previous Parts:

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!