Skip to content

OOP & Design Patterns (GoF)

How a senior Go backend engineer expresses OOP principles, SOLID, and the GoF pattern catalog idiomatically through composition and interfaces, and recognizes which patterns Go makes trivial or unnecessary.

38 questions across 7 topics ยท Level: senior

Topics


OOP Principles in Go

1. How does Go express the four classical OOP pillars (encapsulation, abstraction, polymorphism, inheritance)? Which one is deliberately absent and why?

Difficulty: ๐ŸŸข warm-up ยท Tags: oop, encapsulation, polymorphism, inheritance

Go covers three of the four with different mechanisms than class-based languages. Encapsulation is at the package boundary, not the type: identifiers starting with an uppercase letter are exported, lowercase are package-private. There is no private/protected per-field modifier โ€” the unit of encapsulation is the package. Abstraction comes from interfaces, which are satisfied implicitly (structural typing) โ€” a type never declares it implements an interface. Polymorphism is achieved through interface values: any concrete type satisfying the interface is substitutable. Inheritance is deliberately absent. Go has no class hierarchy and no extends. Instead it offers struct embedding (composition with method promotion). The designers omitted inheritance because it couples subclasses to base-class implementation details (fragile base class), creates deep brittle hierarchies, and conflates 'is-a' with code reuse. Go forces you to reuse via composition and to polymorph via interfaces, keeping the two concerns separate.

Key points - Encapsulation = package-level (exported/unexported), not per-field modifiers - Abstraction + polymorphism = implicit interface satisfaction (structural typing) - No inheritance; struct embedding gives composition + method promotion instead - Inheritance omitted to avoid tight base-subclass coupling and brittle hierarchies

Follow-ups - How would you hide a field from other packages in the same module? - Why is implicit interface satisfaction a double-edged sword for refactoring?


2. Explain how encapsulation actually works in Go. How do you enforce invariants on a type when there are no per-field access modifiers?

Difficulty: ๐ŸŸก medium ยท Tags: encapsulation, package-design, invariants

Encapsulation in Go is enforced at the package boundary. Make the struct's fields unexported (lowercase) and expose behavior through exported methods and a constructor. Code outside the package can only touch the type through that API, so invariants live in the methods. Within the same package everything is visible, so the package is your trust boundary โ€” keep packages cohesive. A common pattern: unexported struct + exported constructor New... that validates and returns the value (often by pointer). For stronger hiding you can return an interface from the constructor so callers cannot even name the concrete type. The trade-off: package-level encapsulation is coarser than per-class private, so a sprawling package leaks internals to itself. Senior teams keep packages small and intentional precisely so encapsulation means something.

Key points - Lowercase fields are unexported = invisible outside the package - Enforce invariants in exported methods + a validating constructor - Return an interface to fully hide the concrete type - Encapsulation strength depends on keeping packages small and cohesive

package account

type Account struct {
    balance int64 // unexported: only this package mutates it
}

func New(initial int64) (*Account, error) {
    if initial < 0 {
        return nil, fmt.Errorf("negative initial balance")
    }
    return &Account{balance: initial}, nil
}

func (a *Account) Withdraw(amount int64) error {
    if amount > a.balance { // invariant lives in the method
        return fmt.Errorf("insufficient funds")
    }
    a.balance -= amount
    return nil
}

func (a *Account) Balance() int64 { return a.balance }

Follow-ups - When would you return an interface from New() instead of *Account? - How does internal/ package directory enforcement complement this?


3. What is method promotion with struct embedding, and how does it differ from inheritance?

Difficulty: ๐ŸŸก medium ยท Tags: embedding, composition, method-promotion

When you embed a type (anonymous field), its exported methods and fields are promoted to the outer struct โ€” you can call them as if they belonged to the outer type. This looks like inheritance but is fundamentally composition. Key differences: (1) No subtype relationship โ€” Outer is NOT a subtype of the embedded Inner; you cannot pass *Outer where *Inner is required. Substitutability comes only from satisfying a shared interface. (2) No dynamic dispatch back into the parent. If Inner has a method that calls another Inner method, embedding Inner in Outer and 'overriding' that method on Outer does NOT cause Inner's code to call Outer's version. There is no virtual-method table linking them โ€” Inner's methods only ever see Inner's receiver. This kills the fragile base class problem: a base class cannot accidentally invoke an overridden subclass method. (3) Promotion is purely a name-resolution convenience; the embedded value is a real, addressable field.

Key points - Embedding promotes the embedded type's methods/fields to the outer type - Outer is NOT a subtype of Inner โ€” no implicit substitutability - No virtual dispatch from embedded methods back into the outer type - Avoids the fragile base class problem inherent in inheritance

type Logger struct{}

func (Logger) Log(msg string) { fmt.Println("log:", msg) }

type Server struct {
    Logger // embedded
    Addr string
}

func main() {
    s := Server{Addr: ":8080"}
    s.Log("started") // promoted method, called directly on Server
    // var l Logger = s  // COMPILE ERROR: Server is not a Logger (no inheritance)
}

Follow-ups - Show why overriding an embedded method does NOT achieve polymorphism through the base. - What happens when two embedded types have the same method name?


4. Walk through the fragile base class problem and explain how Go's composition model avoids it.

Difficulty: ๐ŸŸ  hard ยท Tags: fragile-base-class, composition, embedding, dispatch

The fragile base class problem: in inheritance, a base class method may call another of its own methods that a subclass has overridden. Innocuous changes to how the base internally calls itself can silently change subclass behavior, and subclasses can break the base by overriding a method the base relies on. Behavior is split across the hierarchy and the coupling is implicit. Go sidesteps this because embedding has no virtual dispatch: an embedded type's methods always operate on the embedded receiver, never re-dispatching to outer 'overrides'. If Outer defines a method with the same name, it shadows the promoted one for callers of Outer, but the embedded type's own methods still call its own version. So the embedded type's internal call graph is frozen and self-contained โ€” the outer type cannot reach in and change it, and the embedded type cannot reach out. When you genuinely need the parent to call back into a customized step (Template Method), you must do it explicitly by passing an interface โ€” making the dependency visible and intentional rather than magic.

Key points - Inheritance: base methods call overridden subclass methods via vtable โ€” implicit, fragile - Go embedding has no virtual dispatch; embedded methods bind to embedded receiver - Outer methods shadow for external callers but don't rewire the embedded call graph - Template-Method-style callbacks must be passed explicitly via interface in Go

type Base struct{}

func (Base) templateStep() string { return "base step" }
func (b Base) Run() string       { return "running: " + b.templateStep() }

type Derived struct{ Base }

func (Derived) templateStep() string { return "derived step" }

func main() {
    d := Derived{}
    // Prints "running: base step" โ€” NOT "derived step".
    // Base.Run binds to Base.templateStep; no re-dispatch into Derived.
    fmt.Println(d.Run())
}

Follow-ups - How would you make Run() actually call Derived's step idiomatically? - Why is this surprising to engineers coming from Java/C++?


5. What happens when two embedded types declare a method with the same name, or when an embedded method collides with an outer method?

Difficulty: ๐ŸŸก medium ยท Tags: embedding, method-promotion, ambiguity

Go resolves promotion by depth. A method/field at a shallower depth wins. (1) Outer vs embedded: a method defined directly on the outer type is at depth 0 and shadows any same-named promoted method (depth 1+). The promoted one is still reachable explicitly via the embedded field name: o.Inner.Method(). (2) Two embedded types at the same depth with the same method name: this is an ambiguous selector. It is NOT a compile error by itself โ€” the program compiles fine โ€” but any attempt to call o.Method() without qualifying it fails to compile with 'ambiguous selector'. You must disambiguate: o.A.Method() or o.B.Method(). Critically, if both embedded types contribute the same method needed to satisfy an interface, the outer type does NOT satisfy that interface (the method set is ambiguous) โ€” you must add an explicit method on the outer type to resolve it. This is a common gotcha when composing mixins.

Key points - Shallower depth wins; outer-type method shadows promoted ones - Same-depth collision = ambiguous selector, only errors when called unqualified - Disambiguate via the embedded field name: o.A.Method() - Ambiguity means the outer type may NOT satisfy an interface โ€” add an explicit method

