Go - Composition

Concept: Embedding in Go does not create a subtyping relationship — a type that embeds another is not a subtype of the embedded type.

Why it matters: Misunderstanding embedding as inheritance leads to designs that break when Go's type system rejects substitution you expected to work.

package main

import "fmt"

type Engine struct{ HP int }

func (e Engine) Start() { fmt.Printf("engine %d HP starting\n", e.HP) }

type Car struct {
	Engine       // composition — not inheritance
	Model string
}

// accepts Engine only — Car does NOT satisfy this parameter
func tune(e Engine) { fmt.Println("tuning engine", e.HP) }

func main() {
	c := Car{Engine: Engine{HP: 200}, Model: "Sedan"}
	c.Start()          // promoted method — works
	tune(c.Engine)     // must extract the embedded value explicitly
	// tune(c)         // compile error: Car is not an Engine
}

Gotcha: Passing an outer type where the inner type is expected — embedding gives you promoted methods, not substitutability.


Concept: Focus on behavior ("what it does") rather than state ("what it is") when designing types and interfaces in Go.

Why it matters: Behavior-based design produces smaller interfaces, fewer dependencies, and types that are easier to test and replace.

package main

import "fmt"

// wrong: designed around what it IS (a database row)
type UserRow struct {
	ID    int
	Name  string
	Email string
}

// right: designed around what it DOES — behavior is the contract
type UserStore interface {
	FindByID(id int) (string, error)  // behavior: can find a user
	Save(name, email string) (int, error) // behavior: can persist a user
}

type MemUserStore struct{ users map[int]string }

func NewMemUserStore() *MemUserStore {
	return &MemUserStore{users: make(map[int]string)}
}

func (s *MemUserStore) FindByID(id int) (string, error) {
	u, ok := s.users[id]
	if !ok {
		return "", fmt.Errorf("user %d not found", id)
	}
	return u, nil
}

func (s *MemUserStore) Save(name, email string) (int, error) {
	id := len(s.users) + 1
	s.users[id] = name
	return id, nil
}

func main() {
	var store UserStore = NewMemUserStore()
	id, _ := store.Save("Harish", "h@example.com")
	name, _ := store.FindByID(id)
	fmt.Println(name)
}

Gotcha: Designing interfaces around struct fields (getters/setters) instead of meaningful actions — the interface ends up exposing implementation rather than behavior.


Concept: Avoid unnecessary abstractions and prioritize duplication over the wrong abstraction.

Why it matters: A wrong abstraction is worse than duplication — it couples unrelated concepts and forces all implementations into a shape that fits none of them well.

package main

import "fmt"

// wrong abstraction: forced to unify two unrelated things under one interface
type Processable interface {
	Process() error // Invoice and EmailNotification have nothing in common
}

// right: keep them separate — duplication is fine when concepts are unrelated
type Invoice struct{ amount float64 }
type EmailNotification struct{ recipient string }

func (inv Invoice) Generate() error {
	fmt.Printf("invoice: $%.2f\n", inv.amount)
	return nil
}

func (n EmailNotification) Send() error {
	fmt.Printf("email to: %s\n", n.recipient)
	return nil
}

func main() {
	inv := Invoice{amount: 99.99}
	notif := EmailNotification{recipient: "h@example.com"}

	// explicit calls — no forced common abstraction
	inv.Generate()
	notif.Send()
}

Gotcha: Unifying two types under an interface just because they both have an error return — shared signature is not shared behavior.


Concept: Grouping types — organizing and categorizing data types based on shared behavior.

Why it matters: Grouping by behavior (interface) instead of type hierarchy produces extensible designs — new types plug in without modifying existing code.

package main

import "fmt"

// grouped by behavior: anything that can be serialized
type Serializable interface {
	Serialize() string
}

type JSONPayload struct{ data string }
type CSVRow struct{ fields []string }
type BinaryBlob struct{ bytes []byte }

func (j JSONPayload) Serialize() string { return `{"data":"` + j.data + `"}` }
func (c CSVRow) Serialize() string {
	result := ""
	for i, f := range c.fields {
		if i > 0 { result += "," }
		result += f
	}
	return result
}
func (b BinaryBlob) Serialize() string { return fmt.Sprintf("blob[%d]", len(b.bytes)) }

func exportAll(items []Serializable) {
	for _, item := range items {
		fmt.Println(item.Serialize())
	}
}

func main() {
	exportAll([]Serializable{
		JSONPayload{data: "hello"},
		CSVRow{fields: []string{"a", "b", "c"}},
		BinaryBlob{bytes: make([]byte, 8)},
	})
}

