This is the second article in my clean code series. You can read the first part here.
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
- Method modifies state
- Struct contains mutex (otherwise it will be copied!)
- Large struct (avoid copying)
- 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
- Always use constructors for structs with invariants
- Be consistent with receivers within a type
- Prefer composition over inheritance (which doesn't exist)
- Embedding is not inheritance, it's delegation
- Protect concurrent access with a mutex or channels
- 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!