Follow-ups - How do you make the outer type satisfy an interface despite an embedding collision? - Does the same depth rule apply to fields as well as methods?


6. Go is sometimes called 'object-oriented without objects'. Defend or critique that statement at a senior level.

Difficulty: ๐ŸŸ  hard ยท Tags: oop, design-philosophy

It's a fair characterization with caveats. Go has the OOP capabilities that matter: data + behavior bundled (types with methods), data hiding (package encapsulation), and polymorphism (interfaces). What it lacks are the class-centric mechanisms: classes, inheritance hierarchies, constructors as language features, virtual methods, and method overloading. So Go is object-oriented in the Alan-Kay 'late-binding via messages' sense (interface dispatch is dynamic) but not in the Java/C++ 'taxonomy of classes' sense. The senior take: OOP's enduring value is encapsulation and polymorphism for decoupling, not inheritance taxonomies. Go keeps the valuable parts and discards the parts (deep hierarchies, implementation inheritance) that experience showed cause coupling and rigidity. The trade-off is you sometimes write more boilerplate (no super, manual delegation, no generics-free covariance) and lose some expressiveness, but you gain composability and explicitness. Calling it 'OOP without objects' is rhetorical โ€” it has objects (values with methods), just not classes.

Key points - Has encapsulation + polymorphism + data/behavior bundling โ€” the load-bearing OOP parts - Lacks classes, inheritance, constructors-as-syntax, overloading, virtual methods - OOP in the message/late-binding sense, not the class-taxonomy sense - Keeps decoupling value, drops inheritance's coupling cost

Follow-ups - Is interface dispatch in Go statically or dynamically bound? - What does Go lose by not having method overloading?


SOLID in Go

7. Apply the Single Responsibility Principle in Go. How does package and type granularity express it?

Difficulty: ๐ŸŸก medium ยท Tags: solid, srp, package-design

SRP says a unit should have one reason to change. In Go it applies at two levels: the package and the type. A package should be cohesive around one concern (net/http, not utils). A type should not mix, say, HTTP request parsing, business logic, and database access. The Go-idiomatic smell of an SRP violation is a struct with many unrelated fields and a grab-bag of methods, or a 'manager'/'helper'/'util' package. The fix is to split by responsibility and wire dependencies through interfaces injected at construction. SRP pairs naturally with Go's accept-interfaces-return-structs: a UserService depends on a small UserStore interface for persistence and an EmailSender for notifications, so a change to email logic doesn't touch the service. The reason-to-change test is the discriminator: if marketing changes the email template and the persistence team changes the DB, those should not live in the same type.

Key points - SRP applies to both packages and types in Go - Anti-pattern: 'utils'/'manager' packages and god structs - Split by reason-to-change; inject collaborators via small interfaces - Pairs with accept-interfaces-return-structs

Follow-ups - Why is a package named 'utils' an SRP smell? - How granular is too granular for packages?


8. How is the Open/Closed Principle realized in Go without inheritance? Give a concrete example.

Difficulty: ๐ŸŸก medium ยท Tags: solid, ocp, interfaces

