Clean Code in Go (Part 4): Package Architecture, Dependency Flow, and Scalability

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. 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/package@v1.2.3

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!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.