โ† Back to Blog

23 GoF Patterns in 23 Go Snippets

May 11, 2026 ยท 20 min read gopatternsjavacsharptutorial

I spent years memorizing Gang of Four patterns. Strategy, Observer, Factory, Decorator. I could whiteboard them in interviews. I could explain when to use Abstract Factory vs Factory Method. I thought that knowledge made me a better developer.

Then I started writing Go and realized most of those patterns exist to work around limitations that Go doesn't have.

Java and C# can't pass behavior directly. You have to wrap it in an object. You need an interface, a concrete class, maybe a factory to wire it all together. Go has first-class functions. You just pass the function. Pattern gone.

This isn't a theoretical argument. Here are all 23 GoF patterns, what they look like in Java, and what they collapse into in Go.

Just want the cheat sheet? Skip to the scorecard.


Creational Patterns

1. Singleton

Java gives you private constructors, static instances, and double-checked locking. Thread safety is your problem.

Go gives you sync.Once:

var db *DB
var once sync.Once

func GetDB() *DB {
    once.Do(func() { db = connectDB() })
    return db
}

One call, guaranteed. No locking gymnastics.

2. Factory Method

In Java you need an abstract creator class, concrete creator subclasses, and a product interface hierarchy. All to decide which object to create.

In Go it's a function that returns an interface:

type Store interface { Save(data []byte) error }

func NewStore(kind string) Store {
    switch kind {
    case "s3":  return &S3Store{}
    case "disk": return &DiskStore{}
    }
    return &DiskStore{}
}

S3Store struct and DiskStore struct only need to implement a Save method with the same signature. That's all. No base class, no implements keyword, no registration.

3. Abstract Factory

Factory Method creates one object. Abstract Factory creates a family of related objects that must work together. Think database drivers: you need a connection, a transaction, and a query builder that all speak the same dialect. You don't want a Postgres connection paired with a MySQL query builder.

In Java you'd define a factory interface, a concrete factory class per family, and abstract product interfaces. Easily 8+ files.

In Go, the factory is just a struct with function fields โ€” each field is a constructor for one product in the family:

type DBKit struct {
    Connect   func(dsn string) (Conn, error)
    NewTx     func(conn Conn) (Tx, error)
    NewQuery  func() QueryBuilder
}

func Postgres() DBKit {
    return DBKit{
        Connect:  pgConnect,
        NewTx:    pgBeginTx,
        NewQuery: func() QueryBuilder { return &PgQuery{} },
    }
}

Why a struct and not an interface? Because DBKit has no behavior โ€” it's just a bundle of constructors. An interface would force you to create a concrete type per family (PostgresKit struct, MySQLKit struct) with methods. A struct literal lets you assemble the family in one place with zero extra types.

The products (Conn, Tx, QueryBuilder) are still interfaces โ€” they need a contract. But the factory itself is just a function returning a struct. Swap Postgres() for MySQL() and the whole family travels together.

4. Builder

You have a struct with 10 fields. Most have sensible defaults. The caller only wants to override two of them. In Java you'd build a Builder class with a fluent API: new ServerBuilder().port(9090).tls(true).build(). That's a whole extra class just to avoid a constructor with 10 parameters.

Go uses functional options:

type Server struct {
    port    int
    timeout time.Duration
    tls     bool
}

type Option func(*Server)

