The Mental Model Shift
Series
Go for JVM EngineersPart 2 →
Errors as Values
If you have spent years in the JVM ecosystem, picking up Go feels deceptively easy for the first hour and then genuinely disorienting for the next week. The syntax is small, the standard library is pragmatic, but the underlying model of how types, memory, and composition work is different enough to cause subtle bugs and design mistakes if you carry Java assumptions into Go code.
This post unpacks the three shifts that matter most: the absence of a class hierarchy, structural (duck) typing, and the distinction between values and pointers.
No Class Hierarchy — Composition Over Inheritance
In Java, the first question when modelling a domain is usually "what is the base class?" In Go, there is no base class. There is no extends, no abstract, no super. The only mechanism for code reuse is embedding a struct into another struct.
// Java — inheritance
public abstract class Animal {
protected String name;
public abstract String speak();
public String describe() { return name + " says " + speak(); }
}
public class Dog extends Animal {
public Dog(String name) { this.name = name; }
public String speak() { return "Woof"; }
}// Go — composition via embedding
type Animal struct {
Name string
}
func (a Animal) Describe(voice string) string {
return a.Name + " says " + voice
}
type Dog struct {
Animal // embedded, not inherited
Breed string
}
func main() {
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Describe("Woof")) // promoted method
}Embedding promotes the embedded type's fields and methods to the outer type, but it is not polymorphism. A Dog is not an Animal in the type system; you cannot pass a Dog where an Animal is expected unless an interface is involved.
Structural Typing — Interfaces Without Declaration
This is where Go diverges most sharply from Java. In Java you declare that a class implements an interface. In Go, a type satisfies an interface automatically if it has the required methods — no declaration, no annotation, no coupling between the type and the interface definition.
// Java — explicit declaration
public interface Stringer {
String string();
}
public class Point implements Stringer {
int x, y;
public String string() { return x + "," + y; }
}// Go — implicit satisfaction
type Stringer interface {
String() string
}
type Point struct{ X, Y int }
func (p Point) String() string {
return fmt.Sprintf("%d,%d", p.X, p.Y)
}
// Point satisfies Stringer without any declaration.
func Print(s Stringer) { fmt.Println(s.String()) }The practical consequence is that interfaces are defined at the call site, not at the implementation site. A package that accepts a two-method interface can be satisfied by a type that was written years earlier in a completely different package — as long as the method signatures match.
Keep interfaces small. The Go standard library's most-used interfaces are one or two methods: io.Reader, io.Writer, fmt.Stringer. Define interfaces where you consume them, not where you produce types.
Values vs Pointers — The Copy-by-Default World
Java passes object references by value (the reference is copied, the object is not). Go passes everything by value unless you explicitly use a pointer. This means a function that receives a struct gets a full copy.
type Counter struct{ n int }
func badIncrement(c Counter) {
c.n++ // mutates the copy, caller sees nothing
}
func goodIncrement(c *Counter) {
c.n++ // mutates through pointer
}When to use a pointer receiver vs a value receiver follows a straightforward rule:
- Pointer receiver — the method needs to mutate the receiver, or the struct is large enough that copying is wasteful, or the type has a
sync.Mutexor similar. - Value receiver — small, immutable-by-design structs like
time.Timeornet.IP.
Mixing pointer and value receivers on the same type is legal but confusing; pick one and stay consistent.
// Value receiver — fine for small, read-only structs
func (p Point) Distance() float64 {
return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}
// Pointer receiver — mutation needed
func (c *Counter) Inc() { c.n++ }
func (c *Counter) Val() int { return c.n }Nil Is Not null — It Is a Zero Value for Pointer-Like Types
In Java, null is the absence of an object reference, and NPEs are legendary. In Go, nil is the zero value for pointers, slices, maps, channels, and interfaces. A nil slice is valid — you can range over it, append to it, and check its length. A nil map will panic on write but is safe to read.
var s []int // nil, len=0, safe to range/append
var m map[string]int // nil, safe to read (returns zero), panics on write
m2 := make(map[string]int) // initialized, safe to writeThe gotcha for JVM engineers: a nil interface value and an interface holding a nil pointer are different things.
var p *Point = nil
var s Stringer = p // s is non-nil! It holds a (*Point, nil) pair.
fmt.Println(s == nil) // falseZero Values Over Constructors
Go structs are usable at their zero value whenever possible. This is a design constraint, not just a convenience. A sync.Mutex works correctly without any initialisation. A bytes.Buffer is ready to use as var buf bytes.Buffer. Design your own types to follow this pattern where it makes sense.
// Java
Counter c = new Counter(0);
// Go — zero value is the correct initial state
var c Counter
c.Inc()
fmt.Println(c.Val()) // 1Key Takeaways
- Go has no inheritance; embed structs for code reuse and prefer shallow, flat type hierarchies.
- Interfaces are satisfied structurally — define them at the consumer, keep them to one or two methods.
- Everything is copied by default; use pointer receivers when you need mutation or want to avoid large copies.
nilis a first-class zero value for pointer-like types, not a missing reference — understand the nil-interface distinction before it bites you in production.- Design structs to be useful at their zero value; eliminate mandatory constructors where possible.
- Stop looking for a base class; the composition model produces smaller, more testable units than deep inheritance trees.
Series
Go for JVM EngineersPart 2 →
Errors as Values