OCP: open for extension, closed for modification. Without inheritance, Go achieves it through interfaces and composition, not subclassing. You define behavior as an interface; new behavior is added by writing a new type that satisfies it, with no edit to the consuming code. Function values are a lightweight variant โ€” a sort that takes a less func(i,j int) bool is open to new orderings without modification. The classic OCP failure in Go is a switch on a type tag or enum that you must edit every time a new case appears; the OCP-compliant version replaces the switch with polymorphic dispatch through an interface. The pragmatic caveat (and Go's style): don't pre-abstract for hypothetical extension. Apply OCP where the extension axis is real and recurring (e.g., pluggable storage backends, payment providers); a premature interface to satisfy OCP is just speculative generality.

Key points - Extension via new interface-satisfying types, not subclasses - Function values give lightweight OCP (e.g. comparator funcs) - Type-switch on an enum is the canonical OCP violation - Apply only where the extension axis is real โ€” avoid speculative interfaces

type Shape interface{ Area() float64 }

type Circle struct{ R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

type Rect struct{ W, H float64 }
func (r Rect) Area() float64 { return r.W * r.H }

// TotalArea never changes when a new Shape is added โ€” it's closed.
func TotalArea(shapes []Shape) float64 {
    var sum float64
    for _, s := range shapes {
        sum += s.Area()
    }
    return sum
}

Follow-ups - When is a type-switch acceptable despite violating OCP? - How do you avoid speculative interfaces in the name of OCP?


9. What does the Liskov Substitution Principle mean for Go interfaces, given there's no subclassing? Give an LSP violation example.

Difficulty: ๐ŸŸ  hard ยท Tags: solid, lsp, interfaces, contracts

LSP normally constrains subtypes; in Go, the 'subtype' relation is 'satisfies the interface'. LSP becomes: any type implementing an interface must honor the interface's behavioral contract โ€” preconditions, postconditions, error semantics, panics, and side effects โ€” not just its method signatures. The compiler checks signatures; it cannot check semantics, so LSP violations are runtime/contract bugs. Classic Go examples: (1) An io.Writer implementation that silently drops data or returns n < len(p) with nil error โ€” it satisfies the signature but breaks the documented contract that a short write must return an error. (2) A ReadCloser whose Close() is required twice or panics on double close when the contract says idempotent. (3) A 'read-only' implementation of a mutating interface that panics โ€” the canonical Square/Rectangle problem reincarnated. The senior fix: keep interfaces small (fewer contract clauses to honor), and document and test the behavioral contract (often with a shared conformance test suite that every implementation must pass).

Key points - In Go, LSP = honoring an interface's behavioral contract, not just signatures - Compiler checks signatures only; semantic violations are runtime bugs - Examples: short write with nil error, non-idempotent Close, panic on a 'supported' method - Mitigate with small interfaces + shared conformance test suites

// LSP violation: signature fits io.Writer, contract broken.
type LossyWriter struct{ limit int }

func (w *LossyWriter) Write(p []byte) (int, error) {
    if len(p) > w.limit {
        // Contract says: a short write MUST return a non-nil error.
        return w.limit, nil // BUG: violates io.Writer's contract -> LSP break
    }
    return len(p), nil
}

Follow-ups - How would you write a conformance test that all io.Writer impls must pass? - Why does keeping interfaces small reduce LSP risk?


10. Why does the Interface Segregation Principle fit Go especially well? Contrast with how Java often violates it.

Difficulty: ๐ŸŸก medium ยท Tags: solid, isp, interfaces, small-interfaces

ISP says clients shouldn't depend on methods they don't use. Go's whole interface culture is ISP: idiomatic interfaces are tiny โ€” io.Reader, io.Writer, fmt.Stringer are one method each โ€” and composed when needed (io.ReadWriteCloser). Because satisfaction is implicit, a small interface can be defined by the consumer exactly for what it needs, and any existing type satisfies it automatically. Java tends toward fat interfaces declared up front (UnsupportedOperationException for methods an implementer can't support is the smell), because implements is explicit and adding a method breaks all implementers. In Go you avoid fat interfaces because (a) the standard library models the style and (b) you typically define the interface at the point of use, sized to the single function consuming it. The senior heuristic: 'the bigger the interface, the weaker the abstraction' (Rob Pike). Prefer one-to-three method interfaces; if a function only calls Read, ask for io.Reader, not *os.File.

Key points - Idiomatic Go interfaces are tiny โ€” ISP is the default, not an effort - Implicit satisfaction lets consumers define minimal interfaces ad hoc - Java fat interfaces leak via UnsupportedOperationException - 'The bigger the interface, the weaker the abstraction'

// Consumer defines exactly what it needs, not *os.File.
func Count(r io.Reader) (int, error) {
    buf := make([]byte, 32*1024)
    total := 0
    for {
        n, err := r.Read(buf)
        total += n
        if err == io.EOF {
            return total, nil
        }
        if err != nil {
            return total, err
        }
    }
}

Follow-ups - Where should an interface be declared โ€” with the implementer or the consumer? - How do you compose small interfaces into a larger contract?


11. Explain the Dependency Inversion Principle in Go. Who should own the interface โ€” the high-level or low-level module?

Difficulty: ๐ŸŸ  hard ยท Tags: solid, dip, dependency-injection, interfaces

DIP: high-level policy should not depend on low-level details; both depend on abstractions. In Go this is realized by having the high-level package define and own the interface it needs, and the low-level concrete type (e.g., a Postgres adapter) implicitly satisfies it. This is the key Go inversion: the interface lives with the consumer, not the implementer. So a service package declares type Store interface { Save(User) error }, and the postgres package provides a *postgres.Client that happens to satisfy it โ€” postgres imports nothing from service, and service imports nothing from postgres; wiring happens in main. This breaks the import cycle and lets you swap implementations or use a fake in tests. Compared to languages where the interface ships with the implementation, Go's structural typing makes consumer-owned interfaces frictionless because no implements declaration ties the adapter to the abstraction. The anti-pattern is putting interfaces in a shared interfaces or models package that everything imports โ€” that re-couples everyone to a central definition.

Key points - High-level module owns/defines the interface; low-level type satisfies it implicitly - Interface lives with the consumer, not the implementer (the Go inversion) - Concrete adapter package imports nothing from the policy package โ€” wiring in main - Anti-pattern: a central 'interfaces' package everyone imports

// package service (high-level policy) owns the abstraction
package service

type Store interface {
    Save(u User) error
}

type UserService struct{ store Store }

func New(s Store) *UserService { return &UserService{store: s} }
func (svc *UserService) Register(u User) error { return svc.store.Save(u) }

// package postgres (low-level detail) satisfies it implicitly, imports no service
// type Client struct{...}; func (c *Client) Save(u service.User) error {...}
// main wires: service.New(pgClient)

Follow-ups - Why is a shared 'interfaces' package an anti-pattern? - How does consumer-owned interface design help mocking in tests?


Creational Patterns in Go

12. How do you implement Singleton in Go, and why is it often considered an anti-pattern?

Difficulty: ๐ŸŸก medium ยท Tags: creational, singleton, sync.Once, anti-pattern

The idiomatic Singleton is a package-level variable initialized lazily with sync.Once (thread-safe, no double-checked locking needed) or eagerly in an init()/var declaration. No class machinery is required โ€” the package itself is the namespace. It's frequently an anti-pattern because: (1) it's global mutable state, which hides dependencies (functions secretly reach for the global instead of receiving it), making code hard to reason about; (2) it destroys testability โ€” tests can't substitute a fake and can't run in parallel because they share the singleton; (3) lazy Once-based init can mask initialization-order and error-handling problems (you can't easily return an error from a Once.Do). The senior alternative is to construct the dependency once in main and inject it explicitly (DI). Legitimate uses are narrow: truly process-global, stateless or read-mostly resources (a metrics registry, the default logger) where a single instance is a genuine domain constraint, not a convenience.

Key points - Idiomatic singleton = package var + sync.Once for lazy thread-safe init - Anti-pattern: global mutable state hides dependencies, kills testability/parallelism - Once.Do can't cleanly surface init errors - Prefer constructing once in main and injecting; reserve singletons for true process globals

package config

import "sync"

type Config struct{ DSN string }

var (
    once     sync.Once
    instance *Config
)

func Get() *Config {
    once.Do(func() {
        instance = &Config{DSN: loadDSN()}
    })
    return instance
}

Follow-ups - How would you make a Once-based singleton return an init error? - How does a singleton break t.Parallel() tests?


13. Contrast Factory Method and simple factory functions in Go. When do you actually need them?

Difficulty: ๐ŸŸก medium ยท Tags: creational, factory, constructors

Go has no constructors, so a plain function like NewThing(...) (*Thing, error) is the universal 'simple factory' and the default for almost everything โ€” it validates inputs, sets defaults, and returns the value (often with an error). You need a true Factory Method (a function or interface method that returns an interface, choosing the concrete type at runtime) only when the caller shouldn't know the concrete type: e.g., OpenDriver(name string) (Driver, error) returning different implementations based on a config string, or database/sql's driver registration where sql.Open("postgres", ...) dispatches to a registered factory. The GoF 'Factory Method as a subclass-overridden method' form is largely irrelevant in Go (no subclassing). So in practice: use a constructor function by default; introduce a registry-of-factories or a func(...) Iface only when you genuinely select among implementations dynamically. Don't build an AbstractFactory ceremony around what is just a switch returning one of three structs.

Key points - Plain New...() function = the idiomatic constructor/simple factory - True factory method: returns an interface, picks concrete type at runtime - GoF subclass-overridden factory method doesn't apply (no inheritance) - Registry-of-factories (e.g. sql.Register) is the realistic Go form

type Notifier interface{ Send(msg string) error }

// Factory method: selects concrete type, returns interface.
func NewNotifier(kind string) (Notifier, error) {
    switch kind {
    case "email":
        return &emailNotifier{}, nil
    case "sms":
        return &smsNotifier{}, nil
    default:
        return nil, fmt.Errorf("unknown notifier %q", kind)
    }
}

Follow-ups - How does database/sql's driver registry implement a factory? - When does a factory function returning an interface become interface pollution?


14. When is the Abstract Factory pattern justified in Go, and how would you structure it idiomatically?

Difficulty: ๐ŸŸ  hard ยท Tags: creational, abstract-factory, over-engineering

Abstract Factory creates families of related objects that must be used together (e.g., a cloud-provider factory yielding compatible BlobStore + Queue + Secrets clients for AWS vs GCP). It's justified in Go only when there's a real consistency constraint across the products โ€” you must not mix an AWS queue with a GCP blob store. Idiomatically you model the factory as an interface with methods returning each product interface, and one concrete factory per family. You wire the chosen family once in main. It's frequently over-engineering: if products aren't actually coupled, you don't need a factory grouping them โ€” inject each dependency separately. The Go-flavored realization often drops the explicit factory interface in favor of a constructor that returns a struct of related clients, or a per-provider package that exposes New(...) for each product. Reserve the full Abstract Factory interface for plugin-style systems with multiple swappable, internally-consistent backends.

Key points - Use only when products form a consistent family that must not be mixed - Factory interface with one method per product; one concrete factory per family - Often over-engineered โ€” if products aren't coupled, inject them separately - Go alternative: provider package exposing per-product constructors, wired in main

type Storage interface{ Put(key string, b []byte) error }
type Queue interface{ Push(msg []byte) error }

type CloudFactory interface {
    NewStorage() Storage
    NewQueue() Queue
}

type awsFactory struct{ region string }

func (f awsFactory) NewStorage() Storage { return &s3Store{f.region} }
func (f awsFactory) NewQueue() Queue     { return &sqsQueue{f.region} }

Follow-ups - How is Abstract Factory different from just calling several constructors? - When would you collapse it into a single struct of clients?


15. Compare the Builder pattern with functional options in Go. Which do you reach for and why?

Difficulty: ๐ŸŸ  hard ยท Tags: creational, builder, functional-options

Both solve the 'too many constructor parameters / optional config' problem that Go can't address with overloading or default args. Functional options (func(*T) closures passed as variadic) are the dominant Go idiom: NewServer(addr, WithTimeout(5s), WithTLS(cfg)). Pros: backward-compatible API evolution (add an option without breaking callers), self-documenting at the call site, allows validation in each option, defaults handled before applying options. Cons: a little allocation/closure overhead, indirection. Builder (a struct accumulating state with chained WithX methods then a Build() (T, error)) is preferable when: construction is genuinely multi-step or stateful, you want to validate the whole thing atomically in Build, you need to construct the same object many times from a reusable template, or the result is immutable and you want a fluent staged API. The senior call: default to functional options for libraries and configurable services; use a builder for complex, multi-stage, or repeatedly-instantiated objects (e.g., query builders, request builders). Avoid builders that are just setters on a mutable struct with no validation โ€” that's ceremony.

Key points - Both compensate for no overloading/default params in Go - Functional options: variadic func(*T); great for evolvable, optional config APIs - Builder: better for multi-step, stateful, atomically-validated, or reusable construction - Default to options; use builder when construction is genuinely staged/complex

type Server struct {
    addr    string
    timeout time.Duration
    tls     *tls.Config
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithTLS(c *tls.Config) Option       { return func(s *Server) { s.tls = c } }

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second} // defaults
    for _, opt := range opts {
        opt(s)
    }
    return s
}

Follow-ups - How do functional options enable backward-compatible API growth? - How do you return an error from a functional option cleanly?


16. Is the Prototype pattern useful in Go? How do you correctly clone a value, and what's the deep-copy trap?

Difficulty: ๐ŸŸก medium ยท Tags: creational, prototype, deep-copy, value-semantics

Prototype (create new objects by cloning an existing instance) is rarely a named pattern in Go because copying a struct value is built into the language: b := a copies it. The pattern matters only when (a) you want polymorphic cloning behind an interface (Clone() T) for objects whose concrete type the caller doesn't know, or (b) constructing fresh is expensive and a template-plus-copy is cheaper. The critical trap is shallow vs deep copy: assigning a struct copies pointers, slices, and maps by reference header โ€” the clone shares the underlying backing arrays/maps with the original, so mutating one corrupts the other. A correct Clone() must deep-copy reference-typed fields (allocate a new slice and copy elements, new map and copy entries, recursively clone pointed-to structs). For complex graphs people sometimes serialize/deserialize, but that's slow and loses unexported fields. Reach for an explicit Clone() method only when shared-state bugs are a real risk and copying must be polymorphic; otherwise plain value copy with a deliberate deep-copy helper is enough.

Key points - Plain struct assignment already copies โ€” Prototype rarely needed as a named pattern - Use a Clone() interface method for polymorphic cloning of unknown concrete types - Trap: struct copy is shallow โ€” slices/maps/pointers are shared - Correct Clone deep-copies reference fields; serialization clone is slow and drops unexported fields

type Doc struct {
    Title string
    Tags  []string
}

func (d Doc) Clone() Doc {
    cp := d                       // copies Title; Tags header is shared!
    cp.Tags = make([]string, len(d.Tags))
    copy(cp.Tags, d.Tags)         // deep-copy the slice so they don't alias
    return cp
}

Follow-ups - Why is gob/JSON round-trip cloning often a bad idea? - How do unexported fields complicate generic deep copy?


Structural Patterns in Go

17. Why is the Adapter pattern almost trivial in Go? Show the difference between an object adapter and the implicit-satisfaction case.

Difficulty: ๐ŸŸก medium ยท Tags: structural, adapter, implicit-interfaces, trivial-in-go

Adapter makes one interface usable where another is expected. Go's implicit interface satisfaction collapses much of this: if an existing concrete type already has the right method set, it satisfies the target interface with zero adapter code โ€” no implements, no wrapper. You only write an explicit adapter when the method names or signatures actually differ. Two forms: (1) a small wrapper struct holding the adaptee and translating calls; (2) a function adapter using a func type with a method, like http.HandlerFunc, which adapts a plain function into the http.Handler interface. The senior point: don't write adapter structs to 'be safe' โ€” first check whether the type already satisfies the interface. When you do need one, keep it a thin, stateless translation layer. The function-adapter trick (type HandlerFunc func(...); func (f HandlerFunc) ServeHTTP(...) { f(...) }) is a uniquely idiomatic Go realization worth knowing.

Key points - Implicit satisfaction = no adapter needed if method set already matches - Write an adapter only when names/signatures genuinely differ - Function-adapter (http.HandlerFunc) adapts a func to an interface idiomatically - Keep adapters thin, stateless translation layers

// Function adapter: turn a plain func into an interface implementation.
type Handler interface{ Handle(req string) string }

type HandlerFunc func(string) string

func (f HandlerFunc) Handle(req string) string { return f(req) } // adapts func -> Handler

func main() {
    var h Handler = HandlerFunc(func(r string) string { return "ok:" + r })
    fmt.Println(h.Handle("ping"))
}

Follow-ups - Why does http.HandlerFunc exist if you can just pass a func? - When does a 'safety' adapter struct become needless indirection?


18. How is the Decorator pattern expressed in Go? Connect it to HTTP middleware.

Difficulty: ๐ŸŸก medium ยท Tags: structural, decorator, middleware, io

Decorator wraps an object to add behavior while preserving its interface. In Go you implement it by writing a type (or function) that takes the interface, holds it, and calls through while adding behavior before/after. The single most common realization is middleware: a func(http.Handler) http.Handler (or func(next Handler) Handler) that wraps the next handler to add logging, auth, metrics, recovery, etc. Because the wrapper satisfies the same interface, decorators compose by nesting, and you can chain them. The same idea applies to io.Reader/io.Writer โ€” gzip.NewWriter, bufio.NewReader, cipher.StreamWriter are all decorators over an underlying writer/reader. Function-typed decorators (func(T) T) are the lightweight form; struct decorators (embedding the wrapped interface) are used when the decorator carries state. The senior nuance: decorator chains should preserve the contract (don't break short-write/EOF semantics โ€” LSP) and order matters (recovery outermost, logging, then auth).

Key points - Wrap the interface, call through, add behavior โ€” same interface preserved - HTTP middleware func(http.Handler) http.Handler is the canonical decorator - io.Reader/Writer chains (gzip, bufio, cipher) are decorators - Order matters; decorators must preserve the wrapped contract (LSP)

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r) // call through
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}

