- Use `internal/` for private business logic - Define interfaces where you use them, not where you implement - Keep import graph flat and unidirectional - Organize by features, not layers (user/, not controllers/) - Start with a monolith, split when needed - No utils/helpers/common packages - be specific - One package = one clear responsibility- Use `internal/` for private business logic - Define interfaces where you use them, not where you implement - Keep import graph flat and unidirectional - Organize by features, not layers (user/, not controllers/) - Start with a monolith, split when needed - No utils/helpers/common packages - be specific - One package = one clear responsibility

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

2025/11/28 04:12

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/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!

\

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.

You May Also Like

USD/CHF rises on US dollar rebound, weak Swiss economic data

USD/CHF rises on US dollar rebound, weak Swiss economic data

The post USD/CHF rises on US dollar rebound, weak Swiss economic data appeared on BitcoinEthereumNews.com. USD/CHF trades slightly higher on Friday, around 0.8060, up 0.15% at the time of writing. The pair remains on track for a weekly gain, supported by the persistent weakness of the US Dollar (USD) amid growing expectations of interest rate cuts by the Federal Reserve (Fed). The US Dollar Index (DXY) is heading toward its worst weekly performance since July, despite a modest rebound on Friday driven by firmer US Treasury yields. Investors continue to price in substantial monetary easing over the next 12 months. According to the CME FedWatch tool, the chance of a 25-basis-point cut at the December meeting now stands at 85%, compared with less than 40% one month ago. This dynamic is reinforced by dovish comments from several Fed officials and this week’s soft US Retail Sales data. Speculation within the National Economic Council (NEC), suggesting that Kevin Hassett may emerge as the leading candidate to replace Jerome Powell in May, also fuels expectations of a prolonged easing cycle through 2026. In this context, US Dollar rallies are likely to remain contained unless the macroeconomic backdrop shifts meaningfully. In Switzerland, the Swiss Franc (CHF) lacks momentum following economic indicators that came in well below expectations. Swiss Gross Domestic Product (GDP) contracted 0.5% (QoQ) in Q3, below the 0.4% contraction consensus and after a revision of the previous quarter to 0.2%. Growth YoY slowed to 0.5%, far below the previously reported 1.3%. The only positive signal came from the KOF Leading Indicator, which improved to 101.7 from 101.03, slightly above consensus. Still, the data confirms a slowdown in the Swiss economy, reinforcing expectations that the Swiss National Bank (SNB) may keep its policy rate at 0.00% potentially through 2027, according to several analysts. Overall, the environment continues to favour USD/CHF upside, although the pair remains sensitive to…
Share
BitcoinEthereumNews2025/11/28 22:04