JavaRush /Courses /Go SELF /Stabilizing public APIs

Stabilizing public APIs

Go SELF
Level 38, Lesson 2
Available

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
type Task struct
func NewTask(...)
Renaming/deletion = code does not compile
Types in signatures
func Create(t Task) → func Create(t *Task)
Call style changes and interface compatibility changes
Exported fields
Task.Title string
Users start relying on them; later you can’t add invariants
Errors as a contract
ErrNotFound, ValidationError
A user can do errors.Is/errors.As and branch logic
Ability to “reveal” the cause
wrapping via %w
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.

1
Task
Go SELF, level 38, lesson 2
Locked
Task encapsulation
Task encapsulation
1
Task
Go SELF, level 38, lesson 2
Locked
Error contract
Error contract
1
Task
Go SELF, level 38, lesson 2
Locked
Service with ctx
Service with ctx
1
Task
Go SELF, level 38, lesson 2
Locked
List by ID
List by ID
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION