Clean Code in Go (Part 2): Structs, Methods, and Composition Over Inheritance

Written by yakovlef | Published 2025/11/09
Tech Story Tags: golang | software-engineering | clean-code | clean-go-functions | go-structs | go-methods-explained | go-composition-vs-inheritance | go-thread-safety-mutex

TLDRGo offers composition through embedding, interfaces without explicit implementation, and clear rules for methods. The difference between fighting the language and flowing with it usually comes down to understanding structs and methods properly.via the TL;DR App

This is the second article in my clean code series. You can read the first part here.

https://hackernoon.com/clean-code-functions-and-error-handling-in-go-from-chaos-to-clarity-part-1?embedable=true

Introduction: Why OOP in Go Isn't What You Think

I've seen hundreds of developers try to write Go like Java, creating inheritance hierarchies that don't exist and fighting the language every step of the way. "Go has no classes!" — the first shock for developers with Java/C# background. The second — "How to live without inheritance?!". Relax, Go offers something better: composition through embedding, interfaces without explicit implementation, and clear rules for methods.

Common struct/method mistakes I've observed:

  • Using value receivers with mutexes: ~25% cause data races
  • Mixing receiver types: ~35% of struct methods
  • Creating getters/setters for everything: ~60% of structs
  • Trying to implement inheritance: ~40% of new Go developers

After 6 years of working with Go, I can say: the difference between fighting the language and flowing with it usually comes down to understanding structs and methods properly.

Receivers: The Go Developer's Main Dilemma

Value vs Pointer Receiver

This is question #1 in interviews and code reviews. Here's a simple rule that covers 90% of cases:

// Value receiver - for immutable methods
func (u User) FullName() string {
    return fmt.Sprintf("%s %s", u.FirstName, u.LastName)
}

// Pointer receiver - when changing state
func (u *User) SetEmail(email string) error {
    if !isValidEmail(email) {
        return ErrInvalidEmail
    }
    u.Email = email
    u.UpdatedAt = time.Now()
    return nil
}

Rules for Choosing a Receiver

type Account struct {
    ID      string
    Balance decimal.Decimal
    mutex   sync.RWMutex
}

// Rule 1: If even one method requires a pointer receiver,
// ALL methods should use pointer receiver (consistency)

// BAD: mixed receivers
func (a Account) GetBalance() decimal.Decimal { // value receiver
    return a.Balance
}

func (a *Account) Deposit(amount decimal.Decimal) { // pointer receiver
    a.Balance = a.Balance.Add(amount)
}

// GOOD: consistent receivers
func (a *Account) GetBalance() decimal.Decimal {
    a.mutex.RLock()
    defer a.mutex.RUnlock()
    return a.Balance
}

func (a *Account) Deposit(amount decimal.Decimal) error {
    if amount.LessThanOrEqual(decimal.Zero) {
        return ErrInvalidAmount
    }
    
    a.mutex.Lock()
    defer a.mutex.Unlock()
    a.Balance = a.Balance.Add(amount)
    return nil
}

When to Use Pointer Receiver

  1. Method modifies state
  2. Struct contains mutex (otherwise it will be copied!)
  3. Large struct (avoid copying)
  4. Consistency (if at least one method requires pointer)
// Struct with mutex ALWAYS pointer receiver
type Cache struct {
    data map[string]interface{}
    mu   sync.RWMutex
}

// DANGEROUS: value receiver copies mutex!
func (c Cache) Get(key string) interface{} { // BUG!
    c.mu.RLock() // Locking a COPY of mutex
    defer c.mu.RUnlock()
    return c.data[key]
}