Gotcha: Adding types to a behavior group that only partially implement the expected behavior — every method in the interface must be meaningful for every implementor.


Concept: Interface pollution — introducing unnecessary interfaces, leading to code complexity and reduced readability.

Why it matters: Every unnecessary interface adds a layer of indirection that forces readers to trace back to concrete types to understand what actually happens.

package main

import "fmt"

// polluted: interface exists for a single implementation — zero benefit
type EmailSenderInterface interface { // unnecessary
	SendEmail(to, subject, body string) error
}

type EmailSender struct{ smtpHost string }

func (e EmailSender) SendEmail(to, subject, body string) error {
	fmt.Printf("sending to %s via %s\n", to, e.smtpHost)
	return nil
}

// clean: use concrete type directly — add interface only when a second impl appears
func notifyUser(sender EmailSender, email string) error {
	return sender.SendEmail(email, "Welcome", "Hello!")
}

func main() {
	sender := EmailSender{smtpHost: "smtp.example.com"}
	notifyUser(sender, "user@example.com")
}

Gotcha: Defining an interface to "make testing easier" before writing any tests — define the interface in the test file when the need is proven.


Concept: Start with concrete implementations and discover interfaces through refactoring.

Why it matters: Interfaces discovered from real usage fit exactly — pre-designed interfaces almost always have the wrong shape or wrong granularity.

package main

import "fmt"

// step 1: start concrete — solve the problem first
type S3Storage struct{ bucket string }

func (s S3Storage) Upload(key string, data []byte) error {
	fmt.Printf("s3://%s/%s (%d bytes)\n", s.bucket, key, len(data))
	return nil
}

func (s S3Storage) Download(key string) ([]byte, error) {
	return []byte("content"), nil
}

// step 2: second implementation appears — NOW extract the interface
type LocalStorage struct{ dir string }

func (l LocalStorage) Upload(key string, data []byte) error {
	fmt.Printf("local://%s/%s (%d bytes)\n", l.dir, key, len(data))
	return nil
}

func (l LocalStorage) Download(key string) ([]byte, error) {
	return []byte("local content"), nil
}

// interface discovered from two real implementations — exact fit
type ObjectStorage interface {
	Upload(key string, data []byte) error
	Download(key string) ([]byte, error)
}

func backup(store ObjectStorage, key string, data []byte) error {
	return store.Upload(key, data)
}

func main() {
	backup(S3Storage{bucket: "prod"}, "db.sql", []byte("data"))
	backup(LocalStorage{dir: "/tmp"}, "db.sql", []byte("data"))
}

Gotcha: Extracting an interface after only one implementation — you're guessing at the shape; wait for the second real implementation to confirm it.


Concept: Write code in testable layers, each focused on a specific aspect of the problem.

Why it matters: Layered code can be tested layer by layer in isolation — a single monolithic function can only be tested end-to-end.

package main

import "fmt"

// layer 1: pure data transformation — no I/O, fully testable
func parseCSVLine(line string) []string {
	// simplified: real impl would handle quotes, escapes
	result := []string{}
	field := ""
	for _, c := range line {
		if c == ',' {
			result = append(result, field)
			field = ""
		} else {
			field += string(c)
		}
	}
	return append(result, field)
}

// layer 2: business logic — depends on layer 1, testable with known inputs
func buildRecord(fields []string) (map[string]string, error) {
	if len(fields) < 2 {
		return nil, fmt.Errorf("buildRecord: need at least 2 fields")
	}
	return map[string]string{"name": fields[0], "email": fields[1]}, nil
}

// layer 3: orchestration — wires layers together; tested via integration test
func processLine(line string) (map[string]string, error) {
	fields := parseCSVLine(line)
	return buildRecord(fields)
}

func main() {
	record, err := processLine("Harish,h@example.com")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(record)
}

Gotcha: Mixing I/O into business logic layers — a function that reads from a file AND transforms data can't be unit tested without a real file.


Concept: Decoupling allows for flexibility and adaptability to future changes by focusing on behavior rather than concrete types.

Why it matters: Decoupled code lets you swap implementations (real vs mock, v1 vs v2) without touching the business logic that uses them.

package main

import (
	"fmt"
	"time"
)

// behavior contract — business logic depends only on this
type Clock interface {
	Now() time.Time
}

// production implementation
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }

// test implementation — controllable, deterministic
type FixedClock struct{ t time.Time }
func (f FixedClock) Now() time.Time { return f.t }

type Session struct {
	clock     Clock
	createdAt time.Time
}

