Low-Level Design (Object-Oriented Design) Module 9 – One design, six languages
LLD in Go: interfaces, embedding, generics
Write a low-level design in Go: small interfaces declared where they are used, embedding without its traps, useful zero values and Go 1.27 generic methods.
What you will learn
- Design with structs, methods and small consumer-side interfaces
- Use embedding for composition without mistaking it for inheritance
- Write constructors and zero values that leave no object half ready
- Use generics, and Go 1.27 generic methods, where they help
Before you start
What you’ll learn · Before you start
What you will learn
- Design with structs, methods and small consumer-side interfaces
- Use embedding for composition without mistaking it for inheritance
- Write constructors and zero values that leave no object half ready
- Use generics, and Go 1.27 generic methods, where they help
Before you start
On this page
Go has no classes and no inheritance, so a low-level design in Go is built from structs, methods and three tools.
Interfaces are sets of methods, and a type satisfies one implicitly, by having the methods, with no implements
clause. Because of that, Go code declares small interfaces in the package that uses them rather than beside the types
that implement them. Embedding places one struct inside another and promotes the inner struct’s fields and
methods, which gives composition with very little code. The outer type does not become the inner one, though, and a
promoted method never calls a method that the outer type adds. Generics let a type or a function take type
parameters, such as a repository for any key and value, and Go 1.27 added generic methods, methods that declare type
parameters of their own. Around these tools sit the habits of Go designs: a NewX constructor that returns a ready
value, zero values that work where possible, and errors returned as values.
Every example in this lesson ran on Go 1.27.1, and the comparisons ran on Go 1.26.8, the release before it.
A parking design in Go’s three tools
The parking design in Go: an interface declared by its user, implicit implementations, a generic repository and embedding
Text description of the diagram
A class diagram in one column, from top to bottom.
- Lot, a struct: unexported fields policy: FeePolicy and tickets: *Repo[string, Ticket]; methods Park(number, spot, minute) and Leave(number, minute), which returns (int, error). A line named policy goes from Lot to FeePolicy, with multiplicity 1.
- FeePolicy, marked «interface, declared with Lot», with one method, Fee(minutes int) int.
- A group labelled "structs that never mention FeePolicy" holds Hourly, with PerHour: int, and Flat, with Amount: int. A dashed line labelled "has Fee(int) int" goes from the group to FeePolicy: both structs have that method, so both satisfy the interface without declaring it.
- Repo, marked «generic», with type parameters K comparable and V any: methods Put(key K, value V), Get(key K) returning (V, bool), Delete(key K) and the generic method Map[R any](f func(V) R) returning []R. A line named tickets goes from Lot to Repo, with multiplicity 1.
- EVSpot, with ChargerKW: int, and Spot, with ID: string, Level: int and the method Label(): string. A line labelled embeds goes from EVSpot to Spot, with a filled diamond at EVSpot: an EVSpot contains a Spot, and Spot's fields and methods are promoted to it.
The lot needs one thing from a fee policy, a Fee(minutes int) int method, so it declares a one-method interface
for exactly that. Hourly and Flat never mention the interface, yet both satisfy it. Tickets live in a generic
Repo[K, V], and EVSpot embeds Spot:
session · parking_session.py
from session import run, version
version()
run("go run parking/main.go") Output
# go version go1.27.1 darwin/arm64 $ go run parking/main.go B2 on level 1 | charger: 7 kW main.Hourly T1 pays 12000 paise | still parked: [T2@B2] main.Flat T1 pays 10000 paise | still parked: [T2@B2] leave T9: unknown ticket | is ErrUnknownTicket: true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parking_session.py
parking/main.go
// A parking design in Go: structs with methods, an interface declared by the code that uses it,
// a spot type that embeds another, and a generic repository with a Go 1.27 generic method.
package main
import (
"errors"
"fmt"
"slices"
)
type Spot struct {
ID string
Level int
}
func (s Spot) Label() string { return fmt.Sprintf("%s on level %d", s.ID, s.Level) }
// EVSpot embeds Spot: it has Spot's fields and methods, and a field of its own.
type EVSpot struct {
Spot
ChargerKW int
}
// Repo keeps values by key, in the order they were first added.
type Repo[K comparable, V any] struct {
items map[K]V
order []K
}
func NewRepo[K comparable, V any]() *Repo[K, V] {
return &Repo[K, V]{items: make(map[K]V)}
}
func (r *Repo[K, V]) Put(key K, value V) {
if _, ok := r.items[key]; !ok {
r.order = append(r.order, key)
}
r.items[key] = value
}
func (r *Repo[K, V]) Get(key K) (V, bool) {
value, ok := r.items[key]
return value, ok
}
func (r *Repo[K, V]) Delete(key K) {
delete(r.items, key)
r.order = slices.DeleteFunc(r.order, func(k K) bool { return k == key })
}
// Map is a generic method: it declares a type parameter of its own, R (Go 1.27).
func (r *Repo[K, V]) Map[R any](f func(V) R) []R {
out := make([]R, 0, len(r.order))
for _, key := range r.order {
out = append(out, f(r.items[key]))
}
return out
}
// FeePolicy is the only thing a lot needs from a policy. Any type with this method satisfies it.
type FeePolicy interface {
Fee(minutes int) int // in paise
}
type Hourly struct{ PerHour int }
func (h Hourly) Fee(minutes int) int { return h.PerHour * ((minutes + 59) / 60) }
type Flat struct{ Amount int }
func (f Flat) Fee(int) int { return f.Amount }
type Ticket struct {
Number, Spot string
Entered int
}
var ErrUnknownTicket = errors.New("unknown ticket")
type Lot struct {
policy FeePolicy
tickets *Repo[string, Ticket]
}
func NewLot(policy FeePolicy) *Lot {
return &Lot{policy: policy, tickets: NewRepo[string, Ticket]()}
}
func (l *Lot) Park(number, spot string, minute int) {
l.tickets.Put(number, Ticket{Number: number, Spot: spot, Entered: minute})
}
func (l *Lot) Leave(number string, minute int) (int, error) {
t, ok := l.tickets.Get(number)
if !ok {
return 0, fmt.Errorf("leave %s: %w", number, ErrUnknownTicket)
}
l.tickets.Delete(number)
return l.policy.Fee(minute - t.Entered), nil
}
func main() {
ev := EVSpot{ID: "B2", Level: 1, ChargerKW: 7} // Go 1.27: promoted fields as keys
fmt.Println(ev.Label(), "| charger:", ev.ChargerKW, "kW")
for _, policy := range []FeePolicy{Hourly{PerHour: 4000}, Flat{Amount: 10000}} {
lot := NewLot(policy)
lot.Park("T1", "A1", 0)
lot.Park("T2", ev.ID, 30)
fee, _ := lot.Leave("T1", 130)
open := lot.tickets.Map(func(t Ticket) string { return t.Number + "@" + t.Spot })
fmt.Printf("%-13T T1 pays %d paise | still parked: %v\n", policy, fee, open)
}
_, err := NewLot(Flat{Amount: 10000}).Leave("T9", 10)
fmt.Println(err, "| is ErrUnknownTicket:", errors.Is(err, ErrUnknownTicket))
} session.py
"""Prints a terminal session: "$ " and each command, then everything it printed (standard output and standard
error together), and its exit status when that is not 0.
`go` is Go 1.27.1, the version this lesson pins, from the mise installation that also holds the Python running this
file; go="1.26.8" switches to Go 1.26.8 for the comparisons with the previous release. learn:verify's sandbox may not
read that folder, so these sessions are recorded with LEARN_VERIFY_NETWORK=allow. They read only this folder and the
toolchain, write only inside this folder (Go's build cache included), and never use the network: GOTOOLCHAIN=local
stops Go from downloading another version.
A command that fails makes the session exit with status 1, unless the script lists it in may_fail. For a command
that may fail, `keep` (a regular expression) shows only the lines of its output that matter. Test timings, which
change on every run, are left out.
"""
import os
import re
import subprocess
import sys
from pathlib import Path
# <mise>/installs/python/3.14.8/bin/python3 -> <mise>/installs
INSTALLS = Path(sys.executable).resolve().parents[3]
TIMING = re.compile(r"\s*\(\d+\.\d+s\)|\t\d+\.\d+s$")
def _env(go):
here = Path.cwd()
for folder in ("home", "tmp", "gocache", "gopath"):
(here / folder).mkdir(exist_ok=True)
env = dict(os.environ)
env.update(
PATH=os.pathsep.join([str(INSTALLS / "go" / go / "bin"), "/usr/bin", "/bin"]),
HOME=str(here / "home"),
TMPDIR=str(here / "tmp"),
GOCACHE=str(here / "gocache"),
GOPATH=str(here / "gopath"),
GOTOOLCHAIN="local",
GOTELEMETRY="off",
GOFLAGS="-trimpath",
GO111MODULE="auto",
)
return env
def _shell(command, go):
done = subprocess.run(command, shell=True, env=_env(go), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return done.returncode, [TIMING.sub("", line) for line in done.stdout.splitlines()]
def version(go="1.27.1"):
"""Prints `go version` as a comment, so the record says which Go ran."""
status, lines = _shell("go version", go)
if status != 0 or not lines:
sys.exit(f"Go {go} is not installed on this computer")
print("#", lines[0])
def run(script, go="1.27.1", may_fail=(), keep=None):
"""Runs each line of the script as one shell command, in the examples folder."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
status, lines = _shell(command, go)
if status != 0 and keep:
lines = [line for line in lines if re.search(keep, line)]
for line in lines:
print(line)
if status != 0:
print(f"[exit status {status}]")
if command not in may_fail:
unexpected += 1
if unexpected:
sys.exit(1) The first line comes from the embedded Spot: ev.Label() and ev.ID work on an EVSpot as if they were its own,
and Go 1.27 even accepts the promoted fields ID and Level as keys of the struct literal. The hourly policy charged
3 started hours of 40.00 for T1’s 130 minutes, the flat one 100.00, and the lot’s code did not change between the two.
Map is a generic method: called with a function from Ticket to string, it returned the labels of the tickets
still parked. The last line is an error value: Leave wrapped ErrUnknownTicket with %w, and errors.Is found it.
Each tool, and each habit, has an edge that a design must respect:
- Interfaces stay small and live with their users; a one-line check makes the compiler confirm that a type fits.
- Embedding promotes methods, but a promoted method’s receiver is the inner value, so outer methods are never called from inside.
- Zero values are the default state of every Go struct, so a design either makes them work or offers a constructor.
- A nil pointer stored in an error makes a non-nil error, a trap common enough that the Go FAQ answers it.
- Generic methods arrived in Go 1.27, and interface methods still cannot declare type parameters.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Small interfaces, declared where they are used Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Embedding is not inheritance: the override that never runs
- Constructors and zero values that work
- Errors as values: the nil that is not nil
- Generic repositories, and Go 1.27 generic methods
- Practise: port a booking service to Go
- Interview questions
- How does a Go type satisfy an interface, and where should the interface be declared?
- A teammate ports a Java class hierarchy to Go: Account has Statement(), which calls Fee(), and PremiumAccount embeds Account and defines a lower Fee(). Premium customers are still charged the standard fee. Explain why, and redesign it.
- After a refactor, every successful seat reservation in a Go service logs "reservation failed: <nil>" and is rolled back. The function returns error. What is the likely bug, how do you prove it, and how do you fix and prevent it?
- Your team adopts Go 1.27 and wants a generic Repository[K, V] with Map, Filter and GroupBy methods, plus a Repository interface that services depend on, so they can swap in a database later. How do you design it, and what can and cannot be generic?
- Key takeaways
7 runnable examples (Python, assert.go, booking.go, booking_test.go, copied.go, fixed.go, generic_impl.go, interface_method.go, nilerr.go, override.go, receipts.go, repo.go, session, zero.go) · 3 interview answers · about 17 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
References
- Go 1.27 Release Notes (The Go Authors)
- Effective Go (The Go Authors)
- The Go Programming Language Specification (The Go Authors)
- Go Code Review Comments: Interfaces (The Go Authors)
- Go FAQ: Why is my nil error value not equal to nil? (The Go Authors)
- Command vet (the copylocks check) (The Go Authors)
- Package errors (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress