Interfaces & Composition
Go has no classes, no inheritance, and no implements/extends keywords. The two tools it gives you instead are interfaces (a set of method signatures a value can satisfy) and embedding (placing one type inside another to reuse its methods). Everything in this chapter is built from just those two ideas — and a real distributed-systems codebase, multigres (“Vitess for Postgres”), leans on them everywhere: for swappable RPC backends, for the parser’s AST hierarchy, and for forward-compatible gRPC servers.
This chapter builds on Types, Structs & Methods — method sets, receivers, and the AST node types introduced there.
Implicit satisfaction: there is no implements
Section titled “Implicit satisfaction: there is no implements”In Java or C# you write class Foo implements Bar, and the compiler checks the declaration at the point of the class. In Go, a type satisfies an interface automatically the moment its method set covers the interface’s methods. Nothing is written down. There is no syntactic link between the interface and its implementers.
type Stringer interface { String() string}
type Point struct{ X, Y int }
// Point now satisfies Stringer — no declaration, no import of Stringer needed.func (p Point) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }
func describe(s Stringer) { fmt.Println(s.String()) }
func main() { describe(Point{1, 2}) } // works; the compiler matches method setsThe consequence: an interface can be defined in one package and satisfied by types in packages that never import it, or that didn’t even exist when the interface was written. Interfaces are defined by the consumer, not the producer.
The QueryService interface is the textbook case. It lives in go/common/queryservice/queryservice.go, and its doc comment is the only place the implementers are named — because the code itself contains no link:
// QueryService is the interface for executing queries on a multipooler.//// This interface is implemented by the grpcQueryService (the gRPC client to the// multipooler), by the in-process executor, and by the PoolerGateway router.type QueryService interface { ExecuteQuery( ctx context.Context, target *query.Target, sql string, options *query.ExecuteOptions, ) (*sqltypes.Result, *query.ReservedState, error)
StreamExecute( /* ... */ ) // ... more methods}Three structurally unrelated types — the gRPC client wrapper, the in-process executor, and the gateway router — all satisfy this without saying so. A caller holding a QueryService can’t tell which it has, and doesn’t care. That is the whole point: it lets the system swap a real gRPC backend for an in-process executor or a test mock at the boundary, with zero changes to callers.
Small vs. large interfaces
Section titled “Small vs. large interfaces”Go culture favors small interfaces — often one or two methods (io.Reader is just Read). Small interfaces are easy to satisfy, easy to mock, and compose well. But there’s a place for deliberately large interfaces too, and the two serve different jobs. go/common/rpcclient/client.go shows both side by side.
The small one is a pure behavioral interface — “anything that can send and receive on this stream”:
type ManagerHealthStream interface { Recv() (*multipoolermanagerdatapb.ManagerHealthStreamResponse, error) Send(*multipoolermanagerdatapb.ManagerHealthStreamClientMessage) error}The large one is a capability interface — it aggregates an entire RPC surface (consensus plus manager methods, around 20 of them) so that one object represents “talk to a multipooler node,” cache and all:
// MultiPoolerClient defines the unified interface for communicating with a multipooler node.type MultiPoolerClient interface { Recruit(ctx context.Context, pooler *clustermetadatapb.MultiPooler, request *consensusdatapb.RecruitRequest) (*consensusdatapb.RecruitResponse, error) Promote(ctx context.Context, pooler *clustermetadatapb.MultiPooler, request *consensusdatapb.PromoteRequest) (*consensusdatapb.PromoteResponse, error) // ... ~20 methods}When do you reach for each? A small interface is the right choice when many unrelated types implement it and callers only need one behavior — mocking it in a test is trivial, you implement two methods. A large capability interface is right when you want to abstract a whole subsystem behind a single swappable seam: the real implementation dials gRPC and caches connections, but a test can pass a hand-written fake that records calls. The cost of a large interface is that every implementer (including every mock) must supply all ~20 methods — which is exactly why these use generated mocks rather than hand-written ones.
Interface embedding (interface-in-interface)
Section titled “Interface embedding (interface-in-interface)”An interface can embed another interface. The embedded interface’s methods become part of the outer one — set union, not inheritance. The AST builds a three-level chain of these in go/common/parser/ast/nodes.go.
type Reader interface{ Read(p []byte) (int, error) }type Closer interface{ Close() error }
// ReadCloser = Read + Close. A type satisfies it iff it has both methods.type ReadCloser interface { Reader Closer}The base interface every AST node satisfies is Node:
type Node interface { NodeTag() NodeTag Location() int SetLocation(location int) String() string SqlString() string}Stmt and Expression each embed Node and add one method, and Value embeds Expression (which transitively embeds Node):
type Stmt interface { Node StatementType() string}
type Expression interface { Node ExpressionType() string}
type Value interface { Expression // which embeds Node IsValue() bool}So a Value requires all of Node’s five methods plus ExpressionType() plus IsValue(). This is how Go expresses “a value is a kind of expression is a kind of node” without inheritance: the relationship is purely method-set containment. A *Integer satisfies Value because it has every method in the union, and you can assign a *Integer to a variable of type Value, Expression, or Node interchangeably.
Interface embedding isn’t confined to the parser. The gateway grows its capability interface compositionally, by embedding QueryService and adding a routing method:
type Gateway interface { // the query service that this Gateway wraps around queryservice.QueryService
// QueryServiceByID returns a QueryService QueryServiceByID(ctx context.Context, id *clustermetadatapb.ID, target *query.Target) (queryservice.QueryService, error)}A Gateway is a QueryService (every QueryService method is in its set) and also can resolve a service by ID. Embedding the interface keeps the two in sync: if QueryService gains a method, Gateway automatically requires it too.
Struct embedding and method promotion
Section titled “Struct embedding and method promotion”Embed a type as an anonymous field (write the type with no field name) and the outer struct gains — promotes — the embedded type’s fields and methods as if they were its own. Every AST node struct embeds BaseNode to inherit the boilerplate Node methods.
type Base struct{ ID int }func (b *Base) Describe() string { return fmt.Sprintf("id=%d", b.ID) }
type User struct { Base // anonymous field — embedding Name string}
func main() { u := &User{Base: Base{ID: 7}, Name: "ada"} fmt.Println(u.ID) // promoted field: actually u.Base.ID fmt.Println(u.Describe()) // promoted method: actually u.Base.Describe()}BaseNode carries the tag and source location and implements four of Node’s five methods:
type BaseNode struct { Tag NodeTag Loc int}
func (n *BaseNode) NodeTag() NodeTag { return n.Tag }func (n *BaseNode) Location() int { return n.Loc }func (n *BaseNode) String() string { return fmt.Sprintf("%s@%d", n.Tag, n.Loc) }func (n *BaseNode) SetLocation(location int) { n.Loc = location }Integer embeds it and so inherits NodeTag, Location, SetLocation for free — then overrides the two methods that need node-specific behavior:
type Integer struct { BaseNode IVal int}
func NewInteger(value int) *Integer { return &Integer{ BaseNode: BaseNode{Tag: T_Integer}, IVal: value, }}
func (i *Integer) String() string { return fmt.Sprintf("Integer(%d)@%d", i.IVal, i.Location()) }func (i *Integer) SqlString() string { return strconv.Itoa(i.IVal) }Because *Integer has NodeTag/Location/SetLocation (promoted from *BaseNode) plus its own String/SqlString, its method set covers all of Node — and with ExpressionType() and IsValue() defined elsewhere, it covers Value too. Embedding supplied the shared 80%; the override supplied the unique 20%.
The override / shadowing rule
Section titled “The override / shadowing rule”When the outer type defines a method with the same name as the embedded type, the outer one shadows the promoted one for calls through the outer type:
i := NewInteger(42)i.SqlString() // "42" — Integer.SqlString winsi.BaseNode.SqlString() // panics — you explicitly selected the base versionBaseNode.SqlString() is deliberately written to panic:
func (n *BaseNode) SqlString() string { if n.Tag == T_Invalid { return "<INVALID>" } panic(fmt.Sprintf("SqlString() not implemented for node type %s (tag: %d). "+ "Please implement SqlString() method for this node type to enable SQL deparsing.", n.Tag, int(n.Tag)))}This is a “fail loud” design. SqlString is the deparser — turning an AST back into SQL. There is no sensible default SQL for an arbitrary node, so instead of returning "" (which would silently emit broken SQL), the base implementation panics with the node tag in the message. A node author who forgets to override SqlString finds out at the first deparse, loudly, instead of shipping a query that drops a clause.
Embedding is not inheritance
Section titled “Embedding is not inheritance”This is the single most important correction for an OO programmer. Method promotion is forwarding, not virtual dispatch. The embedded BaseNode has no knowledge of the Integer that embeds it.
// Suppose BaseNode had a method that called another of its own methods:func (n *BaseNode) Render() string { return "tag=" + n.NodeTag().String() }If BaseNode.Render() calls n.NodeTag(), it calls BaseNode’s NodeTag — because n is a *BaseNode, and Go resolves the call against *BaseNode’s method set, not the dynamic type of whatever embedded it. There is no super/base-to-derived back-reference. If you come from Java expecting the base class to dispatch into the subclass override (the “template method” pattern), Go will surprise you: it simply does not do that. When you need that polymorphism, you pass the interface around and let interface dispatch pick the right method — which is exactly what the deparser does. It calls someNode.SqlString() through the Node interface, and interface dispatch lands on Integer.SqlString.
Embedding an interface in a struct (the Unimplemented*Server idiom)
Section titled “Embedding an interface in a struct (the Unimplemented*Server idiom)”You can embed an interface (not just a struct) as an anonymous field. The struct then promotes whatever methods the embedded interface value provides — and, crucially, satisfies that interface even if the struct defines none of the methods itself. This powers the most pervasive embedding idiom in the codebase: gRPC server forward-compatibility.
The protobuf compiler generates, for every service, an Unimplemented<Service>Server type whose methods all return “unimplemented.” Every server struct embeds it:
type MultiOrchServer struct { multiorchpb.UnimplementedMultiOrchServiceServer engine *recovery.Engine coordinator *consensus.Coordinator logger *slog.Logger}Why? The generated gRPC server interface lists every RPC in the .proto. When someone adds a new RPC and regenerates, that interface grows a method. If MultiOrchServer implemented the interface directly, it would stop compiling until you hand-wrote the new method. By embedding UnimplementedMultiOrchServiceServer, the new method is promoted automatically (returning an “unimplemented” gRPC error at runtime), so the struct keeps satisfying the interface and the build stays green. You override only the RPCs you actually handle; the rest fall through to the embedded defaults. This same struct appears in every service (multigateway, multiadmin, multipooler). See gRPC & Protobuf for how these types are generated.
Accept interfaces, return structs (and the nuance)
Section titled “Accept interfaces, return structs (and the nuance)”The conventional Go guideline: functions should accept interface parameters (so callers can pass any implementation) but return concrete types (so callers get the full API and you don’t prematurely narrow). The codebase follows this — but with a deliberate twist worth understanding.
The gateway’s gRPC service constructor returns the interface, and the concrete type is unexported:
type grpcQueryService struct { // lowercase — package-private conn *grpc.ClientConn client multipoolerservice.MultiPoolerServiceClient // ...}
func newGRPCQueryService( conn *grpc.ClientConn, poolerID topoclient.ComponentID, logger *slog.Logger,) queryservice.QueryService { // returns the INTERFACE return &grpcQueryService{ /* ... */ }}Here returning the interface is intentional narrowing: grpcQueryService is unexported, so no caller outside the package can even name the concrete type. Callers can only hold a queryservice.QueryService, which means the gRPC backend is genuinely swappable — nobody can write code that depends on it being gRPC. This is the right call when the concrete type is an implementation detail behind an abstraction seam.
Contrast the AST constructors, which return concrete structs because the caller genuinely wants the rich type:
func NewInteger(value int) *Integer // returns concrete *Integerfunc NewString(value string) *String // returns concrete *StringAfter i := NewInteger(42) you can read i.IVal directly. Returning Node here would force every caller to type-assert back to *Integer to touch the field — pointless friction.
And a third flavor: some constructors return the Node interface precisely because they pick the concrete type dynamically:
func NewQualifiedName(schema, name string) Node { // returns interface; builds a *RangeVar return &RangeVar{ /* ... */ }}So the rule is really: return the most specific type your caller can usefully depend on. A factory that hides its backend returns an interface; a constructor for a concrete value returns the struct; a function that chooses among several node types returns the common interface.
Type assertions: extracting the concrete type back out
Section titled “Type assertions: extracting the concrete type back out”An interface value hides its dynamic type. A type assertion tries to recover it. There are two forms, and the difference matters enormously.
var n Node = NewInteger(5)
i := n.(*Integer) // single-value: panics if n is not actually a *Integeri, ok := n.(*Integer) // comma-ok: ok is false (and i is the zero value) on mismatch — no panicYou can also assert to an interface type, which asks “does this value’s dynamic type satisfy this interface?” — a runtime conformance check. The AST helpers use the comma-ok form consistently, for exactly that reason. They accept any (an empty interface, covered below) and test whether the value is a Node at all:
func IsNode(v any) bool { _, ok := v.(Node) // does v satisfy the Node interface at runtime? return ok}
func CastNode(v any) Node { if node, ok := v.(Node); ok { return node } return nil // not a node → nil interface, no panic}And the comma-ok form asserting to a concrete type, used to pull a typed value out of a Node:
func IntVal(node Node) int { if i, ok := node.(*Integer); ok { return i.IVal } return 0}NodeTagOf, LocationOf, and BoolVal/FloatVal follow the identical shape: comma-ok, return a safe default on mismatch. The single-value form would panic if handed something that isn’t the expected type — unacceptable for a parser helper that gets called on arbitrary tree nodes.
Type switches: dispatching on the concrete type
Section titled “Type switches: dispatching on the concrete type”A type switch is a multi-way comma-ok assertion. It’s how you dispatch over a closed family of node types — the AST’s bread and butter.
func area(s Shape) float64 { switch v := s.(type) { // v is bound to the concrete type in each case case *Circle: return 3.14159 * v.R * v.R // v is *Circle here case *Rect: return v.W * v.H // v is *Rect here default: return 0 // v keeps the interface type (Shape) here }}GetExpressionArgs dispatches over a dozen expression node types to pull out their argument lists. In each case, the bound variable e has the concrete pointer type, so e.Args type-checks:
func GetExpressionArgs(expr Node) *NodeList { switch e := expr.(type) { case *FuncExpr: return e.Args case *OpExpr: return e.Args case *BoolExpr: return e.Args case *CaseExpr: return e.Args case *CaseWhen: return NewNodeList(e.Expr, e.Result) // ... *CoalesceExpr, *ArrayExpr, *ScalarArrayOpExpr, // *RowExpr, *Aggref, *WindowFunc, *SubLink ... default: return nil }}A grouped case lists several types in one arm. When you group types, the bound variable keeps the interface type (here node Node), not any concrete type — because the cases disagree on what concrete type it is:
switch n := node.(type) {case *NodeList: for _, item := range n.Items { // n is *NodeList — concrete, single case if item != nil { WalkNodes(item, walker) } }case *Integer, *Float, *Boolean, *String, *BitString, *Null: // n is still Node here (grouped case) — these are all leaves, no traversal returndefault: // other node types: not yet traversed}The normalizer uses a type switch to skip subtrees whose literals carry planner-significant meaning, and finishes with a single-value assertion of the walk result back to the narrower Stmt interface:
switch n := node.(type) {case *VariableSetStmt, *VariableShowStmt, *DefElem: return false // grouped: don't normalize these subtreescase *FuncCall: if isPlannerLiteralFunc(n.Funcname) { // single case: n is *FuncCall, so n.Funcname is valid // ... selectively normalize specific args ... return false }}// ...}, nil).(Stmt) // assert Rewrite's Node result back to StmtThat trailing .(Stmt) is the rare justified single-value assertion: Rewrite always returns a Node whose dynamic type is a statement here, so the assert documents and enforces that invariant; a violation is a bug worth panicking over.
The empty interface and any
Section titled “The empty interface and any”interface{} is the interface with no methods. Every type satisfies it, so a variable of that type can hold anything — Go’s dynamic-typing escape hatch. As of Go 1.18, any is a built-in alias for interface{}; they are identical, and modern code writes any.
NewValue is the canonical use: it sits at the boundary between Go literal values and AST nodes, so it accepts any and type-switches on the Go builtin type to build the right node:
func NewValue(val any) Node { switch v := val.(type) { case string: return NewString(v) case int: return NewInteger(v) case int32: return NewInteger(int(v)) case int64: return NewInteger(int(v)) case float32: return NewFloat(fmt.Sprintf("%g", v)) case float64: return NewFloat(fmt.Sprintf("%g", v)) case bool: return NewBoolean(v) case nil: // matches an untyped nil val return NewNull() default: return NewString(fmt.Sprintf("%v", v)) // unknown: stringify it }}The IsNode/NodeTagOf/LocationOf/CastNode helpers also take any because they’re written to be safe on anything, node or not.
The typed-nil gotcha (read this twice)
Section titled “The typed-nil gotcha (read this twice)”This is the bug every Go programmer hits once and never forgets. An interface value has two parts internally: a type and a value. An interface is == nil only when both halves are nil. If you store a nil concrete pointer into an interface, the type half is set (*Foo) and the value half is nil — so the interface is not == nil, even though the thing it wraps is nil.
type E struct{}func (*E) Error() string { return "boom" }
func bad() error { var p *E = nil return p // returns a non-nil error wrapping a nil *E !!}
func main() { err := bad() fmt.Println(err == nil) // FALSE — surprises everyone // err.Error() // and calling a method may panic, depending on the method}The interface err has dynamic type *E and value nil, so err == nil is false. Callers that guard with if err != nil { ... } will wrongly enter the error branch, and methods that dereference the receiver will panic.
Production code guards against this explicitly. InternalQueryService returns an interface, and its *Executor field might be nil — so it nil-checks before returning, and returns the literal nil (an untyped nil, which is a truly nil interface):
func (s *QueryPoolerServer) InternalQueryService() executor.InternalQueryService { // Explicit nil check required: returning a nil *Executor directly would produce a // non-nil interface value wrapping a nil pointer, causing callers' == nil checks to // pass but method calls on the interface to panic. if s.executor == nil { return nil } return s.executor}Without the check, return s.executor when s.executor is a nil *Executor hands back a non-nil InternalQueryService interface wrapping a nil pointer — a caller’s if svc != nil passes, then the first method call panics.
There’s a real regression test for the same class of bug. A factory deliberately returns a typed nil to reproduce etcd’s behavior:
func (f *typedNilMockFactory) newConn() (Conn, error) { // ... var conn *mockConn // concrete type → typed nil if f.shouldFail { // Returns typed nil (*mockConn)(nil) wrapped in Conn interface. // When assigned to Conn, it becomes a non-nil interface with a nil // underlying value - the classic Go gotcha. return conn, mterrors.Errorf(mtrpc.Code_UNAVAILABLE, "factory error") } // ...}And the production code under test defends by checking the error first, never trusting the conn when an error came back:
// Check error first - if err != nil, conn is unusable (may be typed nil).if err != nil { if c.closed { return false } return true}Even the mterrors test table encodes the distinction between an untyped nil and a typed nil at the error boundary:
{ // explicit nil error is nil err: (error)(nil), // truly nil interface — both halves nil want: nil,}, { // typed nil is nil err: (*nilError)(nil), // a non-nil error interface wrapping a nil *nilError want: (*nilError)(nil), // ... and it stays that distinct value},Checkpoints
Section titled “Checkpoints”-
A type
*Foohas methodsBar()andBaz(). An interfaceDoerrequires onlyBar(). Does*FoosatisfyDoer, and where (if anywhere) is that relationship written down?Answer
Yes — satisfaction is implicit and structural;*FoosatisfiesDoerbecause its method set includesBar()(extra methods are fine). The relationship is written down nowhere in code; at most it’s documented in a comment or asserted withvar _ Doer = (*Foo)(nil). There is noimplementskeyword. -
BaseNode.SqlString()panics, andIntegeroverrides it to returnstrconv.Itoa(i.IVal). If you callsomeInteger.BaseNode.SqlString()directly, what happens, and why doesn’t the override save you?Answer
It panics. By writing.BaseNode.SqlString()you explicitly select the embedded base method, bypassing the override. Method promotion is forwarding, not virtual dispatch —BaseNodehas no back-reference to theIntegerembedding it, so the base version runs and hits itspanic. Calls throughsomeInteger.SqlString()(or through theNodeinterface) resolve toInteger’s override instead. -
func provide() error { var e *myErr = nil; return e }. Isprovide() == niltrue or false? What’s the fix if you want it to be true?Answer
False. The returnederrorinterface has dynamic type*myErrand a nil value, so it is not== nil. Fix: nil-check first andreturn nil(the untyped nil literal), or have the function return*myErrand only box it intoerrorwhen it’s actually non-nil — exactly the pattern inInternalQueryService. -
In a type switch, when does the bound variable have the concrete type vs. the interface type?
Answer
In a single-type case (case *FuncExpr:) the bound variable has that concrete type, so you can access its fields. In a grouped case (case *Integer, *Float, *Boolean:) the cases disagree on the concrete type, so the bound variable keeps the original interface type (e.g.Node). Thedefaultcase also keeps the interface type.
Exercises
Section titled “Exercises”-
In
go/common/parser/ast/nodes.go, list all five methods of theNodeinterface. Then openIntegerand classify each method as promoted fromBaseNodeor overridden onInteger. Explain, usingBaseNode.SqlString, whySqlStringmust be overridden forIntegerto be usable in the deparser. -
Grep for
Unimplementedacrossgo/services(trygrep -rn "Unimplemented.*Server" go/services). Count how many server structs embed one. Pick two and write one sentence each on what breaks if the proto adds a new RPC and the struct did not embed it. -
Read
InternalQueryServiceingo/services/multipooler/internal/poolerserver/pooler.go. Mentally delete theif s.executor == nilguard and describe the exact runtime failure a caller hits (be specific about which check passes and which call panics). Then read the typed-nil regression test ingo/common/topoclient/wrapperconn_test.goand explain howtypedNilMockFactory.newConnmanufactures the same class of bug. -
Find every comma-ok type assertion in
go/common/parser/ast/nodes.go(IsNode,NodeTagOf,LocationOf,CastNode,IntVal,BoolVal,FloatVal). For each, state what value it returns on a mismatch and what would happen if it had used the single-value form instead. Then explain why the single-value.(Stmt)innormalizer.gois acceptable when these are not. -
Compare
ManagerHealthStream(two methods) withMultiPoolerClient(around 20 methods) ingo/common/rpcclient/client.go. Argue, in terms of who implements each and how each is faked in tests, why one interface is deliberately tiny and the other deliberately large.