DEV Community

Cover image for Effective Go in 2026: The Idioms That Still Decide Good Go Code
Gabriel Anhaia
Gabriel Anhaia

Posted on

Effective Go in 2026: The Idioms That Still Decide Good Go Code


Open a Go codebase you inherited. Read one package. Within a minute
you can tell whether the person who wrote it thought in Go or thought
in the language they came from and typed it out in Go syntax.

The tells are old. The Effective Go
document has barely changed since the early days, and it still
predicts code quality better than any linter. Go 1.23 added generics
maturity, iterators, and a modernized toolchain, but the idioms that
decide good code from bad are the same four they always were:
composition instead of inheritance, small interfaces, errors as
values, and sharing memory by communicating. This post is those four,
in current Go, with the mistakes each one is meant to prevent.

Composition, because Go has no inheritance to fall back on

Developers arriving from Java or C# look for extends. Go doesn't
have it. What it has is embedding, and the reflex is to treat
embedding as inheritance with a different keyword. That reflex writes
bad Go.

Embedding is composition. You put a value inside a struct and its
methods get promoted to the outer type. There is no base class, no
virtual dispatch, no super. The outer type is not an is-a, it's a
has-a that forwards.

type Logger struct {
    prefix string
}

func (l Logger) Logf(f string, a ...any) {
    log.Printf(l.prefix+f, a...)
}

type Server struct {
    Logger // embedded, not inherited
    addr   string
}
Enter fullscreen mode Exit fullscreen mode

Server now has a Logf method for free. Not because it descends
from Logger, but because the compiler forwards the call to the
embedded field. You can override it by declaring Logf on Server
directly, and the inner one is still reachable as s.Logger.Logf.
Nothing is hidden.

The idiom that follows: embed behavior you want to reuse, and keep
the surface small. When you find yourself embedding a type only to
get one of its five methods, you wanted a field, not an embed. The
JVM habit of building deep type hierarchies has no payoff here. The
Go standard library goes maybe two levels deep and stops.

Small interfaces, defined by the consumer

The single most common design mistake in Go written by people from
other ecosystems: big interfaces, declared next to the
implementation, with one implementing type. That is an interface used
as a Java-style contract, and it fights the language.

Go interfaces are satisfied implicitly. A type implements an
interface by having the methods, with no implements keyword. That
one decision changes where interfaces should live and how big they
should be. The standard library's most reused interfaces have one
method:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}
Enter fullscreen mode Exit fullscreen mode

io.Reader and io.Writer are one method each, and half the
standard library composes around them. The lesson is in the size.
The bigger an interface is, the fewer types satisfy it and the less
it's worth.

The second half of the idiom: define the interface where you consume
it, not where you implement it. If your HTTP handler needs to load a
user, the handler's package declares the small interface it needs.

// in the handler package — the consumer
type userLoader interface {
    Load(ctx context.Context, id string) (*User, error)
}

func Handler(u userLoader) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user, err := u.Load(r.Context(), r.PathValue("id"))
        if err != nil {
            http.Error(w, "not found", 404)
            return
        }
        writeJSON(w, user)
    }
}
Enter fullscreen mode Exit fullscreen mode

The concrete repository in your data layer never mentions
userLoader. It just has a Load method with the right signature,
and it satisfies the interface automatically. The handler depends on
a two-line abstraction it owns, not on the whole repository. Tests
pass a fake with one method. This is the pattern that keeps large Go
services decoupled, and it only works because interfaces are small
and consumer-side.

Errors are values, so handle them like values

Go has no exceptions. The community fought this for a decade and Go
1.13's errors.Is/errors.As/%w settled it: an error is a value
you return, inspect, wrap, and compare. panic is for programmer
mistakes and unrecoverable state, not for control flow.

The idiom that trips people is comparison. The moment anyone wraps an
error for context, a == check against a sentinel stops working,
because the wrapped value is a different concrete type.

var ErrNotFound = errors.New("user not found")