func NewSession(clock Clock) *Session {
	return &Session{clock: clock, createdAt: clock.Now()}
}

func (s *Session) Age() time.Duration {
	return s.clock.Now().Sub(s.createdAt)
}

func main() {
	fixed := FixedClock{t: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
	sess := NewSession(fixed)
	// deterministic in tests — no real time dependency
	fmt.Println(sess.Age()) // 0s — created and checked at same fixed time
}

Gotcha: Injecting time.Now as a func() time.Time parameter instead of a Clock interface — function variables work but lose the ability to group related time operations.


Concept: Interface assignments involve copying the concrete data if type information is compatible.

Why it matters: Understanding what gets copied during interface assignment explains why value-type interface values are independent and pointer-type ones are shared.

package main

import "fmt"

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ }
func (c Counter) Value() int { return c.n }

type Incrementer interface{ Inc() }
type Valuer interface{ Value() int }

func main() {
	c := &Counter{}

	// interface assignment: copies the pointer — both interface vars share same Counter
	var inc Incrementer = c
	var val Valuer = c

	inc.Inc()
	inc.Inc()

	// val and inc both hold the same pointer — mutation is visible through val
	fmt.Println(val.Value()) // 2 — shared state through pointer in interface
}

Gotcha: Assigning a value (not pointer) to an interface — the interface stores a copy of the value; mutations through one interface don't affect the original.


Concept: The compiler ensures type integrity during interface assignments — preventing assignments that violate the defined behavior.

Why it matters: Compile-time interface checking catches mismatches before any test runs — the compiler is your first and fastest test.

package main

import "fmt"

type Saver interface {
	Save(data string) error
}

type Loader interface {
	Load(id int) (string, error)
}

// compile-time assertions — zero runtime cost, immediate feedback
type FileSaver struct{ path string }

func (f FileSaver) Save(data string) error {
	fmt.Printf("saving to %s\n", f.path)
	return nil
}

// static interface check: if FileSaver stops implementing Saver, compile fails here
var _ Saver = FileSaver{}

// this would fail to compile if FileSaver doesn't implement Loader:
// var _ Loader = FileSaver{}  // compile error: missing Load method

func main() {
	var s Saver = FileSaver{path: "/tmp/data"}
	s.Save("hello")
}

Gotcha: Only discovering interface satisfaction failure at the call site — use var _ Interface = Type{} assertions at the definition site to catch it immediately.


Concept: Go prioritizes compile-time checks for type compatibility, enhancing code safety and predictability.

Why it matters: Compile-time errors are free to fix; runtime panics in production cost real money and user trust.

package main

import "fmt"

type Celsius float64
type Fahrenheit float64

// compile-time type safety: wrong unit assignment is a compile error, not a runtime bug
func boilWater() Celsius    { return 100 }
func bodyTemp() Fahrenheit  { return 98.6 }

func isSafeToTouch(temp Celsius) bool {
	return temp < 40
}

func main() {
	c := boilWater()
	f := bodyTemp()

	fmt.Println(isSafeToTouch(c)) // false
	// isSafeToTouch(f) // compile error: cannot use Fahrenheit as Celsius
	// isSafeToTouch(float64(f)) // compile error: cannot use float64 as Celsius

	isSafeToTouch(Celsius(f)) // explicit conversion — programmer acknowledges the risk
}

Gotcha: Using type Speed = float64 (alias) instead of type Speed float64 (distinct type) — aliases provide zero type safety.


Concept: Type assertions are runtime operations — errors are detected during program execution, not compile time.

Why it matters: Every type assertion that isn't protected by comma-ok is a potential production panic — they deserve the same care as error handling.

package main

import "fmt"

type Result struct{ value interface{} }

func (r Result) AsInt() (int, error) {
	// runtime check: type unknown at compile time — must assert at runtime
	v, ok := r.value.(int)
	if !ok {
		return 0, fmt.Errorf("AsInt: expected int, got %T", r.value)
	}
	return v, nil
}

func (r Result) AsString() (string, error) {
	v, ok := r.value.(string)
	if !ok {
		return "", fmt.Errorf("AsString: expected string, got %T", r.value)
	}
	return v, nil
}

func main() {
	results := []Result{{42}, {"hello"}, {3.14}}
	for _, r := range results {
		if v, err := r.AsInt(); err == nil {
			fmt.Println("int:", v)
		} else if s, err := r.AsString(); err == nil {
			fmt.Println("string:", s)
		} else {
			fmt.Println("unknown:", r.value)
		}
	}
}

Gotcha: Catching a type assertion panic with recover as the primary error handling strategy — use comma-ok instead; recover is for truly unexpected failures.


Concept: The standard library uses type assertions to provide default implementations while allowing custom behavior.

Why it matters: This pattern — check for an optional interface, fall back to default — is how Go's standard library achieves extensibility without breaking changes.

package main

import "fmt"

// stdlib pattern: check for optional behavior, fall back to default
type Stringer interface{ String() string }

type User struct {
	ID   int
	Name string
}

// User implements Stringer — fmt package checks for this interface
func (u User) String() string {
	return fmt.Sprintf("User{id=%d name=%q}", u.ID, u.Name)
}

// mimic stdlib's optional interface check
func display(v interface{}) string {
	if s, ok := v.(Stringer); ok {
		return s.String() // custom representation
	}
	return fmt.Sprintf("%v", v) // default fallback
}

func main() {
	u := User{ID: 1, Name: "Harish"}
	i := 42

	fmt.Println(display(u)) // uses User.String()
	fmt.Println(display(i)) // fallback: 42
	fmt.Println(u)          // fmt automatically calls u.String()
}

Gotcha: Implementing String() string with a pointer receiver when passing values to fmtfmt.Println(u) where u is a value won't call the pointer receiver's String().


Concept: Interface pollution leads to increased code complexity, reduced readability, and potential performance overhead due to unnecessary allocations.

Why it matters: Each unnecessary interface level adds an allocation + indirection on every call — measured cost, not theoretical.

package main

import (
	"fmt"
	"testing"
)

// polluted: interface with single impl adds alloc + indirection for no benefit
type NumberParser interface{ Parse(s string) (int, error) }
type defaultParser struct{}
func (defaultParser) Parse(s string) (int, error) { return 0, nil } // stub

// clean: function value or concrete type — no interface overhead
type Parser func(s string) (int, error)

func defaultParse(s string) (int, error) { return 0, nil } // stub

var sink int

func BenchmarkWithInterface(b *testing.B) {
	var p NumberParser = defaultParser{}
	for i := 0; i < b.N; i++ {
		v, _ := p.Parse("42")
		sink = v
	}
}

func BenchmarkWithConcrete(b *testing.B) {
	p := defaultParse
	for i := 0; i < b.N; i++ {
		v, _ := p("42")
		sink = v
	}
}

func main() { fmt.Println(defaultParse("42")) }

Gotcha: Accepting benchmark results showing "no difference" as proof the interface is free — the optimizer may have eliminated the dispatch; test with multiple concrete types.


Concept: Avoid designing with interfaces upfront — focus on concrete implementations and discover interfaces through refactoring as needed for decoupling.

Why it matters: Upfront interface design guesses at the right abstraction — concrete-first design reveals the actual abstraction through real usage patterns.

package main

import "fmt"

// phase 1: concrete, works, ships
type Notifier struct{ apiKey string }

func (n Notifier) Notify(msg string) error {
	fmt.Printf("notify[%s]: %s\n", n.apiKey, msg)
	return nil
}

// phase 2: requirement appears for SMS — NOW discover the interface
type SMSNotifier struct{ phone string }

func (s SMSNotifier) Notify(msg string) error {
	fmt.Printf("sms[%s]: %s\n", s.phone, msg)
	return nil
}

// interface discovered from two real types — minimum viable shape
type MessageNotifier interface {
	Notify(msg string) error
}

func alertTeam(n MessageNotifier, msg string) {
	if err := n.Notify(msg); err != nil {
		fmt.Println("alert failed:", err)
	}
}

func main() {
	alertTeam(Notifier{apiKey: "abc"}, "deploy started")
	alertTeam(SMSNotifier{phone: "+91..."}, "deploy done")
}

Gotcha: Designing the interface before writing either implementation — the interface shape is speculation and almost always needs changing once both types exist.


Concept: Question the use of interfaces when they don't provide clear and obvious benefits in terms of decoupling or supporting multiple implementations.

Why it matters: The three legitimate reasons for an interface: multiple implementations, testability, and reducing import cycles — if none apply, the interface is pollution.

package main

import "fmt"

// ask: does this interface have multiple implementations NOW or SOON?
// ask: does this interface help with testing?
// ask: does this interface break an import cycle?
// if all answers are NO: use the concrete type

type PDFExporter struct{ quality int }

func (p PDFExporter) Export(data string) error {
	fmt.Printf("pdf[q=%d]: %s\n", p.quality, data)
	return nil
}

// no interface needed — PDFExporter is used directly
// if a second exporter (CSV, HTML) appears, extract the interface then
func generateReport(exporter PDFExporter, title string) error {
	return exporter.Export("Report: " + title)
}

