- 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
9 min read
For feedback or concerns regarding this content, please contact us at crypto.news@mexc.com

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!

\

Market Opportunity
Particl Logo
Particl Price(PART)
$0.1601
$0.1601$0.1601
+0.06%
USD
Particl (PART) Live Price Chart
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 crypto.news@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

American Bitcoin’s $5B Nasdaq Debut Puts Trump-Backed Miner in Crypto Spotlight

American Bitcoin’s $5B Nasdaq Debut Puts Trump-Backed Miner in Crypto Spotlight

The post American Bitcoin’s $5B Nasdaq Debut Puts Trump-Backed Miner in Crypto Spotlight appeared on BitcoinEthereumNews.com. Key Takeaways: American Bitcoin (ABTC) surged nearly 85% on its Nasdaq debut, briefly reaching a $5B valuation. The Trump family, alongside Hut 8 Mining, controls 98% of the newly merged crypto-mining entity. Eric Trump called Bitcoin “modern-day gold,” predicting it could reach $1 million per coin. American Bitcoin, a fast-rising crypto mining firm with strong political and institutional backing, has officially entered Wall Street. After merging with Gryphon Digital Mining, the company made its Nasdaq debut under the ticker ABTC, instantly drawing global attention to both its stock performance and its bold vision for Bitcoin’s future. Read More: Trump-Backed Crypto Firm Eyes Asia for Bold Bitcoin Expansion Nasdaq Debut: An Explosive First Day ABTC’s first day of trading proved as dramatic as expected. Shares surged almost 85% at the open, touching a peak of $14 before settling at lower levels by the close. That initial spike valued the company around $5 billion, positioning it as one of 2025’s most-watched listings. At the last session, ABTC has been trading at $7.28 per share, which is a small positive 2.97% per day. Although the price has decelerated since opening highs, analysts note that the company has been off to a strong start and early investor activity is a hard-to-find feat in a newly-launched crypto mining business. According to market watchers, the listing comes at a time of new momentum in the digital asset markets. With Bitcoin trading above $110,000 this quarter, American Bitcoin’s entry comes at a time when both institutional investors and retail traders are showing heightened interest in exposure to Bitcoin-linked equities. Ownership Structure: Trump Family and Hut 8 at the Helm Its management and ownership set up has increased the visibility of the company. The Trump family and the Canadian mining giant Hut 8 Mining jointly own 98 percent…
Share
BitcoinEthereumNews2025/09/18 01:33
XRP Ledger Stablecoin Supply Jumps 100% Since December

XRP Ledger Stablecoin Supply Jumps 100% Since December

TLDR Stablecoin supply on the XRP Ledger reached $568 million after rising more than 100% since December 2025. The number of wallets holding less than 100 XRP climbed
Share
Coincentral2026/03/24 00:43
Why The Green Bay Packers Must Take The Cleveland Browns Seriously — As Hard As That Might Be

Why The Green Bay Packers Must Take The Cleveland Browns Seriously — As Hard As That Might Be

The post Why The Green Bay Packers Must Take The Cleveland Browns Seriously — As Hard As That Might Be appeared on BitcoinEthereumNews.com. Jordan Love and the Green Bay Packers are off to a 2-0 start. Getty Images The Green Bay Packers are, once again, one of the NFL’s better teams. The Cleveland Browns are, once again, one of the league’s doormats. It’s why unbeaten Green Bay (2-0) is a 8-point favorite at winless Cleveland (0-2) Sunday according to betmgm.com. The money line is also Green Bay -500. Most expect this to be a Packers’ rout, and it very well could be. But Green Bay knows taking anyone in this league for granted can prove costly. “I think if you look at their roster, the paper, who they have on that team, what they can do, they got a lot of talent and things can turn around quickly for them,” Packers safety Xavier McKinney said. “We just got to kind of keep that in mind and know we not just walking into something and they just going to lay down. That’s not what they going to do.” The Browns certainly haven’t laid down on defense. Far from. Cleveland is allowing an NFL-best 191.5 yards per game. The Browns gave up 141 yards to Cincinnati in Week 1, including just seven in the second half, but still lost, 17-16. Cleveland has given up an NFL-best 45.5 rushing yards per game and just 2.1 rushing yards per attempt. “The biggest thing is our defensive line is much, much improved over last year and I think we’ve got back to our personality,” defensive coordinator Jim Schwartz said recently. “When we play our best, our D-line leads us there as our engine.” The Browns rank third in the league in passing defense, allowing just 146.0 yards per game. Cleveland has also gone 30 straight games without allowing a 300-yard passer, the longest active streak in the NFL.…
Share
BitcoinEthereumNews2025/09/18 00:41