// Compose: handler = Logging(Auth(Recover(mux)))

Follow-ups - Why is middleware ordering significant? - How do io.Writer decorators preserve the Write contract?


19. Differentiate Facade, Proxy, and Adapter in Go terms. Give a one-line use for each.

Difficulty: ๐ŸŸก medium ยท Tags: structural, facade, proxy, adapter

All three wrap something, but for different reasons. Adapter changes an interface to match what a client expects (signature/name translation); same level of capability, different shape. Use: make a third-party SDK satisfy your Store interface. Facade provides a simpler, higher-level interface over a complex subsystem of several types โ€” it hides orchestration. Use: a payments.Client.Checkout() that internally coordinates inventory, charging, and receipt services so callers don't touch all three. In Go a facade is usually just an exported struct/package whose methods sequence calls to internal collaborators. Proxy keeps the same interface as the real object but controls access to it โ€” adding lazy init, caching, access control, remoting, or rate limiting transparently. Use: a caching proxy in front of a slow store, or a gRPC client stub standing in for a remote service. Mnemonic: Adapter = different interface, Facade = simpler interface over many things, Proxy = same interface, controlled access.

Key points - Adapter: same capability, different interface shape (translation) - Facade: simpler high-level API over a complex multi-type subsystem - Proxy: identical interface, adds access control/caching/laziness/remoting - Mnemonic: different vs simpler vs same interface

// Proxy: same interface, adds caching.
type Store interface{ Get(id string) ([]byte, error) }

type cachingProxy struct {
    real  Store
    cache map[string][]byte
}

func (p *cachingProxy) Get(id string) ([]byte, error) {
    if v, ok := p.cache[id]; ok {
        return v, nil
    }
    v, err := p.real.Get(id)
    if err == nil {
        p.cache[id] = v
    }
    return v, err
}

Follow-ups - When does a Facade become a god object? - How is a gRPC generated client a Proxy?


20. How do you implement Composite in Go? Where does it show up in real backend code?

Difficulty: ๐ŸŸก medium ยท Tags: structural, composite, trees, validators

Composite lets clients treat individual objects and compositions of objects uniformly through a common interface. In Go: define a single interface (e.g., Node), have leaf types and a container type both satisfy it, and the container holds a []Node and implements the interface by delegating/aggregating over its children. The tree is recursive; the client calls one method without caring whether it hit a leaf or a branch. Real backend appearances: filesystem trees, JSON/config trees, expression/AST evaluation, permission hierarchies, and โ€” very commonly โ€” composite validators or middleware where a MultiValidator is itself a Validator made of child validators, and a composite http.Handler/router that contains sub-routers. Go-specific notes: you don't need an abstract base for shared child-management code (no inheritance) โ€” either duplicate the tiny bit of slice handling or embed a small helper struct. Watch out for unbounded recursion on deep/cyclic structures.

Key points - One interface; leaf and container both satisfy it; container holds []Node - Container aggregates over children โ€” clients treat leaf and tree uniformly - Real uses: AST/config trees, composite validators, nested routers/handlers - No abstract base needed; guard against deep/cyclic recursion

type Validator interface{ Validate(v string) error }

type lenValidator struct{ min int }
func (l lenValidator) Validate(v string) error {
    if len(v) < l.min { return fmt.Errorf("too short") }
    return nil
}

