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 explicitimplementsdeclarations. 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 infamousnil-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), unpackanywith 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
// 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:
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:
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 |
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
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!
}
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:
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
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.