Skip to content

Go Structs, Methods & Interfaces: Idiomatic Polymorphism & Composition

Created: 2026-11-07 Last updated: 2026-11-07

Executive Summary: Go rejects the classical object-oriented paradigm. There are no classes, no inheritance hierarchies (extends), no abstract base classes, and no explicit implements declarations. Instead, Go adheres to two foundational systems design principles: Composition over Inheritance and Implicit Interface Satisfaction (Duck Typing). This guide forms Part 2.2 (Intermediate) of our Go curriculum: mastering struct memory alignment, understanding when to use Value versus Pointer receivers, composing systems through struct embedding, navigating the anatomy of interface values (iface), and avoiding the infamous nil-interface trap.

  • Structs & Hardware Memory Alignment


    Optimize struct byte layouts, prevent invisible memory padding waste, and utilize metadata struct tags (json:"...").

  • Value vs. Pointer Receivers


    Learn the definitive rules governing method receivers: state mutation, performance copies, and method set compatibility.

  • Composition via Struct Embedding


    Build modular software without brittle inheritance by promoting fields and methods through anonymous embedded structs.

  • Duck Typing & The Nil Interface Trap


    Understand implicit interface compliance (io.Reader), unpack any with type switches, and master the internal (type, value) interface pair.


1. Structs & Hardware Memory Alignment

A struct is a typed collection of fields stored contiguously in memory. Just like in C, the order in which you declare struct fields directly impacts its physical size in RAM.

The CPU Memory Alignment Rule

CPUs read physical memory in word boundaries (8 bytes on 64-bit architectures). If a 64-bit integer (int64) is placed immediately after an 8-bit byte (uint8), the compiler must insert 7 bytes of invisible padding to align the next field on an 8-byte boundary:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    subgraph BadStruct ["Unoptimized Struct (24 Bytes)"]
        direction LR
        B1["uint8: 1B"] --- P1["Padding: 7B"] --- B2["int64: 8B"] --- B3["uint8: 1B"] --- P2["Padding: 7B"]
    end

    subgraph GoodStruct ["Optimized Struct (16 Bytes) - 33% RAM Saved!"]
        direction LR
        G1["int64: 8B"] --- G2["uint8: 1B"] --- G3["uint8: 1B"] --- G4["Padding: 6B"]
    end
Struct Alignment Demonstration
// Bad layout: 24 bytes in RAM due to interleaved padding
type UnoptimizedNode struct {
    Active bool   // 1 byte  (+ 7 bytes padding)
    Load   int64  // 8 bytes
    Flag   uint8  // 1 byte  (+ 7 bytes padding)
}

// Optimized layout: 16 bytes in RAM (Fields ordered from largest to smallest!)
type OptimizedNode struct {
    Load   int64  // 8 bytes
    Active bool   // 1 byte
    Flag   uint8  // 1 byte  (+ 6 bytes tail padding)
}

Pro Tip: In high-throughput distributed systems allocating millions of structs per minute, ordering struct fields by descending size eliminates gigabytes of wasted heap memory and improves CPU L1/L2 cache locality.


Struct Tags and Metadata

Go allows attaching string metadata to struct fields using backticks. These tags are parsed at runtime by reflection libraries:

~/go_oop/tags.go
type UserRecord struct {
    ID        string `json:"id" validate:"required,uuid"`
    Email     string `json:"email" validate:"required,email"`
    Password  string `json:"-"` // Never serialize to JSON!
    CreatedAt int64  `json:"created_at"`
}

2. Methods: Value Receiver vs. Pointer Receiver

In Go, methods are simply functions declared with a special Receiver parameter placed before the function name:

func (r ReceiverType) MethodName(params) ReturnType { ... }

Understanding whether to use a Value Receiver ((u User)) or a Pointer Receiver ((u *User)) is critical:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    CALL["user.UpdateStatus('ACTIVE') called"]
    TYPE{"Receiver Type?"}
    VAL["Value Receiver: (u User)<br/>Operates on a SHALLOW COPY<br/>Original struct UNTOUCHED"]
    PTR["Pointer Receiver: (u *User)<br/>Operates on ORIGINAL MEMORY<br/>Mutates state directly"]

    CALL --> TYPE
    TYPE -->|"func (u User)"| VAL
    TYPE -->|"func (u *User)"| PTR

The Definitive Decision Matrix