type MultiValidator []Validator // composite is itself a Validator
func (m MultiValidator) Validate(v string) error {
    for _, c := range m {
        if err := c.Validate(v); err != nil { return err }
    }
    return nil
}

Follow-ups - How do you prevent infinite recursion in a cyclic composite? - Why might errors.Join pair well with a composite validator?


21. Explain the Bridge pattern and a realistic Go use. How is it distinct from Strategy or plain composition?

Difficulty: ๐ŸŸ  hard ยท Tags: structural, bridge, composition, strategy-contrast

Bridge decouples an abstraction from its implementation so the two can vary independently โ€” it splits one hierarchy that would otherwise explode combinatorially into two: an abstraction side and an implementor side, connected by composition. In Go you express it as: an abstraction struct holding an implementor interface, where you can add new abstraction variants and new implementor variants without an Nร—M class blowup. Realistic use: a Notification abstraction (Alert, Reminder โ€” high-level, with their own logic) bridged to a MessageSender implementor (Email, SMS, Push). You can add a Reminder or a Slack sender independently. How it differs: Strategy swaps a single algorithm at runtime and is about behavior selection; Bridge is about structuring two independent dimensions of variation and is a compile-time architectural split (though it uses the same composition mechanic). The distinction is intent, not mechanics โ€” both hold an interface. In Go, since composition + interfaces are the default, 'Bridge' often just looks like ordinary dependency injection; calling it Bridge is mostly about recognizing the two-axis variation, and naming it is rarely worth the ceremony unless that two-axis growth is real.

Key points - Splits one exploding hierarchy into abstraction + implementor, joined by composition - Abstraction holds an implementor interface; both sides vary independently - Differs from Strategy by intent: structural two-axis variation vs runtime algorithm swap - In Go it often reduces to ordinary DI; name it only when two-axis growth is real

type Sender interface{ Send(to, body string) error } // implementor axis

type Notification struct{ sender Sender } // abstraction axis holds implementor

func (n Notification) Alert(to, msg string) error {
    return n.sender.Send(to, "ALERT: "+msg)
}
func (n Notification) Reminder(to, msg string) error {
    return n.sender.Send(to, "Reminder: "+msg)
}
// New Sender (Slack) and new method on Notification grow independently.

Follow-ups - When does Bridge collapse into ordinary dependency injection? - How does Bridge prevent a class/type explosion?


22. How is the Flyweight pattern realized in Go? Discuss sync.Pool and string/struct interning.

Difficulty: ๐ŸŸ  hard ยท Tags: structural, flyweight, sync.Pool, interning, memory

Flyweight shares intrinsic state across many objects to cut memory, separating shared immutable state from per-instance extrinsic state. Go realizations: (1) Interning โ€” deduplicating equal immutable values so all references point to one copy. For strings/labels (e.g., metric label sets, parsed tokens) you keep a map[string]string cache and return the canonical instance, saving memory when the same value recurs millions of times. (2) sync.Pool โ€” this is not classic flyweight (it's object reuse, not shared-immutable-state), but it's the Go answer to the related problem of reducing allocation/GC pressure by recycling transient objects (e.g., bytes.Buffers, encoder scratch buffers). Caveat: sync.Pool items can be GC'd at any time, must be reset before reuse, and must not be retained after Put. The senior framing: true flyweight in Go = interning of immutable shared state behind a lookup; sync.Pool solves a different problem (allocation churn) and shouldn't be conflated, but both are 'sharing to save memory'. Don't intern unless profiling shows the duplication actually matters โ€” the map/locking can cost more than it saves.

Key points - Flyweight = share intrinsic immutable state across many instances - Interning: canonicalize equal immutable values via a lookup map to dedupe memory - sync.Pool = object reuse to cut alloc/GC pressure โ€” related but not classic flyweight - Pool items may be GC'd anytime, must be reset, never retained after Put; profile before interning

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                 // must reset reused object
    defer bufPool.Put(buf)      // don't retain after Put
    buf.Write(data)
    return buf.String()
}

Follow-ups - Why is sync.Pool not a true flyweight? - What goes wrong if you keep a pointer to a pooled object after Put?


Behavioral Patterns in Go

23. How do you implement Strategy in Go? When is a func value better than an interface, and vice versa?

Difficulty: ๐ŸŸก medium ยท Tags: behavioral, strategy, func-values, interfaces

Strategy = a family of interchangeable algorithms selected at runtime. Go gives you two clean encodings. Func value: pass a func (e.g., less func(a, b int) bool, or sort.Slice's comparator). Best when the strategy is a single operation, stateless, and you want minimal ceremony โ€” no type, no interface declaration. Interface: define a one-method (or few-method) interface and concrete strategy types. Best when the strategy carries configuration/state, needs multiple related methods, or benefits from a name and explicit type for documentation and mockability. Practically: reach for a func value first (it's the lightest), and promote to an interface when the strategy grows state or more than one method, or when you want named implementations a caller can discover. Go's first-class functions make the func form so natural that Strategy is one of the patterns people stop thinking of as a pattern โ€” http.HandlerFunc, sort.Slice, and option funcs are all Strategy in disguise.

Key points - Two encodings: func value (lightest) or interface (stateful/multi-method) - Func value when stateless single operation; interface when it carries config/state - Promote func -> interface as the strategy grows methods or needs a name - First-class functions make Strategy nearly invisible in idiomatic Go

// Strategy as a func value.
type PricingStrategy func(base float64) float64

func regular(b float64) float64 { return b }
func blackFriday(b float64) float64 { return b * 0.7 }

func finalPrice(base float64, strat PricingStrategy) float64 { return strat(base) }

// finalPrice(100, blackFriday) -> 70

Follow-ups - How does sort.Slice embody Strategy? - At what point would you convert a func strategy into an interface?


24. How do you implement Observer in Go using channels vs callback registration? Trade-offs?

Difficulty: ๐ŸŸ  hard ยท Tags: behavioral, observer, channels, pub-sub, concurrency

Observer = subjects notify subscribers of events. Two idiomatic Go encodings. Callback registration: the subject keeps a slice of observer interfaces (or funcs) and iterates calling Notify(event). Simple, synchronous, easy to reason about; but a slow/blocking observer stalls the subject, and you must guard the slice with a mutex if registration races with notification. Channels: each subscriber gets a channel; the subject sends events to all channels (fan-out). This decouples timing and integrates with goroutines/select, but introduces hard questions: buffered vs unbuffered (backpressure vs blocking), what to do when a subscriber is slow (drop, block, or grow), how to unsubscribe without leaking goroutines or panicking on a closed channel, and lifecycle/context cancellation. Trade-offs: callbacks for simple in-process synchronous notifications; channels for concurrent, decoupled, streaming events. Senior pitfalls: goroutine leaks from never-closed subscriber channels, sending on a closed channel, and unbounded buffering. For cross-process or durable observation, you'd move to a real pub/sub broker rather than rolling either.

Key points - Callback registration: slice of observers, synchronous, mutex-guarded, simple - Channels: fan-out, concurrent/decoupled, but raise buffering/backpressure/lifecycle issues - Pitfalls: goroutine leaks, send-on-closed-channel, unbounded buffers - Callbacks for simple sync events; channels for concurrent streams; broker for cross-process

type EventBus struct {
    mu   sync.RWMutex
    subs []chan string
}

func (b *EventBus) Subscribe() <-chan string {
    ch := make(chan string, 8) // buffered: bounded backpressure
    b.mu.Lock()
    b.subs = append(b.subs, ch)
    b.mu.Unlock()
    return ch
}

func (b *EventBus) Publish(ev string) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for _, ch := range b.subs {
        select {
        case ch <- ev: // non-blocking
        default: // drop if subscriber is slow โ€” avoids stalling publisher
        }
    }
}

Follow-ups - How do you implement Unsubscribe without leaking goroutines? - When should you reach for NATS/Kafka instead of an in-process bus?


25. How would you implement Command in Go, and where is it genuinely useful in backend systems?

Difficulty: ๐ŸŸก medium ยท Tags: behavioral, command, saga, cqrs, job-queue