func WithPort(p int) Option            { return func(s *Server) { s.port = p } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithTLS() Option                  { return func(s *Server) { s.tls = true } }

func NewServer(opts ...Option) *Server {
    s := &Server{port: 8080, timeout: 30 * time.Second} // defaults
    for _, o := range opts { o(s) }
    return s
}

// Usage: only override what you care about
srv := NewServer(WithPort(9090), WithTLS())

Each option is a function that mutates the struct. Defaults live in NewServer. Callers only pass what they want to change. You'll see this pattern in every serious Go library โ€” grpc.NewServer, zap.New, http.Server.

5. Prototype

You have an object and you want a copy of it. Maybe a config template you clone and tweak per environment. In Java you implement Cloneable, override clone(), cast from Object, and deal with shallow vs deep copy confusion.

In Go, structs are values. Assignment copies them:

type Config struct {
    Host    string
    Port    int
    Tags    []string
}

func (c Config) Clone() Config {
    clone := c                                  // copies Host and Port
    clone.Tags = append([]string{}, c.Tags...)  // deep copy the slice
    return clone
}

base := Config{Host: "localhost", Port: 5432, Tags: []string{"prod"}}
staging := base.Clone()
staging.Host = "staging-db"  // doesn't affect base

Scalar fields copy automatically. Slices and maps need explicit copies because they're reference types. That's the only thing to watch for.

Note: Clone() is purely a convention. There's no Cloneable interface in Go's standard library and no compiler enforcement. You could name it Copy() or Dup() โ€” nothing forces the name. It's just a community pattern that Clone() means "return a deep copy."


Structural Patterns

6. Adapter

You have an old logger with a WriteLog method. Your new code expects a Logger interface with a Log method. They don't match. In Java you write a whole adapter class that implements Logger, holds a reference to the old logger, and delegates the call. That's a new file, a constructor, and boilerplate.

In Go, wrap it in a one-field struct and delegate:

type Logger interface { Log(msg string) }

type LogAdapter struct {
    legacy *externallib.LegacyLogger
}

func (a *LogAdapter) Log(msg string) { a.legacy.WriteLog(msg) }

No implements keyword, no interface registration. Just the right method signature. The compiler sees that LogAdapter has a Log(string) method and accepts it anywhere a Logger is expected.

If you own the type (it's in your package), it's even simpler โ€” just add the method directly:

type LegacyLogger struct{}
func (l *LegacyLogger) WriteLog(msg string) { fmt.Println(msg) }

// Add this one method and LegacyLogger satisfies Logger
func (l *LegacyLogger) Log(msg string) { l.WriteLog(msg) }

7. Bridge

You have a notification system. Notifications can be urgent or normal (the abstraction). They can be sent via email, SMS, or Slack (the implementation). Without Bridge, you'd end up with UrgentEmail, UrgentSMS, UrgentSlack, NormalEmail, NormalSMS... every combination is a new class. It explodes.

Bridge separates the two dimensions so they vary independently. In Java this means two parallel class hierarchies wired together. In Go it's a struct with an interface field:

type Sender interface {
    Send(to, message string) error
}

type EmailSender struct{}
func (e *EmailSender) Send(to, msg string) error { /* send email */ }

type SlackSender struct{}
func (s *SlackSender) Send(to, msg string) error { /* post to slack */ }

type Notification struct {
    sender  Sender
    urgent  bool
}

func (n *Notification) Notify(to, msg string) error {
    if n.urgent {
        msg = "๐Ÿšจ URGENT: " + msg
    }
    return n.sender.Send(to, msg)
}

Add a new sender? Implement Send. Add a new notification type? Change the struct. Neither side knows about the other.

Usage:

// Email + urgent
n := &Notification{sender: &EmailSender{}, urgent: true}
n.Notify("[email protected]", "Server is down")

// Slack + normal
n2 := &Notification{sender: &SlackSender{}, urgent: false}
n2.Notify("#ops", "Deploy finished")

Pick a Sender and a mode independently โ€” mix and match without a type for every combination.

8. Composite

You want to treat a single item and a group of items the same way. A file has a size. A directory has a size (sum of everything inside it). Your code shouldn't care which one it's dealing with โ€” just call .Size() and get the answer.

This comes up everywhere: file systems, org charts, UI component trees, permission groups. Anything hierarchical where a container behaves like its contents.

In Go it's an interface and a slice of that same interface:

type Node interface { Size() int64 }

type File struct{ size int64 }
func (f *File) Size() int64 { return f.size }

type Dir struct{ children []Node } // Node can be File or Dir
func (d *Dir) Size() int64 {
    var total int64
    for _, c := range d.children { total += c.Size() }
    return total
}

A Dir contains Nodes. A Node can be a File or another Dir. The recursion happens naturally. No abstract base class, no leaf vs composite distinction in the type system.

9. Decorator

Java's decorator pattern requires an abstract decorator wrapping a component, with concrete decorators stacking behavior. Multiple classes, multiple files.

Go wraps a function with a function:

func WithLogging(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        h(w, r)
    }
}

Stack them: WithLogging(WithAuth(handler)).

Worth calling out: http.HandlerFunc is doing three patterns at once. A plain function with the right signature satisfies the http.Handler interface (Adapter). You pass different functions to change behavior (Strategy). You wrap them to add behavior (Decorator). One stdlib type, three patterns gone.

10. Facade

Placing an order involves checking inventory, charging a card, and scheduling shipping. The caller shouldn't need to know about three different subsystems, their initialization order, or their error handling quirks. They just want PlaceOrder().

That's a facade. One simple function hiding complex coordination behind it.

In Java you'd build a Facade class explicitly. In Go, the package system does this by default. Uppercase is your public API. Lowercase is hidden:

// package order โ€” consumers only see PlaceOrder

type inventoryClient struct{}
func (c *inventoryClient) Reserve(item string) error { /* check stock */ return nil }

type paymentClient struct{}
func (c *paymentClient) Charge(card string) error { /* charge card */ return nil }

type shippingClient struct{}
func (c *shippingClient) Schedule(item string) error { /* ship it */ return nil }

type Service struct {
    inventory *inventoryClient // lowercase = invisible outside this package
    payment   *paymentClient
    shipping  *shippingClient
}

func (s *Service) PlaceOrder(item, card string) error {
    if err := s.inventory.Reserve(item); err != nil { return err }
    if err := s.payment.Charge(card); err != nil { return err }
    return s.shipping.Schedule(item)
}

Consumers import order and call PlaceOrder. They never see inventory, payment, or shipping. Every well-designed Go package is already a facade โ€” you don't need a pattern name for it.

11. Flyweight

You're rendering a document with 10,000 characters. Each character has a font, size, and color. If you create a separate object for every character with all its formatting data, you blow up memory. Most characters share the same formatting.

Flyweight says: share the common data, pass the unique data in from outside.

In Java this becomes a FlyweightFactory class, intrinsic vs extrinsic state separation, and a pool of shared objects. In Go it's a map:

var fontCache sync.Map

type Font struct {
    Family string
    Size   int
    Bold   bool
}

func GetFont(family string, size int, bold bool) *Font {
    key := fmt.Sprintf("%s-%d-%v", family, size, bold)
    f := &Font{Family: family, Size: size, Bold: bold}
    actual, _ := fontCache.LoadOrStore(key, f)
    return actual.(*Font)
}

10,000 characters might only need 5 unique Font objects. sync.Map keeps it thread-safe. The characters hold a pointer to the shared font instead of their own copy.

Usage:

type Char struct {
    rune rune
    font *Font // shared, not copied, it is a pointer
}

// 10,000 characters, only a handful of Font allocations
doc := make([]Char, 10000)
for i := range doc {
    doc[i] = Char{rune: 'A', font: GetFont("Arial", 12, false)}
}

12. Proxy

You have a database interface. Every query hits the network. Some queries return the same data every time. You want to add caching without changing the original implementation or the code that calls it.

A proxy sits in front of the real object, intercepts calls, and adds behavior (caching, logging, access control, lazy loading). The caller doesn't know it's talking to a proxy.

In Java you reimplement every method of the interface, even the ones you don't care about. In Go, struct embedding forwards everything automatically. You only override what you intercept:

type DB interface {
    Query(sql string) ([]Row, error)
    Exec(sql string) error
}

type CachedDB struct {
    DB
    cache map[string][]Row // not thread-safe; add sync.RWMutex if queried concurrently
}

func NewCachedDB(real DB) *CachedDB {
    return &CachedDB{DB: real, cache: make(map[string][]Row)}
}

// Only Query is intercepted. Exec goes straight to the real DB.
func (c *CachedDB) Query(sql string) ([]Row, error) {
    if rows, ok := c.cache[sql]; ok { return rows, nil }
    rows, err := c.DB.Query(sql)
    if err == nil { c.cache[sql] = rows }
    return rows, err
}

If DB has 20 methods, you don't write 19 pass-throughs. Embedding handles them. You write code only for the one method you're intercepting. The constructor ensures the cache map is ready to use.


Behavioral Patterns

13. Strategy

You have a checkout function. Sometimes it applies full price. Sometimes 50% off. Sometimes a custom discount based on the user's tier. The pricing logic changes, but the checkout flow stays the same.

In Java you'd create a PricingStrategy interface, a FullPriceStrategy class, a HalfOffStrategy class, a TieredStrategy class, and a factory to pick the right one. Three files minimum for something trivial.

In Go you pass a function:

type Pricer func(base float64) float64

func FullPrice(base float64) float64 { return base }
func HalfOff(base float64) float64   { return base * 0.5 }

func Checkout(items []Item, price Pricer) float64 {
    var total float64
    for _, i := range items { total += price(i.Base) }
    return total
}

// Usage
Checkout(cart, FullPrice)
Checkout(cart, HalfOff)
Checkout(cart, func(b float64) float64 { return b * 0.8 }) // inline 20% off

The strategy is just a function signature. You can pass a named function, an anonymous function, or a method. No interface, no class hierarchy, no factory to select it.

14. Observer

A user signs up. You need to send a welcome email, create an analytics event, and provision their account. These are three separate concerns. The signup handler shouldn't know about all of them. It should just say "user created" and let interested parties react.

In Java you build Subject and Observer interfaces, implement registration and notification methods, manage listener lists. Lots of boilerplate for "call these functions when something happens."

In Go, a slice of callbacks:

type EventBus struct {
    mu   sync.RWMutex // only needed if subscribing/emitting from multiple goroutines
    subs map[string][]func(any)
}

func (eb *EventBus) On(event string, fn func(any)) {
    eb.mu.Lock()
    defer eb.mu.Unlock()
    eb.subs[event] = append(eb.subs[event], fn)
}

func (eb *EventBus) Emit(event string, data any) {
    eb.mu.RLock()
    defer eb.mu.RUnlock()
    for _, fn := range eb.subs[event] { fn(data) }
}

// Usage
bus.On("user.created", sendWelcomeEmail)
bus.On("user.created", trackSignupEvent)
bus.On("user.created", provisionAccount)

bus.Emit("user.created", user)

The sync.RWMutex makes it safe to subscribe and emit from multiple goroutines. Caveat: if a callback calls On() during Emit(), it deadlocks. In production, copy the slice under the lock and call callbacks after releasing it.

If subscribers need to process events concurrently and at their own pace, channels are the alternative:

func fanOut(in <-chan Event, subs []chan<- Event) {
    for event := range in {
        for _, ch := range subs { ch <- event }
    }
}

Each subscriber gets its own channel.

Note: this sends synchronously โ€” a slow subscriber blocks the others. Add buffered channels or a select with timeout in production.

15. Command

You want to queue operations, execute them later, retry on failure, or undo them. A deploy pipeline: validate config, build image, push to registry, update service. Each step is independent. You want to run them in sequence, stop on failure, and maybe roll back.

In Java you create a Command interface, a concrete class per operation, an Invoker to manage execution, and a Receiver that does the actual work. Four concepts for "run these things in order."

In Go, functions go in a slice:

type Command func() error

func Execute(cmds []Command) error {
    for _, cmd := range cmds {
        if err := cmd(); err != nil { return err }
    }
    return nil
}

// Each step is just a function
steps := []Command{validateConfig, buildImage, pushToRegistry, updateService}
if err := Execute(steps); err != nil {
    rollback()
}

Need undo? Store each command with its reverse:

type Action struct {
    Do   func() error
    Undo func() error
}

Commands are data when they're functions. You can queue them, serialize them, replay them, batch them. No interface hierarchy needed.

16. Template Method

You have an ETL pipeline. Every pipeline validates, transforms, and stores data. But the validation logic differs per data source. The transform logic differs per output format. The skeleton is the same, the steps change.

In Java you write an abstract class with a final template method and force subclasses to override the steps. You need inheritance.

In Go, the steps are function fields on a struct:

type Pipeline struct {
    Validate  func(data []byte) error
    Transform func(data []byte) ([]byte, error)
    Store     func(data []byte) error
}

func (p *Pipeline) Run(data []byte) error {
    if err := p.Validate(data); err != nil { return err }
    out, err := p.Transform(data)
    if err != nil { return err }
    return p.Store(out)
}

// CSV pipeline
csvPipeline := &Pipeline{
    Validate:  validateCSV,
    Transform: csvToJSON,
    Store:     writeToS3,
}

// XML pipeline โ€” same skeleton, different steps
xmlPipeline := &Pipeline{
    Validate:  validateXML,
    Transform: xmlToJSON,
    Store:     writeToDB,
}

Same flow, different behavior. No inheritance, no abstract classes. Swap the functions.

17. Iterator

You have a custom data structure โ€” a tree, a linked list, a paginated API response. You want to loop over its elements with for range like you would a slice.

In Java you implement Iterator<T> with hasNext() and next(). You manage state, track position, handle concurrent modification.

In Go, the simplest iterator is a slice. You already use for range on it every day. But what about custom data structures?

Before Go 1.23, you'd return a slice (simple but allocates) or use a channel (concurrent but heavy). Since Go 1.23, you can make any data structure work with for range by returning a special function:

// Iterate over a slice in reverse
func Backward(s []string) iter.Seq[string] {
    return func(emit func(string) bool) {
        for i := len(s) - 1; i >= 0; i-- {
            if !emit(s[i]) {
                return // caller used break
            }
        }
    }
}

// Caller writes a normal loop
for val := range Backward([]string{"a", "b", "c"}) {
    fmt.Println(val) // prints c, b, a
}

The function receives an emit callback from for range โ€” call it for each element, stop if it returns false (the caller used break).

The tree version is the same idea โ€” walk recursively, call emit at each node:

func (t *Tree[V]) All() iter.Seq[V] {
    return func(emit func(V) bool) {
        t.walk(t.root, emit) // visits each node, calls emit(node.Value)
    }
}

for val := range tree.All() {
    fmt.Println(val)
}

The caller writes a for range loop. They don't know if it's a slice, a tree, or a database cursor behind it.

18. State

A TCP connection can be in different states: listening, open, closed. The behavior of Read() and Write() depends on which state it's in. In the closed state, Write() should return an error. In the open state, it should send data.

In Java you build a State interface, a class per state, and a context that delegates to the current state object. Adding a new state means a new class.

In Go, a state is a function that returns the next state:

type State func(event string) State

This is a recursive type โ€” a function whose return type is itself. Each state is a function that, given an event, returns whichever function should handle the next event.

func Idle(event string) State {
    if event == "connect" { return Connected }
    return Idle
}

func Connected(event string) State {
    switch event {
    case "send":       return Connected // stay
    case "disconnect": return Idle
    }
    return Connected
}

// Run the state machine
current := Idle
current = current("connect")  // โ†’ Connected
current = current("send")     // โ†’ Connected
current = current("disconnect") // โ†’ Idle

The state machine is the type signature. Each state knows its transitions. No class hierarchy, no context object.

19. Chain of Responsibility

An HTTP request comes in. It needs authentication checked, then rate limiting, then logging, then the actual handler. Each step decides whether to pass the request along or stop it.

In Java you link handler objects in a chain, each with a reference to the next.

In Go this is called middleware. Every Go developer has written this:

type Middleware func(http.Handler) http.Handler

func WithAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !isAuthenticated(r) {
            http.Error(w, "unauthorized", 401)
            return // stop the chain
        }
        next.ServeHTTP(w, r) // pass to next
    })
}

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// Usage
handler := Chain(myHandler, WithAuth, WithRateLimit, WithLogging)

If you've written Go HTTP middleware, you've implemented Chain of Responsibility. You just didn't need to name it.

20. Mediator

You have a chat system. Users shouldn't hold references to every other user. They shouldn't know who's online or how messages get routed. They just send to the room and the room figures it out.

In Java you build a Mediator interface, a concrete mediator, and colleague classes that communicate through it.

In Go it's a struct that owns the channels:

type ChatRoom struct {
    mu    sync.RWMutex // only needed if Join/Send called from multiple goroutines
    users map[string]chan string
}

func NewChatRoom() *ChatRoom {
    return &ChatRoom{users: make(map[string]chan string)}
}

func (c *ChatRoom) Join(name string) <-chan string {
    ch := make(chan string, 10)
    c.mu.Lock()
    c.users[name] = ch
    c.mu.Unlock()
    return ch
}

func (c *ChatRoom) Send(from, to, msg string) {
    c.mu.RLock()
    ch, ok := c.users[to]
    c.mu.RUnlock()
    if ok {
        select {
        case ch <- fmt.Sprintf("[%s]: %s", from, msg):
        default: // drop message if receiver is full
        }
    }
}

Users know the room. The room knows the users. Users don't know each other. That's the whole pattern.

21. Memento

You're building a text editor. The user types, deletes, types more. They hit undo. You need to restore the previous state without exposing the editor's internals.