Requirement Recommended Receiver Technical Reason
Method must mutate the struct Pointer (*T) Value receiver only mutates a discarded copy on the stack
Struct contains synchronization locks (sync.Mutex) Pointer (*T) Mutexes must never be copied; copying breaks lock state
Struct is large (e.g. $> 64$ bytes) Pointer (*T) Passing pointers avoids expensive memory copy overhead
Small, immutable value (e.g. time.Time, Point)| **Value (T`) Cheap to copy on stack, eliminates GC heap escape
Consistency Rule Pointer (*T) If any method of a type requires *T, declare all methods on *T
~/go_oop/receivers.go
package main

import "fmt"

type ServiceNode struct {
    Host  string
    Ping  int
}

// 1. Value Receiver: Cannot mutate caller's state
func (s ServiceNode) WithZeroPing() ServiceNode {
    s.Ping = 0
    return s // Must return copy to observe change
}

// 2. Pointer Receiver: Directly mutates caller's memory
func (s *ServiceNode) UpdateLatency(ms int) {
    s.Ping = ms // Mutates the real struct!
}

func main() {
    node := ServiceNode{Host: "srv-01", Ping: 45}

    node.UpdateLatency(12)
    fmt.Printf("Latency updated: %d ms\n", node.Ping) // 12 ms
}

3. Composition Over Inheritance (Struct Embedding)

Go does not have class inheritance. Instead, it supports Composition via Struct Embedding. By declaring an anonymous field of another struct type, the outer struct automatically "promotes" the inner struct's fields and methods:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    BASE["BaseModel Struct<br/>ID: string<br/>CreatedAt: time.Time<br/>func Touch()"]
    SUB["UserAccount Struct<br/>BaseModel (Embedded)<br/>Email: string<br/>Role: string"]

    SUB ==>|"Promotes fields & methods"| BASE
~/go_oop/embedding.go
package main

import (
    "fmt"
    "time"
)

type BaseModel struct {
    ID        string
    CreatedAt time.Time
}

func (b *BaseModel) Touch() {
    b.CreatedAt = time.Now()
}

// UserAccount embeds BaseModel anonymously
type UserAccount struct {
    BaseModel // Promoted fields and methods!
    Username  string
    Email     string
}

func main() {
    user := UserAccount{
        BaseModel: BaseModel{ID: "usr-401", CreatedAt: time.Now()},
        Username:  "nhakan",
        Email:     "nejat@example.com",
    }

    // Direct access to promoted fields and methods!
    fmt.Println("User ID:", user.ID) // No need for user.BaseModel.ID!
    user.Touch()                     // Calls user.BaseModel.Touch() directly!
}

4. Interfaces & Implicit Satisfaction (Duck Typing)

In Java or C#, a class must explicitly state class DB implements Storage. In Go, interface satisfaction is 100% implicit:

„If it walks like a duck and quacks like a duck, it’s a duck.“

If a struct implements all the methods defined by an interface, the Go compiler automatically recognizes that the struct satisfies the interface:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    IFACE["Interface: Logger<br/>Log(msg string) error"]
    S1["FileLogger Struct<br/>func (f *FileLogger) Log(msg string) error"]
    S2["ConsoleLogger Struct<br/>func (c *ConsoleLogger) Log(msg string) error"]

    S1 -.->|"Implicitly Satisfies"| IFACE
    S2 -.->|"Implicitly Satisfies"| IFACE

The Interface Segregation Principle

The Go standard library favors small, single-method interfaces. Notice how standard interfaces rarely exceed one or two methods:

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

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

type Closer interface {
    Close() error
}

By designing small interfaces, functions can accept minimal abstractions (e.g. io.Reader), allowing callers to pass files, network sockets, HTTP request bodies, or in-memory byte buffers interchangeably!


The Anatomy of an Interface Value & The nil Trap

Under the hood, an interface value is an iface struct containing two 64-bit pointers: 1. Type Pointer (_type): Points to the concrete type information. 2. Data Pointer (data): Points to the underlying concrete value.

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    IFACE["Interface Variable (iface)<br/>Tab/Type: *MemoryBuffer<br/>Data: 0x0 (nil pointer)"]
    CHECK{"Is iface == nil?"}
    RES["FALSE! (Interface is NOT nil)<br/>Because Type pointer is populated!"]

    IFACE --> CHECK
    CHECK --> RES

The Fatal Nil Interface Trap

An interface is only equal to nil if both its type and its data pointers are nil. If an interface stores a typed pointer whose value happens to be nil, the interface itself is NOT nil:

var buf *bytes.Buffer = nil // buf is a nil pointer
var r io.Reader = buf       // r contains (type: *bytes.Buffer, data: nil)

if r != nil {
    fmt.Println("Surprise: r is NOT nil!") // This branch executes!
    r.Read([]byte{})                      // PANIC: nil pointer dereference!
}
Rule: When returning errors or interfaces, always return an explicit untyped nil rather than a typed nil pointer variable!


5. Hands-On Systems Lab: Pluggable Storage Engine (storage_lab.go)

Let us unify structs, methods, composition, and interfaces into a real-world systems architecture: a pluggable Key-Value storage engine supporting both in-memory RAM storage and disk persistence:

~/go_oop/storage_lab.go
package main

import (
    "errors"
    "fmt"
    "os"
    "path/filepath"
    "sync"
)

// 1. The Core Abstraction Interface
type StorageEngine interface {
    Put(key string, val []byte) error
    Get(key string) ([]byte, error)
    Delete(key string) error
    Close() error
}

var ErrKeyNotFound = errors.New("key does not exist in storage")

// 2. Implementation A: Thread-Safe In-Memory RAM Storage
type MemoryStorage struct {
    mu   sync.RWMutex
    data map[string][]byte
}

func NewMemoryStorage() *MemoryStorage {
    return &MemoryStorage{
        data: make(map[string][]byte),
    }
}

func (m *MemoryStorage) Put(key string, val []byte) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.data[key] = val
    return nil
}

func (m *MemoryStorage) Get(key string) ([]byte, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    val, ok := m.data[key]
    if !ok {
        return nil, ErrKeyNotFound
    }
    return val, nil
}

func (m *MemoryStorage) Delete(key string) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    delete(m.data, key)
    return nil
}

func (m *MemoryStorage) Close() error {
    return nil
}

// 3. Implementation B: Persistent Disk Storage
type DiskStorage struct {
    baseDir string
}

func NewDiskStorage(dir string) (*DiskStorage, error) {
    if err := os.MkdirAll(dir, 0755); err != nil {
        return nil, err
    }
    return &DiskStorage{baseDir: dir}, nil
}

func (d *DiskStorage) Put(key string, val []byte) error {
    path := filepath.Join(d.baseDir, key)
    return os.WriteFile(path, val, 0644)
}

func (d *DiskStorage) Get(key string) ([]byte, error) {
    path := filepath.Join(d.baseDir, key)
    data, err := os.ReadFile(path)
    if errors.Is(err, os.ErrNotExist) {
        return nil, ErrKeyNotFound
    }
    return data, err
}

func (d *DiskStorage) Delete(key string) error {
    path := filepath.Join(d.baseDir, key)
    err := os.Remove(path)
    if errors.Is(err, os.ErrNotExist) {
        return nil, ErrKeyNotFound
    }
    return err
}

func (d *DiskStorage) Close() error {
    return nil
}

// 4. Consumer Function: Pure Polymorphism via Interface Injection
func runSessionWorkflow(store StorageEngine, sessionID string) {
    fmt.Printf("--- Testing Storage Backend (%T) ---\n", store)

    // Write session token
    token := []byte("secret-jwt-token-998822")
    if err := store.Put(sessionID, token); err != nil {
        fmt.Println("Error writing token:", err)
        return
    }
    fmt.Println("Stored session:", sessionID)

    // Read session token
    data, err := store.Get(sessionID)
    if err != nil {
        fmt.Println("Error retrieving session:", err)
        return
    }
    fmt.Printf("Retrieved token for %s: %s\n", sessionID, string(data))

    // Delete session token
    if err := store.Delete(sessionID); err != nil {
        fmt.Println("Error deleting session:", err)
        return
    }
    fmt.Println("Session purged successfully.\n")
}

func main() {
    // A. Test in-memory storage
    memStore := NewMemoryStorage()
    runSessionWorkflow(memStore, "session-mem-101")

    // B. Test disk storage
    diskStore, err := NewDiskStorage("/tmp/go_storage_lab")
    if err != nil {
        panic(err)
    }
    defer os.RemoveAll("/tmp/go_storage_lab")
    runSessionWorkflow(diskStore, "session-disk-202")
}

Compiling and Running the Lab

Executing the Storage Engine Demo
go run storage_lab.go

Output:

--- Testing Storage Backend (*main.MemoryStorage) ---
Stored session: session-mem-101
Retrieved token for session-mem-101: secret-jwt-token-998822
Session purged successfully.

--- Testing Storage Backend (*main.DiskStorage) ---
Stored session: session-disk-202
Retrieved token for session-disk-202: secret-jwt-token-998822
Session purged successfully.


Where to Go Next

Now that structs, method receivers, and polymorphic interface abstractions are mastered, advance to Go's philosophy of fault-tolerant systems:

  • Part 2.3: Robust Error Handling & Resilience


    Master "errors are values", sentinel errors, error wrapping with %w, errors.Is/errors.As, and panic recovery mechanisms.

    Advance to Part 2.3