Command encapsulates a request as an object: parameters bound, plus an Execute() (and optionally Undo()). In Go the simplest command is a closure (func() error) capturing its arguments; the richer form is a Command interface with Execute(ctx) error (and Undo) implemented by structs that carry state. Genuine backend uses: (1) Job/task queues โ€” enqueue command objects and run them on workers, with retry; (2) Undo/redo and transactional sagas โ€” pair Execute/Compensate so a failed multi-step workflow can roll back (each saga step is a command with a compensating command); (3) CQRS write side โ€” a command represents an intent (CreateOrder) handled by a command handler, separate from queries; (4) macro/batch operations โ€” a composite of commands run as one. The Go-idiomatic minimal form (closures or func(ctx) error) is preferred unless you need serialization, undo, queuing metadata, or introspection, in which case the struct-with-interface form earns its keep.

Key points - Closure func() error is the minimal command; interface+struct for richer needs - Backend uses: job queues, saga compensations, CQRS commands, batch/macros - Add Undo/Compensate to support rollback and undo/redo - Prefer closures unless you need serialization/queuing metadata/introspection

type Command interface {
    Execute(ctx context.Context) error
    Compensate(ctx context.Context) error // for saga rollback
}

type ChargeCard struct{ amount int64; cardID string }
func (c ChargeCard) Execute(ctx context.Context) error    { /* charge */ return nil }
func (c ChargeCard) Compensate(ctx context.Context) error { /* refund */ return nil }

Follow-ups - How does Command underpin a saga's compensation logic? - When is a plain func(ctx) error enough vs a Command interface?


26. Implement a State machine in Go. Compare the interface-per-state approach with a transition table.

Difficulty: ๐ŸŸ  hard ยท Tags: behavioral, state, fsm, transition-table

State lets an object change behavior when its internal state changes, ideally without sprawling conditionals. Two Go approaches. Interface per state: define a State interface with the events as methods (Next(event) State), one struct per state, each returning the next state. Pros: each state's logic is isolated and open for extension; no giant switch. Cons: more types, and shared context must be threaded through. Transition table: a map[stateKey]map[eventKey]stateKey (or a map[transition]handler) driving a single engine. Pros: the whole machine is declarative and inspectable in one place, easy to validate/visualize, trivial to add transitions as data. Cons: behavior tied to transitions is harder to attach than with the interface form. Senior guidance: use the table when the machine is mostly about which transitions are legal and is data-driven (order/payment lifecycles); use the interface-per-state form when each state has substantial distinct behavior. Both beat a pile of boolean flags or nested ifs โ€” the real anti-pattern is implicit state spread across many flags.

Key points - Interface-per-state: one struct per state with event methods returning next state - Transition table: declarative map driving one engine; easy to validate/visualize - Table for legality-of-transition machines; interface form for behavior-heavy states - Both beat boolean-flag soup โ€” implicit state is the anti-pattern

type State string
type Event string

var transitions = map[State]map[Event]State{
    "pending":   {"pay": "paid", "cancel": "canceled"},
    "paid":      {"ship": "shipped", "refund": "refunded"},
    "shipped":   {"deliver": "delivered"},
}

func Next(s State, e Event) (State, error) {
    if next, ok := transitions[s][e]; ok {
        return next, nil
    }
    return s, fmt.Errorf("illegal transition %s --%s-->", s, e)
}

Follow-ups - How would you persist and resume a state machine across restarts? - When does the interface-per-state form pay off over the table?


27. How do you replace Template Method (which relies on inheritance) in idiomatic Go?

Difficulty: ๐ŸŸ  hard ยท Tags: behavioral, template-method, composition, higher-order-functions

Template Method defines an algorithm's skeleton in a base class, deferring specific steps to subclass overrides. Go has no subclass override, so the classic form is unavailable โ€” and that's good, because Template Method is exactly the fragile-base-class pattern. Idiomatic replacements: (1) Inject the varying steps as an interface or func values. The skeleton becomes a function/method that takes a Steps interface (or several funcs) and calls them at the right points โ€” the variation is explicit and the skeleton can't be silently broken by a subclass. (2) Higher-order functions โ€” pass the customizable step(s) as closures (process(data, transform func(x) y)). (3) Embedding for shared code + interface for the hook โ€” embed a helper providing the fixed steps, but require the hook via a passed interface, not a promoted method (remember embedded methods don't re-dispatch). The senior point: convert 'override these protected methods' into 'pass me these dependencies', turning implicit inheritance coupling into visible composition.

Key points - Classic Template Method needs subclass override โ€” unavailable and undesirable in Go - Inject varying steps via an interface or func values; skeleton calls them explicitly - Higher-order functions pass the customizable step as a closure - Embedding can't provide the hook (no re-dispatch) โ€” pass it in instead

// Skeleton takes the variable steps as funcs โ€” explicit, no inheritance.
func ETL(rows []Row, transform func(Row) Row, load func([]Row) error) error {
    out := make([]Row, 0, len(rows))
    for _, r := range rows {
        out = append(out, transform(r)) // the 'overridable' step
    }
    return load(out)
}

Follow-ups - Why is Template Method essentially the fragile base class problem? - How do you supply multiple hooks without a parameter explosion?


28. Why is the explicit Iterator pattern rare in Go? Discuss range, channels, closures, and range-over-func (Go 1.23 iterators).

Difficulty: ๐ŸŸ  hard ยท Tags: behavioral, iterator, range-over-func, channels, go1.23

Iterator gives sequential access without exposing the underlying representation. Go made the GoF heavyweight iterator object largely unnecessary because for ... range is built into the language for slices, maps, strings, and channels. Pre-1.23 options for custom sequences: (1) channels โ€” produce values on a channel and range it; clean syntax but costs a goroutine and risks leaks if the consumer stops early without cancellation; (2) closures โ€” a Next() (T, bool) function or a stateful closure; explicit and leak-free but verbose; (3) returning a slice โ€” fine for small/finite data, wasteful for large/lazy. Go 1.23 range-over-func changed this: you can now write a function of type func(yield func(T) bool) (or two-value) and range directly over it, giving lazy, pull-style iteration with no goroutine, native break/early-termination support (yield returns false), and composability โ€” this is now the idiomatic custom iterator (iter.Seq[T]/iter.Seq2[K,V]). Senior takeaway: prefer range-over-func for custom lazy sequences on modern Go; reserve channels for genuinely concurrent producers; avoid materializing huge slices.

Key points - for...range makes the GoF iterator object unnecessary for built-in containers - Channels: clean but goroutine cost and leak risk on early stop - Closures: Next()-style, leak-free but verbose - Go 1.23 range-over-func (iter.Seq) is the idiomatic lazy custom iterator โ€” no goroutine, supports break

// Go 1.23 range-over-func iterator: lazy, no goroutine, break-aware.
func Evens(max int) func(yield func(int) bool) {
    return func(yield func(int) bool) {
        for i := 0; i <= max; i += 2 {
            if !yield(i) { // consumer broke out
                return
            }
        }
    }
}

// for v := range Evens(10) { ... }

Follow-ups - What goroutine leak risk does a channel-based iterator carry? - How does yield returning false implement break semantics?


29. Chain of Responsibility in Go: how does it map to middleware, and how is it different from a simple loop?

Difficulty: ๐ŸŸก medium ยท Tags: behavioral, chain-of-responsibility, middleware, pipeline

Chain of Responsibility passes a request along a chain of handlers until one handles it (or all decline). In Go the dominant realization is the middleware chain: each handler decides whether to handle, transform, short-circuit, or pass to next. HTTP middleware (func(next Handler) Handler) is exactly this โ€” auth middleware can reject early (short-circuit), logging passes through, etc. The distinction from a simple loop: in a loop you'd iterate handlers and call each; CoR gives each handler control over whether and when the next link runs (before/after wrapping, conditional pass-through, early return), which a flat loop can't express as naturally. So CoR โ‰ˆ a composed pipeline where links wrap each other, vs a loop โ‰ˆ a flat dispatch. Other CoR uses: validation pipelines, request preprocessors, event handler chains, and error-recovery handlers. Senior nuance: keep links single-purpose, make the 'who terminates the chain' contract explicit, and beware deep chains hurting traceability โ€” name and order links deliberately (recovery outermost).

Key points - Middleware (func(next Handler) Handler) is the canonical CoR in Go - Each link controls whether/when next runs: wrap, short-circuit, conditional pass - Differs from a flat loop, which can't express before/after wrapping or early-out naturally - Keep links single-purpose; make termination explicit; order deliberately