In Java you need three classes: Originator (the editor), Memento (the saved state), and Caretaker (manages the history).

In Go, value semantics make this trivial. Copy the struct, push it to a slice:

type Editor struct{ content string }

func (e *Editor) Snapshot() string { return e.content }
func (e *Editor) Restore(s string) { e.content = s }

// Undo stack is just a slice
var history []string

history = append(history, editor.Snapshot()) // save
editor.Restore(history[len(history)-1])      // undo

No Memento class. No Caretaker class. A slice is your undo stack.

22. Visitor

You have an AST (abstract syntax tree) with different node types: numbers, addition, multiplication. You want to add operations (evaluate, print, optimize) without modifying the node types themselves.

In Java this is the most ceremony-heavy pattern. A Visitor interface with a visit method per node type. Every node implements accept(Visitor v). Double dispatch to make polymorphism work across two hierarchies.

In Go, type switch:

func Evaluate(node Node) float64 {
    switch n := node.(type) {
    case *Number: return n.Value
    case *Add:    return Evaluate(n.Left) + Evaluate(n.Right)
    case *Mul:    return Evaluate(n.Left) * Evaluate(n.Right)
    }
    return 0
}

func Print(node Node) string {
    switch n := node.(type) {
    case *Number: return fmt.Sprintf("%g", n.Value)
    case *Add:    return fmt.Sprintf("(%s + %s)", Print(n.Left), Print(n.Right))
    case *Mul:    return fmt.Sprintf("(%s * %s)", Print(n.Left), Print(n.Right))
    }
    return ""
}