func main() {
	e := PDFExporter{quality: 300}
	generateReport(e, "Q4 Results")
}

Gotcha: Adding an interface "in case we need it later" — YAGNI applies to interfaces; the refactoring cost when you do need it is low.


Concept: Mocking should be a last resort in testing, with data-oriented tests using concrete implementations preferred.

Why it matters: Tests against real implementations find real bugs; mocks test that your mock behaves like you expect your mock to behave — a tautology.

package main

import (
	"fmt"
	"strings"
)

// data-oriented test: pass real data through a real implementation
// no mock needed — the function is pure and deterministic
func processName(name string) string {
	name = strings.TrimSpace(name)
	name = strings.ToLower(name)
	return strings.ReplaceAll(name, " ", "_")
}

// testable without any mock because it has no external dependencies
func main() {
	cases := []struct{ in, want string }{
		{"  Harish S  ", "harish_s"},
		{"Alice Bob",    "alice_bob"},
		{"",             ""},
	}
	for _, c := range cases {
		got := processName(c.in)
		if got != c.want {
			fmt.Printf("FAIL: processName(%q) = %q, want %q\n", c.in, got, c.want)
		} else {
			fmt.Printf("PASS: %q → %q\n", c.in, got)
		}
	}
}

Gotcha: Mocking a function that has no I/O or external dependencies — if the function is pure, just call it with test data.


Concept: Go's approach to mocking allows API users to define their own interfaces for mocking purposes, freeing API developers from anticipating those needs.

Concept: Go's approach to mocking allows API users to define their own interfaces for mocking purposes, freeing API developers from anticipating those needs.

package main

import "fmt"

// library package: ships only the concrete type — no interface defined
type S3Client struct{ region string }

func (c S3Client) PutObject(bucket, key string, data []byte) error {
	fmt.Printf("s3[%s] PUT %s/%s (%d bytes)\n", c.region, bucket, key, len(data))
	return nil
}

func (c S3Client) GetObject(bucket, key string) ([]byte, error) {
	return []byte("content"), nil
}

// consumer defines the interface they need — only the methods they actually call
type ObjectPutter interface {
	PutObject(bucket, key string, data []byte) error
}

// mock defined in test code — not in the library
type MockPutter struct{ calls []string }

func (m *MockPutter) PutObject(bucket, key string, data []byte) error {
	m.calls = append(m.calls, fmt.Sprintf("%s/%s", bucket, key))
	return nil
}

func uploadBackup(putter ObjectPutter, data []byte) error {
	return putter.PutObject("backups", "db.sql", data)
}

func main() {
	// production
	uploadBackup(S3Client{region: "ap-south-1"}, []byte("data"))

	// test
	mock := &MockPutter{}
	uploadBackup(mock, []byte("data"))
	fmt.Println("mock calls:", mock.calls)
}

Gotcha: Defining the mock interface in the library package — it creates a dependency from library to test infrastructure; define it in the consumer's package.


Concept: This approach promotes cleaner code and separation of concerns, allowing API users to tailor their testing strategies.

Why it matters: Separation of concerns means the library evolves independently of test infrastructure — adding a new method to S3Client doesn't break consumer mocks.

package main

import "fmt"

// narrow interfaces per use case — each consumer defines exactly what they need
type FileReader interface {
	ReadFile(path string) ([]byte, error)
}

type FileWriter interface {
	WriteFile(path string, data []byte) error
}

// consumer 1: only needs to read — mock is trivially simple
type MockReader struct{ data map[string][]byte }

func (m MockReader) ReadFile(path string) ([]byte, error) {
	if d, ok := m.data[path]; ok {
		return d, nil
	}
	return nil, fmt.Errorf("not found: %s", path)
}

// consumer 2: only needs to write — different mock, different concern
type MockWriter struct{ written map[string][]byte }

func (m *MockWriter) WriteFile(path string, data []byte) error {
	m.written[path] = data
	return nil
}

func readConfig(r FileReader) ([]byte, error) { return r.ReadFile("/etc/app.conf") }
func writeLog(w FileWriter, msg string) error { return w.WriteFile("/var/log/app.log", []byte(msg)) }

func main() {
	reader := MockReader{data: map[string][]byte{"/etc/app.conf": []byte("port=8080")}}
	data, _ := readConfig(reader)
	fmt.Println(string(data))

	writer := &MockWriter{written: make(map[string][]byte)}
	writeLog(writer, "started")
	fmt.Println(writer.written)
}

Gotcha: Defining one large FileSystem interface with all methods — consumers mock every method even if they only use one.