Before getting into individual design patterns like the builder, it helps to have the principles they’re built on. Most of them trace back to SOLID, a set of five object-oriented design principles collected by Robert C. Martin.
Go doesn’t have classes or inheritance, so some of these translate directly and some need a bit of reinterpreting. These are my notes on how each one maps to Go.
SOLID design principles by Robert C. Martin
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Single Responsibility Principle
A type should have one primary responsibility, and therefore one reason to change. This is separation of concerns. The opposite is the god object, a type that knows and does everything, and it’s a terrible idea.
Take a journal that stores entries:
type Journal struct {
entries []string
}
func (j *Journal) AddEntry(text string) {
j.entries = append(j.entries, fmt.Sprintf("%d: %s", len(j.entries)+1, text))
}
func (j *Journal) String() string {
return strings.Join(j.entries, "\n")
}
The tempting next step is to add Save, Load and LoadFromWeb methods to Journal. But persistence is a different concern. It changes for different reasons (file format, storage backend), and it’s usually shared across many types. So it lives somewhere else:
func SaveToFile(j *Journal, filename string) error {
return os.WriteFile(filename, []byte(j.String()), 0o644)
}
Journal manages entries. Something else decides where they go.
Open-Closed Principle
Types should be open for extension but closed for modification. You should be able to add behaviour without editing code that already works and is already tested.
The classic example is filtering products. The first version is FilterByColor. Then someone needs FilterBySize, and then FilterBySizeAndColor. Every new requirement means editing the filter type again.
The specification pattern fixes this. Each criterion becomes its own type that satisfies a small interface:
type Specification interface {
IsSatisfied(p *Product) bool
}
type ColorSpec struct{ Color Color }
func (s ColorSpec) IsSatisfied(p *Product) bool { return p.Color == s.Color }
type SizeSpec struct{ Size Size }
func (s SizeSpec) IsSatisfied(p *Product) bool { return p.Size == s.Size }
type AndSpec struct{ First, Second Specification }
func (s AndSpec) IsSatisfied(p *Product) bool {
return s.First.IsSatisfied(p) && s.Second.IsSatisfied(p)
}
The filter itself is written once and never touched again:
func Filter(products []Product, spec Specification) []*Product {
var result []*Product
for i := range products {
if spec.IsSatisfied(&products[i]) {
result = append(result, &products[i])
}
}
return result
}
greenAndLarge := Filter(products, AndSpec{ColorSpec{Green}, SizeSpec{Large}})
A new criterion is a new type. Filter stays closed.
Liskov Substitution Principle
If code works with a type, it should keep working with anything that claims to be that type. In Go terms, every implementation of an interface has to honour the behaviour callers expect, not just the method signatures.
The standard example is the rectangle and the square:
type Sized interface {
Width() int
Height() int
SetWidth(w int)
SetHeight(h int)
}
type Rectangle struct{ width, height int }
func (r *Rectangle) Width() int { return r.width }
func (r *Rectangle) Height() int { return r.height }
func (r *Rectangle) SetWidth(w int) { r.width = w }
func (r *Rectangle) SetHeight(h int) { r.height = h }
A square is a rectangle, so embedding seems natural. A square has to keep its sides equal, so its setters change both:
type Square struct{ Rectangle }
func (s *Square) SetWidth(w int) { s.width, s.height = w, w }
func (s *Square) SetHeight(h int) { s.width, s.height = h, h }
It compiles, and *Square satisfies Sized. Now some code that relies on width and height being independent:
func UseIt(s Sized) {
width := s.Width()
s.SetHeight(10)
expected := width * 10
actual := s.Width() * s.Height()
fmt.Printf("expected area %d, got %d\n", expected, actual)
}
expected area 20, got 20 // Rectangle{2, 3}
expected area 50, got 100 // square of size 5
The square broke a promise the interface implied. The compiler can’t catch this, because the types line up. The fix is to stop pretending: don’t make Square a Sized. Either give it its own type with a single SetSize, or have a constructor return a plain Rectangle with equal sides.
Interface Segregation Principle
You shouldn’t put too much into an interface. Clients shouldn’t be forced to depend on methods they don’t use.
A big interface pushes implementers into stubbing:
type Machine interface {
Print(d Document) error
Scan(d Document) error
Fax(d Document) error
}
type OldPrinter struct{}
func (OldPrinter) Print(d Document) error { return nil }
func (OldPrinter) Scan(d Document) error { return errors.New("operation not supported") }
func (OldPrinter) Fax(d Document) error { return errors.New("operation not supported") }
OldPrinter can only print, yet it has to pretend to scan and fax. Every caller now has to handle errors that only exist because the interface is too wide.
Split it up, and compose when you actually need more:
type Printer interface {
Print(d Document) error
}
type Scanner interface {
Scan(d Document) error
}
type MultiFunctionDevice interface {
Printer
Scanner
}
This is where Go really shines. The standard library is built on tiny interfaces like io.Reader, io.Writer and fmt.Stringer, and composes them as io.ReadWriter. Because interfaces are satisfied implicitly, the consumer can define exactly the interface it needs, right next to the code that uses it. The smaller the interface, the better.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Here the high-level module is the business logic: placing an order. The low-level module is the detail of how the customer is told: email today, maybe SMS tomorrow. If OrderService builds an SMTP client directly, it’s coupled to email forever and hard to test.
Instead, the high-level side defines what it needs:
type Notifier interface {
Notify(to, message string) error
}
type OrderService struct {
notifier Notifier
}
func NewOrderService(n Notifier) *OrderService {
return &OrderService{notifier: n}
}
func (s *OrderService) Place(o Order) error {
// ... validate, persist ...
return s.notifier.Notify(o.Customer, "Order "+o.ID+" confirmed")
}
The details plug in from the outside:
type EmailNotifier struct{ From string }
func (e EmailNotifier) Notify(to, message string) error {
// send the email
return nil
}
svc := NewOrderService(EmailNotifier{From: "shop@example.com"})
In tests, a fake that records messages slots straight in, with no mail server needed. The Go idiom that captures this is “accept interfaces, return structs”.
Takeaways
- One reason to change per type. Keep persistence, formatting and transport out of your domain types.
- Extend with new types, not new branches. Small interfaces like
Specificationlet behaviour grow without edits. - Satisfying an interface is a behavioural promise, not just matching method signatures.
- Keep interfaces small, and define them where they’re consumed.
- Depend on abstractions at boundaries, and inject the concrete pieces from
main.
Next up: the builder pattern, which builds on these ideas for constructing complex objects step by step.