// CORRECT: pointer receiver
func (c *Cache) Get(key string) interface{} {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

Constructors and Factory Functions

Go doesn't have constructors in the classical sense, but there's the New* idiom:

// BAD: direct struct creation
func main() {
    user := &User{
        ID:    generateID(), // What if we forget?
        Email: "[email protected]",
        // CreatedAt not set!
    }
}

// GOOD: factory function guarantees initialization
func NewUser(email string) (*User, error) {
    if !isValidEmail(email) {
        return nil, ErrInvalidEmail
    }
    
    return &User{
        ID:        generateID(),
        Email:     email,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }, nil
}

Functional Options Pattern

For structs with many optional parameters:

type Server struct {
    host     string
    port     int
    timeout  time.Duration
    maxConns int
    tls      *tls.Config
}

// Option - function that modifies Server
type Option func(*Server)

// Factory functions for options
func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

func WithTLS(config *tls.Config) Option {
    return func(s *Server) {
        s.tls = config
    }
}

func WithMaxConnections(max int) Option {
    return func(s *Server) {
        s.maxConns = max
    }
}

// Constructor accepts required parameters and options
func NewServer(host string, port int, opts ...Option) *Server {
    server := &Server{
        host:     host,
        port:     port,
        timeout:  30 * time.Second, // defaults
        maxConns: 100,
    }
    
    // Apply options
    for _, opt := range opts {
        opt(server)
    }
    
    return server
}

// Usage - reads like prose
server := NewServer("localhost", 8080,
    WithTimeout(60*time.Second),
    WithMaxConnections(1000),
    WithTLS(tlsConfig),
)

Encapsulation Through Naming

Go has no private/public keywords. Instead — the case of the first letter:

type User struct {
    ID        string    // Public field (Exported)
    Email     string    
    password  string    // Private field (Unexported)
    createdAt time.Time // Private
}

// Public method
func (u *User) SetPassword(pwd string) error {
    if len(pwd) < 8 {
        return ErrWeakPassword
    }
    
    hashed, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
    if err != nil {
        return fmt.Errorf("hash password: %w", err)
    }
    
    u.password = string(hashed)
    return nil
}

// Private helper
func (u *User) validatePassword(pwd string) error {
    return bcrypt.CompareHashAndPassword([]byte(u.password), []byte(pwd))
}

// Public method uses private one
func (u *User) Authenticate(pwd string) error {
    if err := u.validatePassword(pwd); err != nil {
        return ErrInvalidCredentials
    }
    return nil
}

Composition Through Embedding

Instead of inheritance, Go offers embedding. This is NOT inheritance, it's composition:

// Base struct
type Person struct {
    FirstName string
    LastName  string
    BirthDate time.Time
}

func (p Person) FullName() string {
    return fmt.Sprintf("%s %s", p.FirstName, p.LastName)
}

func (p Person) Age() int {
    return int(time.Since(p.BirthDate).Hours() / 24 / 365)
}

// Employee embeds Person
type Employee struct {
    Person     // Embedding - NOT inheritance!
    EmployeeID string
    Department string
    Salary     decimal.Decimal
}

// Employee can override Person's methods
func (e Employee) FullName() string {
    return fmt.Sprintf("%s (%s)", e.Person.FullName(), e.EmployeeID)
}

// Usage
emp := Employee{
    Person: Person{
        FirstName: "John",
        LastName:  "Doe",
        BirthDate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
    },
    EmployeeID: "EMP001",
    Department: "Engineering",
}

fmt.Println(emp.FullName())       // John Doe (EMP001) - overridden method
fmt.Println(emp.Age())            // 34 - method from Person
fmt.Println(emp.FirstName)        // John - field from Person

Embedding Interfaces

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

// ReadWriter embeds both interfaces
type ReadWriter interface {
    Reader
    Writer
}

// Struct can embed interfaces for delegation
type LoggedWriter struct {
    Writer // Embed interface
    logger *log.Logger
}

func (w LoggedWriter) Write(p []byte) (n int, err error) {
    n, err = w.Writer.Write(p) // Delegate to embedded Writer
    w.logger.Printf("Wrote %d bytes, err: %v", n, err)
    return n, err
}

// Usage
var buf bytes.Buffer
logged := LoggedWriter{
    Writer: &buf,
    logger: log.New(os.Stdout, "WRITE: ", log.LstdFlags),
}

logged.Write([]byte("Hello, World!"))

Method Chaining (Builder Pattern)

type QueryBuilder struct {
    table   string
    columns []string
    where   []string
    orderBy string
    limit   int
}

// Each method returns *QueryBuilder for chaining
func NewQuery(table string) *QueryBuilder {
    return &QueryBuilder{
        table:   table,
        columns: []string{"*"},
    }
}

func (q *QueryBuilder) Select(columns ...string) *QueryBuilder {
    q.columns = columns
    return q
}

func (q *QueryBuilder) Where(condition string) *QueryBuilder {
    q.where = append(q.where, condition)
    return q
}

func (q *QueryBuilder) OrderBy(column string) *QueryBuilder {
    q.orderBy = column
    return q
}

func (q *QueryBuilder) Limit(n int) *QueryBuilder {
    q.limit = n
    return q
}

func (q *QueryBuilder) Build() string {
    query := fmt.Sprintf("SELECT %s FROM %s", 
        strings.Join(q.columns, ", "), q.table)
    
    if len(q.where) > 0 {
        query += " WHERE " + strings.Join(q.where, " AND ")
    }
    
    if q.orderBy != "" {
        query += " ORDER BY " + q.orderBy
    }
    
    if q.limit > 0 {
        query += fmt.Sprintf(" LIMIT %d", q.limit)
    }
    
    return query
}

// Usage - reads like SQL
query := NewQuery("users").
    Select("id", "name", "email").
    Where("active = true").
    Where("created_at > '2024-01-01'").
    OrderBy("created_at DESC").
    Limit(10).
    Build()

// SELECT id, name, email FROM users WHERE active = true AND created_at > '2024-01-01' ORDER BY created_at DESC LIMIT 10

Thread-Safe Structs

// BAD: race condition
type Counter struct {
    value int
}

func (c *Counter) Inc() {
    c.value++ // Race when accessed concurrently!
}

// GOOD: protected with mutex
type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

// EVEN BETTER: using atomic
type AtomicCounter struct {
    value atomic.Int64
}

func (c *AtomicCounter) Inc() {
    c.value.Add(1)
}

func (c *AtomicCounter) Value() int64 {
    return c.value.Load()
}

Anti-patterns and How to Avoid Them

1. Getters/Setters for All Fields

// BAD: Java-style getters/setters
type User struct {
    name string
    age  int
}

func (u *User) GetName() string { return u.name }
func (u *User) SetName(name string) { u.name = name }
func (u *User) GetAge() int { return u.age }
func (u *User) SetAge(age int) { u.age = age }

// GOOD: export fields or use methods with logic
type User struct {
    Name string
    age  int // private because validation needed
}

func (u *User) SetAge(age int) error {
    if age < 0 || age > 150 {
        return ErrInvalidAge
    }
    u.age = age
    return nil
}

func (u *User) Age() int {
    return u.age
}

2. Huge Structs

// BAD: God Object
type Application struct {
    Config     Config
    Database   *sql.DB
    Cache      *redis.Client
    HTTPServer *http.Server
    GRPCServer *grpc.Server
    Logger     *log.Logger
    Metrics    *prometheus.Registry
    // ... 20 more fields
}

// GOOD: separation of concerns
type App struct {
    config   *Config
    services *Services
    servers  *Servers
}

type Services struct {
    DB    Database
    Cache Cache
    Auth  Authenticator
}

type Servers struct {
    HTTP *HTTPServer
    GRPC *GRPCServer
}

Practical Tips

  1. Always use constructors for structs with invariants
  2. Be consistent with receivers within a type
  3. Prefer composition over inheritance (which doesn't exist)
  4. Embedding is not inheritance, it's delegation
  5. Protect concurrent access with a mutex or channels
  6. Don't create getters/setters without necessity

Struct and Method Checklist

  • Constructor New* for complex initialization
  • Consistent receivers (all pointer or all value)
  • Pointer receiver for structs with a mutex
  • Private fields for encapsulation
  • Embedding instead of inheritance
  • Thread-safety when needed
  • Minimal getters/setters

Conclusion

Structs and methods in Go are an exercise in simplicity. No classes? Great, less complexity. No inheritance? Perfect, the composition is clearer. The key is not to drag patterns from other languages but to use Go idioms.

In the next article, we'll dive into interfaces — the real magic of Go. We'll discuss why small interfaces are better than large ones, what interface satisfaction means, and why "Accept interfaces, return structs" is the golden rule.

How do you handle the transition from OOP languages to Go's composition model? What patterns helped you the most? Share your experience in the comments!


Written by yakovlef | Team Lead | Software Engineer
Published by HackerNoon on 2025/11/09