type Handler func(req *Request) error
type Middleware func(next Handler) Handler

func Auth(next Handler) Handler {
    return func(req *Request) error {
        if !req.Authed {
            return fmt.Errorf("unauthorized") // short-circuit the chain
        }
        return next(req) // pass along
    }
}

func Chain(h Handler, mws ...Middleware) Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

Follow-ups - Why apply middleware in reverse order during composition? - How does CoR differ from the Decorator pattern mechanically?


30. When would you use the Mediator pattern in a Go backend, and what's the risk?

Difficulty: ๐ŸŸก medium ยท Tags: behavioral, mediator, event-bus, god-object

Mediator centralizes communication between many components so they don't reference each other directly โ€” they talk through a hub. In Go backends it shows up as an in-process event bus / dispatcher, a coordinator orchestrating several services (instead of each service calling the others), or a hub managing connected clients (a WebSocket chat hub mediating between connections is the textbook case). You'd reach for it when you have an Nร—N web of components whose direct coupling has become unmanageable; the mediator turns Nร—N into Nร—1. The risk is that the mediator becomes a god object โ€” all logic and knowledge migrate into the hub, which grows into an untestable bottleneck and a single point of change. Mitigation: keep the mediator thin (routing/coordination only, not business logic), and don't introduce one prematurely โ€” two or three components talking directly is fine. In distributed contexts the mediator generalizes to a message broker, but in-process you're trading direct coupling for centralization, so use it only when the coupling pain is real.

Key points - Centralizes Nร—N component communication into Nร—1 through a hub - Go forms: in-process event bus/dispatcher, orchestrator, WebSocket hub - Risk: mediator becomes a god object / bottleneck / single point of change - Keep it thin (routing only); don't introduce prematurely

Follow-ups - How do you keep a mediator from accumulating business logic? - How does Mediator differ from Observer in coupling direction?


31. Why is the Visitor pattern awkward in Go, and how do generics change the picture?

Difficulty: ๐Ÿ”ด staff ยท Tags: behavioral, visitor, type-switch, generics, double-dispatch

Visitor lets you add operations to a fixed set of types without modifying them, via double dispatch: each element has Accept(v Visitor) and calls back v.VisitConcrete(self). It's awkward in Go because: (1) Go has no method overloading, so the visitor interface needs a distinctly named method per concrete type (VisitCircle, VisitSquare), and (2) there's no inheritance to share Accept boilerplate, so every element type must hand-write Accept. Adding a new element type forces editing the visitor interface and every visitor implementation โ€” verbose and rigid. Many Go codebases instead use a type switch over an interface (switch n := node.(type)), which is simpler and centralizes the operation, at the cost of being non-exhaustive (the compiler won't tell you a case is missing) and open to forgetting a type. Generics help only partially: they don't give you double dispatch or method overloading, but a generic Walk[T] or generic visitor function can reduce some boilerplate for homogeneous traversals. The pragmatic senior call: prefer a type switch for AST-style operations in Go and accept the non-exhaustiveness (mitigate with a default panic and tests); reserve full Visitor for when you truly need multiple operations over a stable, closed type set and want each operation grouped.

Key points - Visitor needs double dispatch; Go lacks overloading so methods are named per type - No inheritance means every element hand-writes Accept โ€” verbose, rigid to extend - Type switch is the common Go alternative: simpler but non-exhaustive (no compiler check) - Generics don't provide double dispatch; they only trim some traversal boilerplate

// Idiomatic Go alternative to Visitor: a type switch over a closed interface.
func Eval(n Node) float64 {
    switch n := n.(type) {
    case Num:
        return n.Val
    case Add:
        return Eval(n.L) + Eval(n.R)
    case Mul:
        return Eval(n.L) * Eval(n.R)
    default:
        panic(fmt.Sprintf("unhandled node %T", n)) // guard for non-exhaustiveness
    }
}

Follow-ups - How do you make a type-switch visitor 'fail loud' on a missing case? - Could a sealed-interface convention plus a linter approximate exhaustiveness?


32. How do you implement Memento in Go for snapshot/restore, and what are the pitfalls?

Difficulty: ๐ŸŸก medium ยท Tags: behavioral, memento, undo, deep-copy

Memento captures and externalizes an object's internal state so it can be restored later, without violating encapsulation. In Go you provide a Snapshot() method returning an opaque state value (often an unexported-field struct or an exported Memento type the caller treats as a token) and a Restore(m Memento) method. The originator owns serialization of its own state, so encapsulation holds โ€” callers can hold the memento but not inspect/mutate the originals. Backend uses: undo/redo, transaction rollback to a checkpoint, optimistic-concurrency 'before' images, and editor/document history. Pitfalls: (1) shallow snapshots โ€” if the state contains slices/maps/pointers, you must deep-copy when snapshotting and when restoring, or the memento aliases live state and 'restore' won't isolate it; (2) memory โ€” keeping many full snapshots is expensive; consider diffs/command-based undo instead for large state; (3) staleness โ€” restoring an old memento into an object that's structurally changed. Often in Go the lighter Command-with-Undo approach is preferable to full state snapshots unless state is small or restore must be exact.

Key points - Snapshot() returns an opaque state token; Restore() reinstates it โ€” originator owns serialization - Encapsulation preserved: caller holds the memento but can't inspect internals - Deep-copy reference fields on both snapshot and restore or you alias live state - Many full snapshots are costly โ€” prefer command/diff-based undo for large state

type Editor struct{ content []string }

type Memento struct{ snapshot []string } // opaque to callers

func (e *Editor) Save() Memento {
    cp := make([]string, len(e.content)) // deep copy: don't alias live slice
    copy(cp, e.content)
    return Memento{snapshot: cp}
}

func (e *Editor) Restore(m Memento) {
    cp := make([]string, len(m.snapshot))
    copy(cp, m.snapshot)
    e.content = cp
}

Follow-ups - When is command-based undo preferable to full mementos? - How do you bound memory when keeping a long undo history?


Patterns Go Makes Trivial & Over-Engineering

33. Which GoF patterns does Go make unnecessary or trivial, and exactly which language feature dissolves each?

Difficulty: ๐ŸŸ  hard ยท Tags: patterns, go-idiom, trivial-patterns

Several GoF patterns exist mainly to work around limitations Go doesn't have: (1) Iterator โ€” dissolved by built-in for...range and, from Go 1.23, range-over-func; you rarely build an iterator object. (2) Strategy โ€” first-class functions make a strategy just a func value; no strategy hierarchy needed. (3) Decorator โ€” interfaces + closures (func(Handler) Handler middleware, io wrappers) make decoration trivial. (4) Adapter โ€” implicit interface satisfaction means an existing type often satisfies the target interface with zero adapter code; you only adapt when signatures truly differ. (5) Singleton โ€” a package-level var (with sync.Once) is the whole 'pattern'; the package is the namespace, no class needed. (6) Command โ€” a closure (func() error) is a command. (7) Template Method โ€” replaced by passing func/interface hooks. The unifying reason: GoF patterns are largely language-feature workarounds; features Go has natively (first-class funcs, implicit interfaces, packages, closures, range) absorb the pattern. The senior framing (Rob Pike / Peter Norvig): many patterns are 'invisible' or 'just code' in a language with the right primitives.

Key points - Iterator -> for...range / range-over-func - Strategy/Command -> first-class functions and closures - Decorator -> interfaces + middleware/io wrappers - Adapter -> implicit interface satisfaction; Singleton -> package var + sync.Once

Follow-ups - Why are many GoF patterns described as 'language-feature workarounds'? - Which patterns remain genuinely useful in Go despite this?


34. How do you recognize when a pattern has become over-engineering? What's the cost of premature abstraction in Go?

Difficulty: ๐ŸŸ  hard ยท Tags: patterns, over-engineering, premature-abstraction, yagni

Signs a pattern has become over-engineering: an interface with exactly one implementation and no test fake that needs it; a factory that only ever returns one concrete type; layers of indirection where the reader must jump through three files to see what a request does; a Manager/Service/Handler ecosystem wrapping a few lines of logic; pattern names appearing in type names (StrategyFactoryImpl) to justify their existence. The cost of premature abstraction in Go specifically: (1) interfaces defined before there are two implementations guess the wrong seams and become wrong abstractions you now must refactor across packages; (2) indirection defeats Go's strengths โ€” readability, grep-ability, and 'see the call' directness; (3) interfaces hurt inlining/devirtualization and add allocation; (4) speculative generality adds cognitive load and test surface for flexibility you never use. The Go cultural answer is to write the concrete code first, let duplication or a real second implementation reveal the seam, and then extract the interface ('a little copying is better than a little dependency'). Patterns are vocabulary for communicating a structure you arrived at, not a checklist to apply up front.

Key points - Smells: single-impl interface, single-type factory, deep indirection, pattern names in types - Premature interfaces pick wrong seams and become wrong abstractions - Indirection hurts readability, grep-ability, inlining; adds alloc and test surface - Write concrete first; extract the seam when a real second case appears

Follow-ups - How does 'a little copying is better than a little dependency' apply here? - Why is a single-implementation interface usually a smell in Go?


35. What does 'patterns are vocabulary, not goals' mean for how a senior engineer uses them in code review?

Difficulty: ๐ŸŸก medium ยท Tags: patterns, code-review, communication, design-philosophy

It means design patterns are a shared language for naming structures that emerge from solving real problems โ€” they're descriptive labels, not prescriptive targets. A senior uses them in review to communicate ('this is effectively a decorator chain; the order is wrong') and to recognize recurring shapes, not to mandate ('add a factory here because it's a pattern'). Practically: never approve or request a pattern for its own sake; ask what problem it solves and whether a simpler concrete solution would do. Reject 'patternitis' โ€” code where the abstraction exists to display knowledge rather than reduce real complexity or duplication. Conversely, naming a pattern can speed review and onboarding when the structure genuinely matches, because it imports a well-understood contract and trade-offs. The mature stance: arrive at structure by responding to forces (change axes, duplication, testing needs), and reach for a pattern's name afterward to describe and discuss it. The goal is working, maintainable software; patterns are one of several tools and a communication aid, not a scorecard.