func (r *Repo) Load(id string) (*User, error) {
    row := r.db.QueryRow(q, id)
    // ... scan ...
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("repo.Load %s: %w",
            id, ErrNotFound)
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

Downstream, you inspect with errors.Is, which walks the wrap chain:

u, err := repo.Load(id)
if errors.Is(err, ErrNotFound) {
    // handle the 404 case
}
Enter fullscreen mode Exit fullscreen mode

When you need the concrete error to read fields off it, errors.As
does the type-directed unwrap:

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    log.Printf("failed on path %s", pathErr.Path)
}
Enter fullscreen mode Exit fullscreen mode

Three rules carry most of the weight. Wrap with %w when you add
context and the caller might want to inspect the cause. Use %v when
the error is an implementation detail the caller should not couple
to. And never inspect an error by string-matching its Error()
output. The string is for humans; the type and the wrap chain are for
code.

Go 1.20 added errors.Join for the case you have several failures to
return at once, and it composes with errors.Is the same way:

err := errors.Join(validateName(n), validateAge(a))
if err != nil {
    return err // both failures, one value
}
Enter fullscreen mode Exit fullscreen mode

Share memory by communicating

The line is on a T-shirt: "Do not communicate by sharing memory;
share memory by communicating." It reads like a slogan until you've
debugged a data race at 2 a.m. The idiom is that ownership of a piece
of data lives with one goroutine, and other goroutines get access by
passing messages over channels, not by grabbing a shared mutex around
a shared variable.

Here is the version people reach for first, coming from threads:

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with this for a plain counter. sync.Mutex and
sync/atomic are the right tool when the shared state is small and
the critical section is trivial. The idiom pushes back when the state
grows and the locking spreads across methods, because that is where
lock-ordering bugs and forgotten unlocks live.

The channel version gives one goroutine sole ownership of the state,
and every mutation is a message:

type cmd struct {
    delta int
    reply chan int
}

func runCounter(cmds <-chan cmd) {
    n := 0 // owned by this goroutine alone
    for c := range cmds {
        n += c.delta
        if c.reply != nil {
            c.reply <- n
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No mutex, because there is nothing shared. The n variable is a
local, touched by exactly one goroutine. Callers send a cmd and, if
they want the result, read it back off reply. The race is
impossible by construction, not by discipline.

For the common fan-out case, the answer from the
golang.org/x/sync libraries is
errgroup,
which pairs goroutines with a shared context and the first error:

g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
    g.Go(func() error {
        return fetch(ctx, u)
    })
}
if err := g.Wait(); err != nil {
    return err // first failure cancels the rest
}
Enter fullscreen mode Exit fullscreen mode

Go 1.22 fixed the loop-variable capture that used to make that loop a
trap, so u is per-iteration now and the closure is safe. The idiom
underneath is unchanged: goroutines coordinate through a channel and a
context, and no two of them write the same memory.

The through-line

None of these four is new. Composition over inheritance, small
consumer-side interfaces, errors as values, and communication over
shared memory were in Effective Go before generics, before modules,
before context. The toolchain got better around them. The idioms
held.

The reason they still decide good Go from bad is that each one is a
place where the language quietly refuses to do what your previous
language did. No extends. No implements. No throw. No shared-heap
threading model as the default. Fight those refusals and you write Go
that compiles but reads like a translation. Lean into them and you get
code that a stranger can read in a minute, which was the whole point.

Pick one package you own. Find the biggest interface in it and ask
which methods the caller actually uses. That is usually the fastest
Effective-Go win still sitting in a 2026 codebase.


These four idioms are the spine of every good Go service, and each
one has more depth than a single post can hold. The Complete Guide to
Go Programming
works through the language and runtime that make them
work — interface internals, the memory model, and how the scheduler
turns "share by communicating" into real concurrency. Hexagonal
Architecture in Go
is about keeping those idioms at the right
boundary, so small interfaces and error values stay honest across the
ports and adapters of a service that has to survive on-call.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)