New operation? Write a new function with a type switch. No changes to existing node types. No double dispatch.

23. Interpreter

You want users to express rules or queries in a mini-language. "Give me electronics over $50" or "retry 3 times with exponential backoff." You need to parse that intent and execute it.

In Java you build an expression tree with abstract and concrete expression classes, terminal and non-terminal nodes.

In Go, functions compose:

type Filter func(item Item) bool

func PriceAbove(min float64) Filter {
    return func(i Item) bool { return i.Price > min }
}

func InCategory(cat string) Filter {
    return func(i Item) bool { return i.Category == cat }
}

func Every(filters ...Filter) Filter {
    return func(i Item) bool {
        for _, f := range filters {
            if !f(i) { return false }
        }
        return true
    }
}

// Build a query by composing functions
expensive := PriceAbove(50)
electronics := InCategory("electronics")
query := Every(expensive, electronics)

// Use it
for _, item := range catalog {
    if query(item) { fmt.Println(item.Name) }
}

Each function is a grammar rule. And combines them. You build complex queries by composing simple functions. No expression tree classes, no parser hierarchy.


The Scorecard

Pattern Go Replacement
Singleton sync.Once
Factory Method Function returning interface
Abstract Factory Struct of factory functions
Builder Functional options (...Option)
Prototype Value copy
Adapter Implicit interface satisfaction
Bridge Struct with interface field
Composite Interface + slice of interface
Decorator Middleware func(H) H
Facade Package exports
Flyweight Shared instance cache (sync.Map)
Proxy Embedding + method override
Strategy Pass a func
Observer Channels or callback slice
Command func in a slice
Template Method Struct with func fields
Iterator range / iter.Seq
State func returning next func
Chain of Responsibility Middleware chain
Mediator Struct with channels
Memento Struct copy
Visitor Type switch
Interpreter Composable functions

