1. Introduction
A public API is like an outlet in the wall: while you’re renovating, you can temporarily run an extension cord across the whole apartment. But once you decide to hang cabinets, install appliances, and actually live there, that extension cord suddenly turns into an architectural decision for years. Layers (storage, transport, UI) are especially painful because they love to “stick” to function signatures and types. If the core is unstable, every new layer starts dragging its requirements into the domain, and the project turns into a layered cake of mutual dependencies.
Stabilization doesn’t mean “never change again.” It means “choose the contract first, then change carefully and predictably.” In Go this is especially important because the language and ecosystem take compatibility very seriously: you can’t just change the type of an exported entity without breaking someone else’s code. The official compatibility text shows well that even changing the value type (for example, turning a concrete type into an interface) can break users who passed it as a concrete type.
2. What counts as a public API in a Go package
When you first hear “public API,” your brain draws Swagger and HTTP endpoints. In Go it’s simpler and at the same time trickier: public is everything that is exported (starts with an uppercase letter) from a package. But in practice the “API surface” is wider: it includes observable behavior, error conventions, and even things like output format stability.
You can think of a public API as a set of promises. Some promises are written in code (exported names), and some are in behavior (which errors we return and how to recognize them). In Go it’s common to make decisions based on errors as values: we check err != nil, compare with a sentinel error, or extract a typed error. This is not a “side effect,” it’s a design tool.
To pin down what people usually “break by accident,” a table helps:
| “API part” | Example | Why it breaks users |
|---|---|---|
| Exported names | |
Renaming/deletion = code does not compile |
| Types in signatures | |
Call style changes and interface compatibility changes |
| Exported fields | |
Users start relying on them; later you can’t add invariants |
| Errors as a contract | |
A user can do errors.Is/errors.As and branch logic |
| Ability to “reveal” the cause | |
You “allow” the user to depend on internal error causes; this is an API decision |
| Observable behavior | output order, string format | Users’ tests/scripts start relying on this |
The practical goal: make your “core” (domain + usecase logic) have a small, clear, and stable API. Then adding storage or HTTP becomes “plugging in an adapter,” not “an organ transplant operation.”
3. The todo core: the Task model and invariants
So we’re not talking in a vacuum, let’s assume we have a learning app “todo”: tasks, creation, marking done, listing. Up to now you might have kept everything in main and passed around []string, map[int]string, and “magic” numbers. That’s fine at the learning stage—your brain needs oxygen, not NASA architecture.
But before adding new layers, it’s important to separate “what a task is” from “where it is stored” and “how it is shown to the user.” In Go this usually starts with a domain model package. Let it be the task package.
Task model: keep fields private
Let’s make a very small and already more stable definition:
package task
type ID int64
type Task struct {
id ID
title string
done bool
}
The key move here is that the fields are not exported. This isn’t “paranoia,” it’s a way to preserve the ability to change internals without breaking the outside world. Once you export Task.Title, you suddenly have an obligation to explain whether it can be an empty string, whether the title can be changed directly, how that affects invariants, and why your app later behaves strangely.
Constructor and invariants
Any model has invariants: rules under which an object is considered “valid.” For a task the invariant is simple: the title must not be empty (and usually must not consist only of whitespace). If you export fields, invariants live “in the developer’s head” (usually not for long). If you make a constructor, the invariant becomes part of the code—and that already looks like an engineering approach.
Let’s add a New constructor and a sentinel error in the Go style:
package task
import (
"errors"
"strings"
)
var ErrInvalidTitle = errors.New("invalid title")
func New(id ID, title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ErrInvalidTitle
}
return Task{id: id, title: title}, nil
}
Why do we return Task{} on error? Because that’s idiomatic in Go: if it didn’t work, return the zero value + error. Then the caller must check the error (and yes, that’s the well-known “idiom” people love and hate Go for at the same time).
Access data via methods
Now let’s expose access to the data via methods:
package task
func (t Task) ID() ID { return t.id }
func (t Task) Title() string { return t.title }
func (t Task) Done() bool { return t.done }
This looks a bit more verbose than “fields exposed,” but later you can add, for example, title normalization, computed fields, caching, logging—all without changing the contract.
Value vs pointer: decide receivers up front
At some point you’ll need to change the task’s status. The most obvious way is a method that changes done. But then you hit a choice: value receiver or pointer receiver? You already know that T and *T have different method sets, and that affects interfaces.
API stabilization means: choose consciously and lock it in. One calm option is to make methods “non-mutating,” returning a new copy instead. Then Task stays a value type with simple rules:
package task
func (t Task) WithDone(done bool) Task {
t.done = done
return t
}
This is a small detail, but it helps future layers a lot: storage and transport like simple “values,” not complex objects with internal lifecycles. And you avoid situations where someone somewhere holds a pointer to a task and unexpectedly sees that it “changed by itself.”
4. Errors as part of the contract
When you add a storage or HTTP layer, errors start traveling through the code like tourists: bottom-up, with transfers, with souvenirs in the form of context. If you don’t stabilize errors up front, you’ll get one of two nightmares: either “fmt.Errorf("something went wrong") everywhere,” or “the user parses err.Error() as a string.”
In Go, errors are values. We make decisions by comparing them and extracting types. Therefore “which errors your package returns” is a direct API contract.
Sentinel errors
For example, “not found” is an excellent candidate for a sentinel error. Let’s define it in the domain or near the usecase layer (depends on architecture). For simplicity of the example, let it be in the task package:
package task
import "errors"
var ErrNotFound = errors.New("not found")
Now an outer layer can write errors.Is(err, task.ErrNotFound) and behave differently.
Typed error for validation
Validation often needs details: which field is invalid and why. A typed error is convenient for that:
package task
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
And an example function that can return it:
package task
import "strings"
func ValidateTitle(title string) error {
if strings.TrimSpace(title) == "" {
return &ValidationError{Fields: map[string]string{"title": "must not be empty"}}
}
return nil
}
A typed error gives you a stable structure: the outer layer doesn’t guess from a string; it pulls fields via errors.As.
Wrapping (%w) is an API decision
When you add context to an error via fmt.Errorf, you have two options: “just text” (%v) or “wrap with a cause” (%w). The key point: %w makes the cause observable and available for recognition. This is not a neutral refactor; it’s a contract choice: do you allow the user to “look inside” your errors? The official error-wrapping write-up explicitly says that deciding whether to unwrap or not is an API decision, analogous to deciding whether to export a struct field.
For example, a usecase can return “task not found,” adding operation context:
package app
import (
"fmt"
"example.com/todo/task"
)
func (s *Service) GetTitle(id task.ID) (string, error) {
_, err := s.repo.ByID(id)
if err != nil {
return "", fmt.Errorf("get title: %w", err)
}
// ...
return "", nil
}
If repo.ByID returns task.ErrNotFound, then an outer layer can do errors.Is(err, task.ErrNotFound)—and that becomes your promise. That’s why API stabilization includes the question: which error causes do we consider part of the contract, and which are internal details.
A short practical recommendation: outward-facing code should usually make recognizable “error classes” like validation / not found. But internal “EOF,” “permission denied,” or a specific database’s errors are better kept internal and hidden behind your domain errors, so you don’t tie users to an implementation. And yes, this doesn’t prevent you from keeping the original error “inside” for logs—just don’t promise that external code can rely on it.
By the way, wrapping mechanics and the “cause chain” arrived as official support in Go 1.13, and the overall approach is described as standard practice.
5. Core boundaries: context, interfaces, determinism, internal
When the core is ready, the next things that start “breaking the contract” are how you plug in infrastructure: context, repository, output order, and what you export outward at all.
context.Context in usecase method signatures
context.Context in Go usually shows up where there is I/O, waiting, and cancellation. And when you add storage or HTTP, context almost inevitably wants to appear in signatures. The problem is that adding a parameter to an existing function is an incompatible API change.
In the standard library world, many Go APIs had to be “retrofitted” for context, and it was done in a way that didn’t break compatibility. One write-up explains that you can’t change signatures, so sometimes they add “duplicate” methods/functions (for example, Call and CallContext) or carry context through a struct, but the second approach can lead to confusion.
For a learning project, the calmest path is this: if you already feel I/O is coming soon, start accepting ctx in usecase methods in advance. Minimal and without philosophy:
package app
import (
"context"
"example.com/todo/task"
)
func (s *Service) Create(ctx context.Context, title string) (task.Task, error) {
_ = ctx // not used yet, but the contract is already stable
// ...
return task.Task{}, nil
}
Yes, at first it looks like a “parameter for looks.” But this is exactly stabilization: you bake the contract shape in before infrastructure grows into it.
Interfaces and package boundaries
Layers are usually connected via interfaces: the core says “I need to save and retrieve tasks,” and a concrete implementation (memory, file, DB) arrives later. It’s important not to mix things up: interfaces should not exist “just in case.” Their job is to lock down the minimum the core actually needs.
Let our usecase layer depend on Repository, but the interface is declared where it’s used (in the app package). This matches Go style: interfaces usually live with the consumer.
package app
import (
"context"
"example.com/todo/task"
)
type Repository interface {
NextID(ctx context.Context) (task.ID, error)
Save(ctx context.Context, t task.Task) error
ByID(ctx context.Context, id task.ID) (task.Task, error)
}
Notice what’s not here: “future” methods, “delete everything,” “export to CSV,” and “make it pretty.” The smaller the interface, the easier it is to plug in new implementations and the less likely you are to break everyone with the next idea.
Deterministic behavior
When you get to outer layers, you suddenly learn that “in which order you output tasks” is also an API. Scripts, tests, integrations, and even your own future code will start expecting stability. This hurts especially if somewhere inside you use a map and “whatever order happens.”
Stabilizing the core here is simple: return data in a predictable order, or explicitly document that order is not guaranteed. For tasks, ordering by ID is often convenient. Suppose the repository returns a slice of tasks, and we want to sort it stably before returning outward:
package app
import (
"slices"
"example.com/todo/task"
)
func sortByID(tasks []task.Task) {
slices.SortFunc(tasks, func(a, b task.Task) int {
return int(a.ID() - b.ID())
})
}
Yes, it’s a small thing. But it turns “chaos that sometimes passes tests” into “a predictable contract.” And the earlier you do it, the fewer surprises you’ll have when plugging in new layers.
Project structure and internal/
When people say “stabilize the API,” they often forget that the cheapest way to stabilize is simply not to export extra stuff. In Go you have a great tool: packages and the internal/ directory, which forbids imports from the outside. It’s literally a “fence” around implementation details.
One possible neat layout for a learning todo project (without tying to a specific IDE) could look like this:
todo/
cmd/
todo/
main.go
task/
task.go
errors.go
internal/
app/
service.go
repo_memory.go
The logic here is: the task package is the domain model; you can consider it part of the “external” API. The internal/app package is usecase logic and repository implementations that we want to keep under control for now. When it’s time to add new layers, you can add adapters nearby without changing the domain.
An example of the “wiring” in main.go stays simple and honest:
package main
import (
"context"
"fmt"
"example.com/todo/internal/app"
)
func main() {
svc := app.NewService(app.NewMemoryRepo())
t, err := svc.Create(context.Background(), "buy milk")
if err != nil {
fmt.Println("error:", err) // error: ...
return
}
fmt.Println("created:", t.Title()) // created: buy milk
}
The stabilization idea here is that the outside world (CLI, and later other layers) talks to the service through a small set of methods, while repository internals don’t stick out yet.
6. API freeze checklist before adding a layer
Stabilization is not magic and not “a feeling that everything is fine.” It’s a small checklist: you ask yourself the right questions and fix what will be expensive to change later.
It’s convenient to picture it as a decision flow:
flowchart TD
A[Want to add a new layer] --> B[Audit exported names]
B --> C[Model invariants: what is valid?]
C --> D[Errors as a contract: which classes do we recognize?]
D --> E[Signatures: do we need context?]
E --> F[Determinism: order/format/stability]
F --> G[Tests and examples lock in the contract]
G --> H[Plug in a new layer without rewriting the core]
In “human text”: before adding a layer, you reduce the exported surface, lock invariants via constructors and validation, choose an error strategy (including wrapping), decide about context in signatures, stabilize order/format, and then cement everything with tests. Only then do you add the layer—otherwise the layer will start dictating requirements to the core, and you’ll be “patching the contract on the fly.”
7. Common mistakes when stabilizing a public API
Mistake #1: exporting struct fields “to make it easier.”
At first it really is easier: t.Title = ""—done. But a week later you’ll want to forbid an empty title, and you already have users (including yourself) writing into the field directly. Constructors and accessors look boring, but they leave you room to evolve.
Mistake #2: thinking “error text is just text.”
In Go, errors are values, and logic is often branched on them. If you didn’t provide a stable way to recognize an error class (ErrNotFound, ValidationError), people will start comparing strings or doing strings.Contains(err.Error(), "..."), and that will become a “public API” too—just a terrible one.
Mistake #3: wrapping everything with %w indiscriminately.
Wrapping makes error causes observable, which turns internal details into part of the contract. Official materials emphasize that the decision “to make unwrap possible or not” is an API decision, not a mechanical habit.
Mistake #4: putting context “inside a struct” and then being surprised by the confusion.
Context has a lifetime scope, and when you store it on an object, you mix lifecycles and make it harder to call with different deadlines/cancellation. The analysis of API evolution with context shows why this leads to non-obvious behavior, and why it’s often better to accept ctx as a parameter.
Mistake #5: bloating interfaces “for the future.”
An interface with 12 methods seems “universal” until you try to write a second implementation. The smaller the interface, the fewer obligations and the easier it is to plug in new layers. Large interfaces almost always mean you merged multiple roles into one.
GO TO FULL VERSION