Go: Fundamentals
go, golang, interfaces, composition, struct embedding, duck typing, simplicity, zero values, idiomatic go
Introduction
The team came to Go from a decade of Java, and they brought the furniture with them. Within a month the codebase had grown a familiar shape: an AbstractBaseHandler embedded three layers deep so that a UserHandler could inherit from a SecuredHandler that inherited from a LoggedHandler that inherited from the base — a class hierarchy faked with struct embedding, because Go has no classes to inherit from. Every domain type carried a hand-written GetName() and SetName() around a field that was already public. Interfaces with a dozen methods were declared up front, in the package that defined the concrete type, “so we have the abstraction ready when we need it.” The result compiled, passed review, and was miserable to work in. Every new feature meant tracing four files up an embedding chain to find where a method actually lived. The interfaces were so large that test doubles took fifty lines to satisfy. The team was writing Java with Go’s keyword set, and fighting the language the whole way.
The turn came during a refactor nobody volunteered for. A new engineer, asked to add a metrics sink, deleted the AbstractBaseHandler chain instead of extending it. In its place she wrote a three-line interface — one method — defined in the package that consumed it, and a plain struct that happened to have that method. No implements, no base class, no getters. The handler shrank by half; the test double became four lines. The thing that had felt like a missing feature — Go’s refusal to give them inheritance, generics-everywhere, or exceptions — turned out to be the point. The codebase got simpler and more reliable not despite the language’s smallness but because of it. Once the team stopped importing habits from elsewhere, the friction disappeared. This chapter is about that shift: what Go’s defining choice actually is, and how to lean into it.
The Core Insight
Most languages compete on what they let you express. They add features — inheritance, operator overloading, exceptions, rich generics, metaprogramming — and each feature is a new way to be clever. Go made the opposite bet. Its defining choice is radical simplicity: a deliberately tiny feature set, chosen so that there is usually one obvious way to do a thing, and so that any engineer on a large team can read any other engineer’s code without first learning their personal dialect. Go optimizes for reading code over writing it, because in a codebase that outlives its authors, code is read far more often than it is written. Less is the feature.
That bet shows up in a handful of concrete absences, and each one is a presence in disguise:
- No classes, no inheritance. You cannot build a type hierarchy; instead you compose, embedding small structs into larger ones so their methods are promoted to the whole. There is no base class to trace up, because there is no “up.”
- No
implementskeyword. A type satisfies an interface simply by having the right methods — the relationship is inferred, never declared. This is the centerpiece, and it changes where interfaces live and how big they get. - No exceptions. Errors are ordinary values, returned alongside results and handled with an
if. The control flow you see is the control flow that runs. - One loop, one formatter, few keywords.
foris the only loop,gofmtends every style debate, and the language has roughly twenty-five keywords you mostly know already.
None of these is a limitation the way a missing library is a limitation. Each is a constraint that buys uniformity, and uniformity is what makes a large codebase legible to a large team.
A mental model
Hold three small pictures in your head and most of idiomatic Go follows from them.
The first is duck typing checked at compile time. The old slogan — if it walks like a duck and quacks like a duck, it’s a duck — describes Go interfaces exactly, with one upgrade: the check happens when you compile, not when you run. An interface is a list of method signatures, and any type that has those methods “qualifies” automatically, without ever announcing it. So a Writer is not a thing types descend from; it is a question the compiler asks of every value — does this have a Write of the right shape? — and any value that answers yes can be used where a Writer is expected.
The second picture is composition as assembly, not descent. In an inheritance language you build a Car by saying it is a Vehicle. In Go you embed an Engine and a Chassis struct inside it; the Car now has their methods, promoted as its own. You bolt small parts together rather than extend a lineage — and when behavior must change, you swap a part instead of rewriting a hierarchy.
The third picture is the zero value as a usable default. Every type in Go has a zero value it takes when declared without initialization — 0 for numbers, "" for strings, nil for pointers and maps and slices, and a struct whose fields are each their own zero. The standard library is designed so that, wherever possible, the zero value is already useful: a sync.Mutex is ready to lock the moment it exists, a bytes.Buffer is ready to write to. You do not call a constructor to make these valid; they are born valid. Designing your own types so their zero value works is one of the quiet habits that separates Go that fights the language from Go that flows with it.
When Go’s model fits
Go’s simplicity is not free, and it is not always the right trade. The language pays for readability with a certain bluntness: until recently no generics at all, and even now a culture that reaches for them sparingly; explicit error handling that can feel repetitive; a garbage collector that, while excellent, is still a garbage collector. You are choosing boring, uniform, fast-to-build over maximally expressive.
Figure 16.1 shows the structural shape that makes this trade pay off — small interfaces at the consumer, concrete types satisfying them for free, behavior composed from parts. That shape is what makes Go services easy to read and easy to test, and it is why Go landed where it did on the language map.
Reach for Go when readability across a team, fast compile-and-deploy cycles, and straightforward concurrency matter most — which describes a huge swath of modern infrastructure: network services and APIs, microservices, CLI tools, cloud-native plumbing. It is no accident that Docker, Kubernetes, Terraform, and most of the cloud control plane are written in Go — the language was built at Google for exactly this band of work, and its single static binary, millisecond startup, and goroutine-based concurrency are tuned for servers and tools.
Reach for something else when Go’s bets cut against you. For heavy numerical or ML work, Python’s ecosystem (NumPy, PyTorch) is the gravity well and Go has no answer. For zero-GC, bare-metal control — high-frequency trading, embedded systems, hard real-time — Rust or C++ give you the determinism a garbage collector cannot promise. For browser and native mobile UIs, Go simply has no story. Go sits in the middle band of the language landscape: more abstraction and safety than C++ or Rust, more control and speed than Python or TypeScript, and a deliberate refusal to be the best at either extreme in exchange for being the easiest to build reliable services in.
What you’ll learn
- Why Go interfaces are implicit and small, and how “accept interfaces, return structs” reorganizes where abstractions live in a codebase
- How
io.Readerandio.Writerbecame the most reused interfaces in the language by being exactly one method each - How struct embedding gives you composition — and why Go’s lack of inheritance is a design choice, not an omission
- Why Go types are designed so the zero value is useful, and how that removes most of the need for constructors
- The core idioms — multiple return values,
defer, the empty interface andany, and the real behavior of slices and maps — that make Go code read like Go - How to treat simplicity as a discipline: choosing explicit over implicit, and resisting the urge to import cleverness from other languages
Prerequisites
- Programming basics: variables, functions, loops, conditionals, and the idea of a type. If you have written non-trivial code in any language, you have enough.
- Comfort at a shell: running commands, reading output, following a compiler’s error messages.
- Helpful but not required: prior exposure to an object-oriented language, mainly so you can feel what Go deliberately leaves out.
Interfaces are implicit and small
The single most important thing to understand about Go is its interfaces, because almost everything idiomatic in the language flows from how they work. An interface is a named set of method signatures — and that is all. A type satisfies the interface if it has methods with those names and shapes. Crucially, the type never says so. There is no implements clause, no registration, no inheritance from the interface. The compiler simply checks, wherever you try to use a value as an interface, whether that value’s type has the required methods. This is structural typing: membership is determined by shape, not by declaration.
This is a small syntactic difference and a large architectural one. With explicit implements, type and interface are coupled at the point of definition — the type author must know about the interface and opt in. In Go, a type can satisfy an interface written years later, by someone who never saw the type, in a package the type doesn’t import. The relationship is discovered, not declared.
That decoupling drives the most important Go convention there is: define interfaces at the consumer, not the implementer, and keep them small. Because a type doesn’t declare which interfaces it satisfies, the package that uses a behavior is the right place to describe it — and only the slice it actually needs. A function that saves data doesn’t need a FileSystem interface with twenty methods; it needs one, Write, so that is the interface it should ask for. Below, the consumer declares exactly its dependency, and any value with a matching Write method — a real file, an in-memory buffer, a network connection, a test double — flows straight in.
// The consumer declares the smallest interface it needs.
type Writer interface {
Write(p []byte) (n int, err error)
}
// Save accepts the interface, not a concrete type — so it works with anything
// that has a Write method, including a fake in a test.
func Save(w Writer, data []byte) error {
_, err := w.Write(data)
return err
}The standard library is built almost entirely out of this idea, and io.Reader and io.Writer are its purest expression. Each is a single-method interface, and because they are so small an enormous variety of types satisfy them: files, network sockets, byte buffers, gzip streams, HTTP request bodies, encryption wrappers. Any function written against io.Reader works with all of them, and any new type that adds a Read method joins for free. The lesson is that interface power is inversely proportional to interface size: the smaller the interface, the more types satisfy it and the more places it can be reused. A one-method interface is a universal joint; a twelve-method interface is a straitjacket.
The companion rule is accept interfaces, return structs. Take the narrowest interface that describes what you need, so callers have maximum freedom in what they pass; but return concrete struct types, not interfaces, so callers get the full documented type and can decide for themselves what interface to view it through. Accepting interfaces keeps inputs flexible; returning structs keeps outputs honest.
Build it → See implicit interfaces organize a real multi-service system in Project 02: Microservice Platform, the repository’s Go system — gRPC services behind a Kong gateway, where small consumer-defined interfaces and the standard
ioandhttpinterfaces are the seams between handlers, transports, and storage.
Composition over inheritance
Go has no classes and no inheritance, and this is the second thing that throws newcomers from object-oriented languages. There is no extends, no base class, no super. What Go offers instead is struct embedding: you place one struct type inside another without a field name, and the inner type’s fields and methods are promoted to the outer type, callable as though they belonged to it directly.
The distinction between embedding and a normal field is the whole trick. A normal field gives the outer struct a named member whose methods you reach through that name. An embedded field gives the outer struct the inner type’s methods as its own: a Server that embeds a Logger can call server.Log(...) directly — the method is promoted — while one with a named logger Logger field must call server.logger.Log(...). Embedding is composition with method promotion; the outer type has the inner type’s behavior, presented as its own.
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { /* write prefixed line */ }
type Store struct{ /* ... */ }
func (s Store) Get(key string) (string, bool) { /* ... */ return "", false }
// Server is assembled from parts. It has Log and Get for free, promoted.
type Server struct {
Logger // embedded — Server.Log works directly
Store // embedded — Server.Get works directly
addr string // a normal field, reached as Server.addr
}This looks like inheritance and is fundamentally different. With inheritance, a subclass is a superclass, bound permanently, and a change to the base ripples down through every descendant whether they wanted it or not. With embedding, a Server has a Logger — it holds a value, delegates to it, and can swap or wrap it without disturbing a hierarchy, because there is no hierarchy. Composition assembles behavior from small independent parts; inheritance descends from a lineage you cannot easily escape. Go supports only the first, because deep inheritance trees are one of the most reliable sources of unreadable code in large systems — the exact thing Go’s simplicity is meant to prevent.
The trap is to use embedding to rebuild inheritance — stacking embedded types three deep to fake a class hierarchy, the mistake from the opening story. Embedding is for “this type is partly made of that capability,” not for “this type is a kind of that type.” Embed a Logger because your server logs; do not embed an AbstractHandler because you want a base class. Method promotion is a convenience for delegation, not a back door to a type tree.
The zero value is useful
In many languages a freshly declared object is a hazard — null, uninitialized memory, an object that throws until you call its constructor. Go takes the opposite stance. Every type has a well-defined zero value that a variable assumes when declared without an initializer, and idiomatic Go types are designed so that zero value is immediately usable — which removes most of the ceremony other languages spend on constructors.
The zero values are mechanical: numeric types are 0, strings "", booleans false, pointers and interfaces and maps and slices and channels and functions nil, and a struct’s zero value is a struct whose every field holds its own zero. The design goal is that these defaults mean something sensible. The canonical example is sync.Mutex: a freshly declared var mu sync.Mutex is a valid, unlocked mutex you can use right away — there is no NewMutex(). Likewise a zero bytes.Buffer is an empty buffer ready to be written to. The library authors arranged the struct fields so that all-zero is a working initial state.
// No constructor needed: the zero value of each of these is ready to use.
var mu sync.Mutex // unlocked, usable immediately — mu.Lock() works
var buf bytes.Buffer // empty buffer, ready — buf.WriteString("hi") works
var counts map[string]int // BUT: nil map — safe to READ, panics on WRITEThe map on that last line is the one sharp edge, and the most common beginner panic in Go. A nil map (a map’s zero value) is safe to read — indexing returns the value type’s zero — but writing to it panics with assignment to entry in nil map. Initialize maps with make (or a literal) before writing, which is why a map field, unlike a mutex, needs setup. Slices are friendlier: a nil slice is a valid empty slice you can range over and append to, because append allocates as needed. The practical takeaway is to design your own types so their zero value works, as the standard library does — and when a field genuinely needs setup, provide a small constructor and make it the only blessed way to build the type.
The idioms that make Go read like Go
A handful of small idioms, used everywhere, are most of what makes Go code recognizable as Go. The first is multiple return values, the foundation of the language’s whole approach to errors. A function returns its result and an error side by side, and the caller handles the error immediately with an if. This is why you see the same shape on nearly every line that can fail: call, check, proceed.
result, err := divide(10, 2)
if err != nil {
return err // handle the failure right here, then move on
}
// the happy path continues at the left marginThis keeps the happy path flush against the left margin, with each failure handled on its own line where it happens — no separate catch block far away in the file. It is more verbose than exceptions, and that verbosity is the point: every place a thing can fail is visible in the source. (Error handling is a deep enough topic to have its own chapter; here it is enough to see that explicit returns are the mechanism.)
The second idiom is defer, which schedules a call to run when the surrounding function returns, no matter how it returns — Go’s answer to “make sure this cleanup happens” for closing a file, unlocking a mutex, or closing a response body. Because the cleanup sits immediately next to the acquisition, you can see at a glance that every opened thing is closed, even on an early error return.
f, err := os.Open("data.txt")
if err != nil {
return err
}
defer f.Close() // runs when the function returns, on any path
// ... use f freely; cleanup is already guaranteedThe third is the empty interface, written interface{} in older code and any since Go 1.18 — exact synonyms. Because every type has at least zero methods, every type satisfies the empty interface, so an any can hold a value of any type. It is Go’s escape hatch for genuinely heterogeneous data (decoded JSON, a fmt.Println argument list), and it should be used sparingly: the moment you store something as any you have thrown away its static type and must recover it with a type assertion or type switch before you can do anything specific. Reach for any only when you truly cannot name the type; prefer a small interface or, where it fits, generics.
The last pieces are slices and maps, Go’s everyday collections, each with one surprise. A slice is a lightweight three-word view — a pointer into a backing array, a length, and a capacity — so slices are cheap to pass but share their backing storage. Re-slicing (s[1:4]) creates a new view over the same array, so writes through one view show up in another, and append overwrites shared elements if there is spare capacity; use copy or the three-index slice expression when you need independence. Maps are hash tables created with make or a literal, with the comma-ok idiom — v, ok := m[key] — to distinguish “present with a zero value” from “absent.” And map iteration order is deliberately randomized: never depend on the order range walks a map, because Go goes out of its way to make sure you can’t.
A team porting a service from Java declared its interfaces the way Java does — big, and on the implementer’s side. The storage package exported a Storage interface with fourteen methods (Get, Put, Delete, List, Scan, BatchGet, Watch, and seven more) and a PostgresStore declared to implement it. Every consumer imported the whole fourteen-method interface even if it called only Get. Two costs landed immediately. First, testing was agony: a unit test for a function that needed only Get still had to supply a fake implementing all fourteen methods, so every test file carried a sixty-line do-nothing stub. Second, the giant interface became a coupling magnet — because it lived in the storage package and everyone imported it, no consumer could shrink it, and adding a fifteenth method broke every fake in the codebase at once.
The fix was pure idiomatic Go and almost mechanical. They deleted the exported Storage interface; PostgresStore went back to being a plain struct. Each consumer then declared the tiny interface it actually used — often one method — right where it used it: a reporting package defined type getter interface { Get(id string) (Record, error) } and accepted that. The fourteen-method stub vanished, test doubles became one or two methods each, and the concrete PostgresStore satisfied every small interface for free, having never heard of any of them. The rule they wrote down: interfaces belong to the consumer and should be as small as the consumer’s need — defining a big interface on the implementer is importing a habit Go was built to do without.
Simplicity as a discipline
Everything above is downstream of one idea, and it is worth naming directly because it is the part you have to practice rather than merely learn: in Go, simplicity is a discipline, not a default you get for free. The language removes a lot of ways to be clever, but it cannot stop you from importing cleverness from elsewhere — the fourteen-method interface, the three-deep embedding chain, the any-typed soup that defers all type checking to runtime. Writing good Go is largely the discipline of not doing those things, of preferring the plain version even when a clever one is available.
The guiding value is explicit over implicit. You see the errors because they are returned, not thrown; you see the cleanup because it is deferred in plain sight, not hidden in a destructor; you see what a function depends on because it is in the parameter list as a small interface, not reached through a global or a deep base class. This explicitness is sometimes more typing and always more reading-friendly — which, in a codebase a team will live in for years, is the trade that pays. The cultural norms reinforce it: gofmt ends every style debate; the community treats panic/recover as a last resort, not a control-flow tool; and even after generics arrived in 1.18, idiomatic Go reaches for them sparingly. The instinct to ask “what is the smallest, most boring version of this that works?” is the single most valuable habit you can build, and it is what separates Go that fights the language from Go that flows with it.
Practical exercise
Difficulty: Level I · Level II · Level III
Level I — Model a small domain with composition. Build a tiny notification system: a
Notifierinterface with exactly one method,Notify(msg string) error, and two concrete struct types that satisfy it (say, anEmailNotifierand aConsoleNotifier) — neither declaring that it implements anything. Then build anAlerterstruct by embedding a smallLoggerstruct so the alerter gets aLogmethod for free. Write a function that accepts aNotifierand sends through it. Confirm that you used no inheritance, no getters/setters, and that each type satisfies the interface purely by having the method.Level II — Refactor an over-large interface. Start from (or write) a struct with a single fat interface declared on the implementer’s side — eight or more methods. Refactor it to idiomatic Go: delete the big interface, leave the struct as a plain concrete type, and have each consumer declare the small interface it actually needs (often one method) at the point of use. Change at least one function to accept the interface and return the struct. Measure the win concretely: how many lines does a test double shrink to before and after, and how many methods does the largest interface in your code now have?
Level III — Design a pluggable component on implicit interfaces. Design a small processing pipeline whose stages are pluggable through a one-method interface, so a new stage can be added by any package without modifying the pipeline. Make at least one type rely on its zero value being usable (no constructor required). Then write a short design note explaining how Go’s structural interface satisfaction differs from nominal
implementsin Java or C++ — what becomes possible when a type can satisfy an interface it has never heard of — and how Go’s approach compares to Python’styping.Protocol, which is also structural but checked by an external type checker rather than the language’s own compiler.
Summary
Go’s defining choice is radical simplicity: a tiny feature set, chosen so that code is uniform and easy to read across a large team, optimizing for reading over clever writing. The centerpiece is the implicit interface — a type satisfies an interface just by having the right methods, with no implements keyword — which lets you define interfaces small and at the consumer, and to “accept interfaces, return structs.” There is no inheritance; behavior is built by composition, embedding small structs so their methods are promoted to the whole. Types are designed so their zero value is useful, removing most of the need for constructors. A handful of idioms — multiple returns for errors, defer for cleanup, any as a sparingly-used escape hatch, and the real reference-semantics of slices and maps — make code read like Go. And underneath it all, simplicity is a discipline: the practice of choosing the plain, explicit version and refusing to import cleverness the language was built to do without.
Key takeaways
- Interfaces are satisfied implicitly and should be small and consumer-defined — the smaller the interface, the more types satisfy it and the more it is reused;
io.Reader/io.Writerare one method each for exactly this reason. - “Accept interfaces, return structs”: narrow inputs for caller freedom, concrete outputs for caller honesty.
- Go has no inheritance by design; you compose with struct embedding (which promotes methods) — and you must resist using embedding to fake a class hierarchy.
- The zero value is meant to be usable, so most types need no constructor — with maps the sharp exception (read-safe, write-panics-when-nil; initialize with
make). - Simplicity is a discipline: explicit over implicit, errors as values,
gofmtover style debates, and generics used sparingly rather than everywhere.
Connections to other chapters
- The Polyglot Landscape (context): Go sits squarely in the middle band of the language map — more safety and abstraction than C++/Rust, more control and speed than Python/TypeScript — and simplicity is the design axis it optimizes. That chapter’s framing of “one language per workload” is exactly why Go owns the service-and-tooling band rather than trying to be best everywhere.
- Concurrency and Parallelism Models (extension): the small implicit interfaces from this chapter and goroutines are the two halves of the Go service toolkit. Once you can compose behavior from interfaces, concurrency is how you make those compositions do many things at once — Go’s goroutines and channels are covered there alongside the threads and async runtimes of the other languages, all built on type foundations like the ones laid here.
- Error Handling (extension): the explicit, returned errors introduced here as an idiom get their full treatment there — wrapping with
%w,errors.Is/errors.As, custom error types, and the nil-interface trap — set beside how the other languages handle failure. Explicit errors are the same “no magic” philosophy as implicit interfaces, applied to failure. - Python: Advanced Language Features (contrast): Python’s
typing.Protocolis also structural typing — a class satisfies a protocol by shape, not declaration, just like Go — but it is checked by an external tool (mypy) rather than the language itself, and it sits in a world that still has full inheritance and exceptions. Comparing Go’s compiler-enforced structural interfaces with Python’sProtocoland with Java/C++’s nominalimplements/extendsis the cleanest way to see what Go’s choice actually costs and buys.
Further reading
Essential
- Donovan & Kernighan, The Go Programming Language — the definitive book; the chapters on interfaces and composition are the canonical statement of everything above.
- Effective Go (the official guide) — the source of the idioms: small interfaces, embedding, the
deferpattern, and the zero-value conventions, straight from the language team.
Deep dives
- Rob Pike, “Simplicity is Complicated” (talk) — the argument for why a small language is hard to design and worth the effort, by one of Go’s creators.
- Go Proverbs (Rob Pike) — short, memorable distillations: “the bigger the interface, the weaker the abstraction,” “accept interfaces, return structs,” “a little copying is better than a little dependency.”
Historical context
- The CSP lineage (Hoare, Communicating Sequential Processes, 1978) and the Plan 9 / Newsqueak heritage that Pike, Thompson, and Griesemer carried into Go — the reason Go’s concurrency and its minimalism feel of a piece, drawn from decades of systems work before the language existed.