Key points - Patterns are descriptive vocabulary for emergent structures, not prescriptive goals - Use them to communicate and recognize, not to mandate in review - Reject patternitis: abstraction that displays knowledge vs reduces real complexity - Arrive at structure via forces (change/duplication/testing), then name it

Follow-ups - How do you push back on a 'we should add a factory here' review comment? - When does naming a pattern genuinely speed up a review?


Dependency Injection & Interface Design

36. How is Dependency Injection done idiomatically in Go? Compare manual constructor injection with wire and fx.

Difficulty: ๐ŸŸ  hard ยท Tags: dependency-injection, wire, fx, constructor-injection

The default and preferred Go DI is manual constructor injection: each component takes its dependencies (as interfaces) as constructor params, and main (the composition root) builds the object graph explicitly. No framework, no reflection, fully type-checked, trivially testable (pass a fake). It scales further than people expect; many large Go services use nothing else. When the wiring graph gets large and tedious, two tools help. google/wire is compile-time DI: you declare provider functions and wire generates the boilerplate constructor code via go generate. It's just code generation โ€” zero runtime reflection, errors at compile time, and you can read the generated graph. uber-go/fx is runtime DI built on dig (reflection): you register providers and fx resolves/constructs the graph, and it adds lifecycle hooks (start/stop), which suits large apps with many long-lived components. Trade-off: wire keeps Go's compile-time guarantees and transparency; fx buys lifecycle management and less boilerplate at the cost of runtime reflection and harder-to-trace failures. Senior guidance: start with manual injection; adopt wire if boilerplate hurts and you want to keep compile-time safety; consider fx only for large apps that benefit from its lifecycle/module system.

Key points - Default: manual constructor injection with interfaces; main is the composition root - wire = compile-time codegen DI, no reflection, errors at build time, transparent - fx = runtime reflection DI with lifecycle (start/stop) hooks, for large apps - Start manual; wire to cut boilerplate safely; fx for big lifecycle-heavy graphs

// Manual constructor injection โ€” the Go default.
type Store interface{ Get(id string) (User, error) }
type Mailer interface{ Send(to, body string) error }

type UserService struct {
    store  Store
    mailer Mailer
}

func NewUserService(s Store, m Mailer) *UserService {
    return &UserService{store: s, mailer: m}
}

// main wires concrete impls: NewUserService(pg, ses)

Follow-ups - Why does wire keep Go's compile-time guarantees while fx doesn't? - What makes fx's lifecycle hooks valuable in large services?


37. Explain 'accept interfaces, return structs'. Why is it idiomatic and where does it not apply?

Difficulty: ๐ŸŸ  hard ยท Tags: interface-design, accept-interfaces-return-structs, idiom

The guideline: functions/constructors should accept interface parameters (so callers can pass any implementation, including fakes) but return concrete struct types (so callers get the full, discoverable API and you can add methods without breaking them). Why idiomatic: (1) accepting interfaces maximizes the caller's flexibility and decouples you from concretes; the interface should be the minimal set of methods you actually use, often defined by the consumer. (2) Returning a concrete struct gives callers all methods/fields, supports method autocompletion and documentation, and lets you grow the type's API additively โ€” returning an interface hides capability and pins you to that exact method set. It also avoids premature interface definitions. Where it doesn't apply / exceptions: (a) when you genuinely need to return one of several implementations chosen at runtime (factory) โ€” return an interface; (b) when returning a concrete type would leak internals you want hidden, return an interface deliberately; (c) the error interface is of course returned (it's the standard). Also note: returning interfaces can hurt because callers can't type-assert to richer behavior, and a nil concrete pointer boxed in an interface is the classic non-nil-interface bug.

Key points - Accept interfaces: caller flexibility, decoupling, easy fakes; interface should be minimal - Return structs: full discoverable API, additive growth, no hidden capability - Exceptions: factories returning one-of-many impls; deliberate hiding; error interface - Returning interfaces risks losing richer behavior and the nil-interface trap

// Accept a minimal interface, return a concrete struct.
type Reader interface{ Read(p []byte) (int, error) }

type Parser struct{ /* fields, methods can grow additively */ }

func NewParser(r Reader) *Parser { // accepts interface
    return &Parser{ /* ... */ } // returns concrete *Parser
}

Follow-ups - How does returning a concrete struct enable additive API growth? - Explain the nil-interface trap when returning an interface holding a nil pointer.


38. What is interface pollution, and how do you decide where and when to define an interface in Go?

Difficulty: ๐ŸŸ  hard ยท Tags: interface-design, interface-pollution, small-interfaces, anti-pattern

Interface pollution is defining interfaces with no real need โ€” typically a 'header interface' mirroring every method of a single concrete type, declared next to the implementer 'for testing' or 'for flexibility', when there's exactly one implementation and no second use. It adds indirection, defeats grep-ability, forces every method through a contract, blocks additive growth, and provides no abstraction value. Go's rule of thumb (codified in the proverb 'don't design with interfaces, discover them'): (1) Define interfaces at the point of consumption, not at the implementation. The package that uses a dependency declares the minimal interface for what it calls; the provider just satisfies it implicitly. (2) Wait for a real need: a second implementation, a genuine test seam you can't otherwise reach, or a published abstraction boundary. (3) Keep them small โ€” one to three methods. (4) Don't pre-export interfaces from library packages speculatively; return concrete types and let consumers define the interface they need. The mocking-only justification is the weakest one and the usual source of pollution โ€” prefer interfaces sized to the consumer over mirror interfaces sized to the implementation.

Key points - Pollution = needless/mirror interfaces, often one-impl, declared at the implementer - Discover interfaces, don't design them up front - Define minimal interfaces at the consumer, not next to the concrete type - Justify by real need (2nd impl, true test seam, boundary); 'for mocking' alone is weak

// Pollution: mirror interface next to the only implementation.
type UserStore interface { // BAD if there's one impl and no real consumer need
    Create(u User) error
    Get(id string) (User, error)
    Update(u User) error
    Delete(id string) error
}

// Better: consumer defines exactly what it uses.
// in service pkg:
type userGetter interface{ Get(id string) (User, error) }
func Greet(g userGetter, id string) (string, error) { /* ... */ return "", nil }

Follow-ups - Why is 'I need it for mocking' often a weak reason to add an interface? - How does defining interfaces at the consumer keep them small?