When code fights back
You write a function to save a user. It validates the email, hashes the password, connects to the database, logs the action, and sends a welcome email. It works. Then the product manager asks to change the email provider. You have to touch the database code to swap the email library. You break the password hashing. You spend three hours fixing a regression that shouldn't exist.
This is what happens when code ignores separation of concerns. SOLID principles exist to prevent this pain. SOLID stands for Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These are five rules for keeping code flexible and maintainable. Go doesn't force SOLID on you, but its type system, small interfaces, and package structure make SOLID the path of least resistance.
SOLID in a Go kitchen
Think of a professional kitchen. The chef cooks. The dishwasher cleans. The expo calls orders. If the chef also has to wash dishes, the kitchen slows down. If the dishwasher breaks, the chef can't cook. Good design gives each role one job and lets you swap tools without rebuilding the counter.
Go's interfaces and packages are your modular kitchen tools. You define what a tool does, not how it's built. You combine small tools to build complex systems. When a requirement changes, you swap one tool. You don't rewrite the whole kitchen.
Single Responsibility: One job per type
Single Responsibility means a type or function should have one reason to change. If a struct handles data, validation, persistence, and notifications, it has four reasons to change. A change to the notification logic risks breaking persistence.
Go encourages small types. You split concerns by creating separate structs or functions. This keeps code readable and testable.
// User holds data only. No methods for side effects.
type User struct {
ID int
Name string
Email string
}
// Validator checks rules. It depends on User, not the other way around.
type Validator struct{}
func (v Validator) Check(u User) error {
// Return early on failure. The unhappy path is visible.
if u.Email == "" {
return fmt.Errorf("email is required")
}
return nil
}
// Receiver name matches the type. Use (u *User), not (this *User).
func (u *User) DisplayName() string {
// Pure function. No side effects. Easy to test.
return u.Name
}
Don't pass a *string for simple data. Strings are cheap to pass by value. Use pointers only when you need to mutate the value or when the value is nilable. Trust gofmt to format the code. Argue logic, not formatting.
One struct, one job. If you're adding a method and asking whether it belongs there, the answer is usually no.
Open/Closed: Extend without editing
Open/Closed means code should be open for extension but closed for modification. You should add new behavior without changing existing code. This prevents regressions and keeps stable code stable.
Go achieves this with interfaces. You define an interface for a behavior. Types implement the interface. New types add new behavior without touching the code that uses the interface.
The Go compiler itself follows this principle. The cmd/compile package splits compilation into distinct phases like parsing, type checking, and SSA generation. Each phase operates on ir.Func, a stable abstraction. The dwarfgen and ssa packages depend on ir.Func without knowing about each other. The HTMLWriter in cmd/compile/internal/ir/html.go implements Open/Closed by allowing new visualization phases to be added via WritePhase without modifying existing rendering logic.
// Payer defines behavior. Implementations vary.
type Payer interface {
Pay(ctx context.Context, amount float64) error
}
// StripePayer implements Payer.
type StripePayer struct {
Key string
}
func (s StripePayer) Pay(ctx context.Context, amount float64) error {
// ctx carries cancellation and deadlines.
// Always check ctx.Done() in long operations.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Call Stripe API.
return nil
}
// Checkout depends on Payer, not StripePayer.
// You can swap in PayPalPayer without changing Checkout.
func Checkout(ctx context.Context, p Payer, amount float64) error {
// Accept interfaces, return structs.
// This function accepts an interface and returns an error.
return p.Pay(ctx, amount)
}
The convention is to accept interfaces and return structs. Functions take interfaces to allow flexibility. They return structs to give callers concrete data. This keeps dependencies flowing in one direction.
Design for extension. New features should add code, not change existing code.
Liskov Substitution: Honor the contract
Liskov Substitution means subtypes must be substitutable for their base types. In Go, there is no class inheritance. LSP applies to interfaces. If a type implements an interface, it must fulfill the contract completely.
A type that implements io.Reader must read data without blocking forever or returning errors that violate the contract. If a type panics on valid input, it breaks LSP. The compiler helps enforce this. If you pass a type that doesn't implement all methods, the compiler rejects the program.
// Reader interface from io package.
// Any type implementing Read must honor the contract.
type Reader interface {
Read(p []byte) (n int, err error)
}
// BadReader breaks LSP. It panics instead of returning an error.
type BadReader struct{}
func (b BadReader) Read(p []byte) (int, error) {
// Panicking violates the contract.
// Callers expect errors, not panics.
panic("read failed")
}
The compiler complains with cannot use BadReader as Reader in argument if you try to use a type that doesn't match the interface signature. If you forget to implement a method, you get a missing-method error. These errors save you from runtime surprises.
Interfaces define contracts. Breaking a contract breaks the system.
Interface Segregation: Slice interfaces thin
Interface Segregation means many specific interfaces are better than one general interface. Clients shouldn't depend on methods they don't use. Large interfaces force types to implement methods they don't need, creating coupling.
Go interfaces are small. The standard library uses io.Reader, io.Writer, and io.Closer separately. You can combine them when needed. Keep your interfaces tiny. If a type implements an interface but doesn't use half the methods, the interface is too big.
Public names start with a capital letter. Private names start lowercase. No keywords like public or private. Use _ to discard values intentionally. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors.
// Split large interface into smaller ones.
// Notifier handles emails.
type Notifier interface {
SendEmail(to string, body string) error
}
// Logger handles logs.
type Logger interface {
Log(msg string)
}
// Service depends only on what it needs.
type Service struct {
notifier Notifier
logger Logger
}
// Service doesn't care about SMS or Slack.
// It only uses email and logging.
func (s Service) Process() {
s.logger.Log("processing")
// ...
s.notifier.SendEmail("user@example.com", "done")
}
Slice interfaces thin. Clients should only see methods they use.
Dependency Inversion: Depend on behavior
Dependency Inversion means high-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
In Go, this means passing interfaces to functions and structs. High-level policy depends on low-level details only through interfaces. This makes code testable and flexible. You can inject mocks for testing or swap implementations for different environments.
The compiler uses this too. The cmd/compile/internal/abi/abiutils.go file defines ABIParamResultInfo which depends on abstract ABIConfig rather than concrete architecture implementations. This enables the same ABI logic to work across ARM64, S390X, and x86 targets without duplication.
// UserRepo is an abstraction.
type UserRepo interface {
GetByID(ctx context.Context, id int) (User, error)
Save(ctx context.Context, u User) error
}
// PostgresRepo implements UserRepo.
type PostgresRepo struct {
db *sql.DB
}
func (r PostgresRepo) GetByID(ctx context.Context, id int) (User, error) {
// Query database.
// Handle errors explicitly.
var u User
err := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id=$1", id).Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return User{}, err
}
return u, nil
}
// UserService depends on UserRepo interface.
// It doesn't know about Postgres.
type UserService struct {
repo UserRepo
}
// NewUserService injects the dependency.
func NewUserService(repo UserRepo) UserService {
return UserService{repo: repo}
}
// GetUser uses the repo.
func (s UserService) GetUser(ctx context.Context, id int) (User, error) {
// ctx is always the first parameter.
u, err := s.repo.GetByID(ctx, id)
if err != nil {
return User{}, err
}
return u, nil
}
Context is plumbing. Run it through every long-lived call site. Functions that take a context should respect cancellation and deadlines. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Depend on behavior, not implementation. Your tests will thank you.
Pitfalls and compiler errors
Go developers sometimes overuse interfaces. You don't need an interface for every struct. Start with concrete types. Add interfaces when you need to swap implementations or when multiple types share behavior. The compiler will yell at you with imported and not used if you define an interface and never use it. Or cannot use concrete type as interface type if you forget to implement a method.
Another pitfall is returning interface{} everywhere. This loses type safety. Use generics or specific types when possible. The compiler rejects the program with undefined: pkg if you forget to import a package. Forget to use one and you get imported and not used. These errors keep your code clean.
The worst goroutine bug is the one that never logs. Always handle errors and log failures. If you forget to capture a loop variable in a goroutine, the compiler rejects the program with loop variable i captured by func literal in Go 1.22+. This prevents subtle bugs.
Keep it simple. SOLID is a guide, not a religion. Use it to reduce pain, not add ceremony.
When to use what
Use a single struct with methods when the data and behavior are tightly coupled and won't change independently. Use an interface when you need to swap implementations for testing or when multiple types share a behavior. Use dependency injection when a function needs to work with different backends without knowing their details. Use package-level functions when the operation is stateless and doesn't belong to a specific type. Use composition over inheritance when you need to combine behaviors from multiple sources. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.