Go and the Art of Narrow Abstractions
Design
Go is the language people complain about and keep using. It has no generics (well, it didn’t for a decade). No inheritance. No exceptions. No operator overloading. No macros. Programmers coming from C++ or Rust look at it and see a language that’s missing half the toolbox. And yet Go powers some of the most demanding infrastructure on the planet - Docker, Kubernetes, Terraform, CockroachDB, the list goes on. Something is working.
I think I know what it is. Go’s designers made a bet: that a small number of deep, narrow abstractions would beat a large number of shallow, feature-rich ones. And they were right.
This isn’t just my opinion. There’s a framework for understanding why, and it comes from John Ousterhout’s A Philosophy of Software Design. The book gives us precise language for what Go got right - and where it paid a price.
The Deep Module Thesis
Ousterhout’s central insight is that the best modules are deep: powerful functionality hidden behind a simple interface. He puts it plainly:
“The best modules are those that provide powerful functionality yet have simple interfaces. I use the term deep to describe such modules.”
-- John Ousterhout, A Philosophy of Software Design
The opposite is a shallow module - one where the interface is nearly as complex as the implementation. You learn a lot of API surface and get very little in return:
“A shallow module is one whose interface is complicated relative to the functionality it provides. Shallow modules don’t help much in the battle against complexity, because the benefit they provide (not having to learn about how they work internally) is negated by the cost of learning and using their interfaces.”
-- John Ousterhout, A Philosophy of Software Design
Ousterhout uses Unix file I/O as his canonical example of depth. Five system calls - open, read, write, lseek, close - hide hundreds of thousands of lines of implementation dealing with disk layout, caching, permissions, scheduling, and device drivers. The interface is tiny. The implementation is enormous. That’s depth.
Go is full of this pattern.
io.Reader: One Method, Infinite Depth
The deepest interface in Go’s standard library is io.Reader:
type Reader interface {
Read(p []byte) (n int, err error)
}One method. That’s the entire contract. And yet this single method is implemented by files, network connections, HTTP response bodies, compression streams, cipher streams, strings, byte buffers, and anything else that produces bytes. Rob Pike captured the design principle behind this as a Go Proverb:
“The bigger the interface, the weaker the abstraction.”
-- Rob Pike, Go Proverbs
io.Writer follows the same pattern:
type Writer interface {
Write(p []byte) (n int, err error)
}The power shows up in composition. Because these interfaces are so narrow, you can snap them together like garden hose segments. Doug McIlroy saw this in 1964:
“We should have some ways of coupling programs like garden hose - screw in another segment when it becomes necessary to massage data in another way. This is the way of IO also.”
-- Doug McIlroy, quoted in “Less is exponentially more”
In Go, io.Copy connects any Reader to any Writer in a single call:
// Copy from an HTTP response body to a file.
// No intermediate buffer management. No type adapters.
resp, err := http.Get(”https://example.com/data”)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(”data.bin”)
if err != nil {
return err
}
defer out.Close()
io.Copy(out, resp.Body)A network socket feeds into a file. No adapter classes. No wrapper hierarchies. The two sides have never heard of each other, but they compose because both speak the same one-method protocol.
This is Ousterhout’s deep module in its purest form: a trivial interface hiding arbitrary implementation complexity.
Goroutines: The Deepest Module in Go
If io.Reader is Go’s deepest interface, goroutines are its deepest module. The interface is two characters:
go handleConnection(conn)That’s it. Behind those two characters, the Go runtime manages:
Dynamic stacks starting at just 2KB, growing as needed up to 1GB. A goroutine’s memory footprint is lean by default and adapts under load.
User-space context switching at roughly 200 nanoseconds per switch - no kernel transition, no heavyweight thread state save/restore. Compare that to 1-10 microseconds for an OS thread context switch.
M:N scheduling that multiplexes millions of goroutines onto a handful of OS threads, with work-stealing across processor cores.
Cooperative preemption and stack-growth checks inserted by the compiler at function prologues.
The Cloudflare engineering team documented the stack mechanics:
“In Go, goroutines do not have a fixed size stack. Instead they start small (2KB) and grow and shrink as needed. When a goroutine runs out of stack space, the stack is automatically doubled. The runtime also adjusts all pointers to ensure they reference correct addresses in the new location.”
-- Cloudflare, “How Stacks are Handled in Go”
This is what a deep module looks like. You write go f() and the runtime handles scheduling, memory management, stack growth, and preemption. The programmer sees two characters. The runtime sees thousands of lines of highly tuned assembly and C.
Channels complete the picture. Communication between goroutines happens through typed, synchronized channels:
ch := make(chan string)
go func() {
ch <- “hello from goroutine”
}()
msg := <-ch
fmt.Println(msg)The Go Proverb says it best:
“Don’t communicate by sharing memory, share memory by communicating.”
-- Rob Pike, Go Proverbs
Channels replace mutexes and condition variables with a single abstraction that is both simpler to use and harder to misuse. The concurrency model is powerful and performant - not one at the expense of the other.
defer: Cleanup Without Ceremony
Most languages that want guaranteed resource cleanup require class machinery - constructors, destructors, move semantics. Go takes a different path:
f, err := os.Open(”config.json”)
if err != nil {
return err
}
defer f.Close()
// ... work with f ...
// f.Close() runs automatically when the function returns,
// no matter how it returns.defer schedules a function call to run when the enclosing function exits. No classes. No destructors. No special syntax for “this object owns that resource.” You open something, you defer the close, and you move on. The cleanup is visible right next to the acquisition, and it is guaranteed to execute regardless of panics or early returns.
It is a narrow abstraction - one keyword, one behavior - but it solves a real problem that other languages throw significant machinery at.
Composition Over Inheritance
Rob Pike drew the line clearly:
“If C++ and Java are about type hierarchies and the taxonomy of types, Go is about composition.”
-- Rob Pike, “Less is exponentially more”
In Go, interfaces are satisfied implicitly. There is no implements keyword. If your type has the right methods, it satisfies the interface. Period:
type Logger interface {
Log(msg string)
}
// FileLogger satisfies Logger without declaring it.
// No “implements” clause. No base class. No registration.
type FileLogger struct {
file *os.File
}
func (l *FileLogger) Log(msg string) {
fmt.Fprintln(l.file, msg)
}This design avoids the taxonomy problem that Pike describes. You never have to decide whether FileLogger should inherit from AbstractLogger which inherits from BaseLogger. Types are coupled only by what they can do, not by what hierarchy they belong to.
Ousterhout warns about the opposite extreme - what he calls classitis:
“The extreme of the ‘classes should be small’ approach is a syndrome I call classitis, which stems from the mistaken view that ‘classes are good, so more classes are better.’ In systems suffering from classitis, developers are encouraged to minimize the amount of functionality in each new class.”
-- John Ousterhout, A Philosophy of Software Design
His example is Java I/O. To open a file and read serialized objects, you need three separate wrapper classes:
FileInputStream fileStream =
new FileInputStream(fileName);
BufferedInputStream bufferedStream =
new BufferedInputStream(fileStream);
ObjectInputStream objectStream =
new ObjectInputStream(bufferedStream);Three objects. Two of them are never referenced again after construction. And if you forget to add BufferedInputStream, your program silently runs with no buffering. That’s shallow: lots of interface surface, not much depth per layer.
Go’s standard library avoids this entirely. Buffering, compression, and encryption are all Reader/Writer wrappers that compose without ceremony:
file, _ := os.Open(”data.gz”)
gzReader, _ := gzip.NewReader(file)
scanner := bufio.NewScanner(gzReader)Each layer does one thing. Each layer composes through the same one-method interface. No classitis.
The Standard Library: Deep by Default
Go ships with a standard library that covers networking, cryptography, encoding, compression, and platform abstractions out of the box. net/http alone is a production-grade HTTP server in a few lines:
http.HandleFunc(”/hello”, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, “Hello, %s”, r.URL.Path[7:])
})
http.ListenAndServeTLS(”:443”, “cert.pem”, “key.pem”, nil)That’s a TLS-enabled web server. No third-party frameworks. No dependency trees. The standard library is deep enough that you can build real services without leaving it.
This matters because every external dependency is a shallow module risk. Each one adds interface surface - its API, its versioning, its transitive dependencies - without necessarily adding proportional depth. Pike addressed this directly:
“Go is more about software engineering than programming language research. Or to rephrase, it is about language design in the service of software engineering.”
-- Rob Pike, “Go at Google”
The Go Proverb puts the tradeoff in concrete terms:
“A little copying is better than a little dependency.”
-- Rob Pike, Go Proverbs
A rich standard library means fewer shallow shims between your code and the operating system. The platform abstractions are already deep. You build on top of them instead of reinventing them.
No Magic
Go avoids implicit behavior. There is no operator overloading, no implicit conversions, no hidden constructors, no annotation-driven code generation at compile time. The program behaves the way it reads.
“Clear is better than clever.”
-- Rob Pike, Go Proverbs
This predictability is itself a form of depth. When every function call does exactly what it says, the programmer’s mental model of the program stays accurate. There are no hidden costs, no surprise allocations, no invisible middleware intercepting method calls. The abstraction is narrow, but it is honest.
Pike explained the philosophy behind this constraint:
“What you’re given is a set of powerful but easy to understand, easy to use building blocks from which you can assemble - compose - a solution to your problem. It might not end up quite as fast or as sophisticated or as ideologically motivated as the solution you’d write in some of those other languages, but it’ll almost certainly be easier to write, easier to read, easier to understand, easier to maintain, and maybe safer.”
-- Rob Pike, “Less is exponentially more”
Where Go Pays the Cost
Narrow abstractions have tradeoffs. Go made deliberate choices that create friction, and honesty requires acknowledging them.
Error Handling: The Shallow Spot
Go’s error handling is the one place where the language chose breadth over depth. The error interface is admirably narrow:
type error interface {
Error() string
}But the usage pattern is verbose:
f, err := os.Open(name)
if err != nil {
return fmt.Errorf(”open config: %w”, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf(”read config: %w”, err)
}
var cfg Config
err = json.Unmarshal(data, &cfg)
if err != nil {
return fmt.Errorf(”parse config: %w”, err)
}Three operations, nine lines of error handling. Ousterhout has a name for this problem:
“Throwing exceptions is easy; handling them is hard. Thus, the complexity of exceptions comes from the exception handling code. The best way to reduce the complexity damage caused by exception handling is to reduce the number of places where exceptions have to be handled.”
-- John Ousterhout, A Philosophy of Software Design
His prescription is to define errors out of existence - design APIs so that exceptional conditions simply do not arise. Go’s approach is the opposite: every error is explicit, every call site handles it individually. The Go Proverb says “Errors are values,” and that’s true, but it means error handling is spread across every function rather than aggregated or masked.
This is the one area where Go’s narrowness works against depth. The interface is simple. The pattern it creates at scale is not.
No Sum Types
Go lacks algebraic data types. You cannot express “this value is one of exactly these three shapes” and have the compiler verify exhaustiveness. The workaround - empty interfaces with type switches - works at runtime but gives up compile-time guarantees. This is a genuine gap where the narrow type system forces programmers to solve problems that other languages handle structurally.
Late Generics
Go shipped without generics for over a decade. This meant that generic data structures required either code duplication or interface{} with runtime type assertions - both shallow patterns by Ousterhout’s measure. Generics arrived in Go 1.18 (2022), deliberately constrained to avoid the complexity of C++ templates. The jury is still out on whether the constraints went too far, but the conservative approach is consistent with Go’s philosophy: add nothing until you understand the cost.
The Bigger Picture
Rob Pike laid out the core thesis in 2012:
“Did the C++ committee really believe that was wrong with C++ was that it didn’t have enough features? Surely, in a variant of Ron Hardin’s joke, it would be a greater achievement to simplify the language rather than to add to it.”
-- Rob Pike, “Less is exponentially more”
And:
“Less can be more. The better you understand, the pithier you can be.”
-- Rob Pike, “Less is exponentially more”
This is Ousterhout’s deep module principle stated in a programmer’s voice. The language that ships fewer features but makes each one deep wins the adoption game. Go’s io.Reader does more with one method than most type hierarchies do with fifty. Goroutines do more with two characters than most threading libraries do with an entire API. defer does more with one keyword than RAII does with constructors, destructors, move semantics, and the rule of five.
The evidence isn’t anecdotal. Go powers the container orchestration layer (Kubernetes), the container runtime (Docker), the infrastructure provisioning layer (Terraform), and large swaths of the cloud platform business at every major provider. These are not toy programs. They are mission-critical systems built by large teams under real production pressure. And they chose the language with the smallest feature set.
That should tell us something.
References
John Ousterhout, A Philosophy of Software Design, Stanford University, 2018.
Rob Pike, “Less is exponentially more”, 2012.
Rob Pike, “Go at Google: Language Design in the Service of Software Engineering”, SPLASH 2012.
Rob Pike, Go Proverbs, Gopherfest 2015.
Rob Pike, “Simplicity is Complicated”, dotGo 2015.
Tpaschalis, “Deep vs Shallow Go interfaces”.
Dave Cheney, “Don’t just check errors, handle them gracefully”, 2016.
Cloudflare, “How Stacks are Handled in Go”.