What This Means

Go didn't remove design patterns. It absorbed them into the language.

First-class functions kill every pattern that exists to pass behavior around. Strategy, Command, Observer, Template Method. Gone. Implicit interfaces kill every pattern that exists to make types compatible. Adapter, Bridge. Gone. Composition via embedding kills every pattern that relies on inheritance. Proxy, Decorator. Gone.

You don't need to memorize 23 patterns. You need to understand three language features: functions as values, interfaces without declarations, and struct embedding. The rest follows.

Not all patterns disappear completely. Composite is still a recursive tree worth naming. State machines are still state machines. Middleware is still an architectural decision. But the implementation drops from "three classes and an interface" to "five lines and a function signature."

If you're coming from Java or C# and wondering where all the patterns went, they're still here. They're just not ceremonies anymore.

If you're picking up Go and want to write it idiomatically from day one, I have two free courses on ByteLearn โ€” no signup wall:

  • Go Essentials โ€” interfaces, composition, and the language features that replace these patterns. Start here if you're learning Go.
  • Go Concurrency Patterns โ€” pipelines, worker pools, channels, and real-world concurrent design. Start here if you already know Go and want to master concurrency.

Got thoughts on this post?

I'd love to hear from you. Reach out on any of these:

Want to learn by doing?

ByteLearn.dev has free courses with interactive quizzes for developers.

Browse courses โ†’
ยฉ 2026 ByteLearn.dev. Free courses for developers. ยท Privacy