1. Package contract: what exactly you promise to external code
When you write code in a single file, it feels like everything is under control: “well, I know what’s going on here.” But as soon as a package appears, the code turns into a small library. And suddenly it turns out: your future “user” is you a month from now (without fresh memories), your coworker, tests, and sometimes even a completely different project.
A package contract is a set of promises the package makes to the outside world. In Go, the most direct language of the contract is the exported API: exported types, functions, methods, variables, constants. Change the contract and you can break users. That’s literally one of the core meanings of compatibility: you can’t “just remove an API,” because someone may already be relying on it.
Let’s think practically: a good contract makes a package predictable. A bad contract makes a package a “box of surprises,” where every attempt to “improve” the implementation risks blowing up external code.
Minimal API: exports
There’s a simple (and slightly sad) truth: an exported name is almost impossible to “take back painlessly”. Sure, you can mark it “deprecated” and keep a permanent museum in the code, but that’s a separate kind of art.
That’s why one of the most useful habits in Go is to start with minimal exports. The logic is: anything not exported can be changed every day, as long as tests pass and behavior stays the same. Anything exported starts living by the rules of “we promised this.”
Imagine the package is a cafe. The exported API is the menu. If you print an 80-page menu every day, visitors spend time reading instead of eating. A minimal API is a short menu where every dish actually gets ordered.
A table that helps you “sober up”
| Element | Exported? | What it means in practice |
|---|---|---|
|
Yes/No | Outside code can create/compare/copy values and expect stability |
| Struct fields | Yes/No | Exported field = external code can change it directly (invariants leak) |
|
Yes | You promise a way to create it correctly |
|
Yes | You promise a distinguishable error cause (it will be checked via errors.Is) |
|
Yes | You promise behavior and signature; renaming is a breaking change |
The main idea: export not because “it’s convenient right now,” but because “it’s truly part of the contract.”
2. Model invariants: why it’s better to hide fields
Very often beginners (and sometimes not only beginners) do this: create a struct, export its fields, and celebrate that “now everything is simple.” But that’s the kind of simplicity of “removed the fridge door—now it’s easier to reach the food.” Yes, easier. But there’s a nuance: the temperature also became “easier,” because it no longer exists.
Invariants are rules for data correctness. For example, for a task in a TODO app you can easily come up with invariants: id must be positive, title must not be empty, done is a boolean state, not a string like "yes pls".
If fields are exported, any external code can do:
t := todo.Task{}
t.ID = -100
t.Title = ""
And that’s it—your package no longer controls its own model.
Practice: make the model safe
Below is a small but very typical pattern: hide fields, create via a constructor, and expose access methods to the outside.
package todo
import (
"errors"
"strings"
)
type Task struct {
id int
title string
done bool
}
func NewTask(id int, title string) (Task, error) {
title = strings.TrimSpace(title)
if id <= 0 {
return Task{}, errors.New("id must be positive")
}
if title == "" {
return Task{}, errors.New("title is empty")
}
return Task{id: id, title: title}, nil
}
Notice: you cannot correctly create Task{} from the outside, because the fields aren’t visible. But you can create it via NewTask, and that’s where you enforce the invariants.
Next, we’ll add methods (and yes, a short receiver name is fine):
package todo
func (t Task) ID() int { return t.id }
func (t Task) Title() string { return t.title }
func (t Task) Done() bool { return t.done }
And separately, a method that changes state—but now under your control:
package todo
func (t *Task) MarkDone() {
t.done = true
}
Now external code can’t “accidentally” make a task invalid while bypassing your package.
4. Errors as part of the contract: what you can promise outward
Errors in Go are values the code works with, not just text on the screen. This is especially important for package contracts: as soon as external code starts distinguishing error causes, you’re no longer “returning a string,” you’re emitting a signal someone can rely on.
“Wrap or not wrap” is an API-level decision
Here’s the key idea worth remembering: if you wrap an error via %w, you make the original error part of the API, because external code will be able to write errors.Is(...) and start relying on it.
Why does this matter? Because sometimes it’s correct (for example, if the error source was provided by the package user), and sometimes it’s a leak of implementation details.
The Go blog on errors has a very practical example: if your package uses database/sql internally and returns sql.ErrNoRows outward via wrapping, then external code will start checking specifically for sql.ErrNoRows. Later you’ll want to change the storage implementation—and suddenly it turns out you can’t without breaking users.
So wrapping isn’t “prettier,” it’s “exposing your internals.”
Stable error causes for your own package
For a TODO package, we usually need external code to be able to distinguish a few causes: “not found,” “validation,” “internal error.” Let’s start with the simplest thing: a sentinel error.
package todo
import "errors"
var ErrNotFound = errors.New("not found")
Now inside the package we can guarantee: if a task is not found, the error returned outward is recognized as ErrNotFound.
An example function that adds context and keeps the cause:
package todo
import "fmt"
func (s *Service) Get(id int) (Task, error) {
t, err := s.store.Get(id)
if err != nil {
return Task{}, fmt.Errorf("get task %d: %w", id, err)
}
return t, nil
}
But there’s an important caveat: what exactly is inside err? If s.store is an internal detail and you don’t want its errors to be exported outward, then %w may be unnecessary. And that’s already a contract decision.
Often the pattern is: at the bottom (inside the package) you distinguish the details, but outward you return errors of your domain, without revealing “which exact driver/file/format it was.”
5. Clear dependencies: “explicitly pass what you need”
In bad code, dependencies appear like mushrooms after rain: inside a function a logger suddenly gets created, a file is opened, current time is taken, network calls happen, config is read from the environment. You open the function and see not business logic, but shamanism and side effects.
In good Go code, dependencies are made explicit. This isn’t about being “trendy,” it’s about predictability. When a dependency is explicit, it’s easier to understand what the function behavior depends on, easier to replace the dependency in tests, and easier to avoid hiding global state.
Mini rule: a package should not decide “where to get the world”
A package should be able to work with what it is given. If it needs “now,” give it a “clock.” If it needs storage, give it a “store.” And you don’t have to turn this into a cosmic DI container: in Go, a constructor is usually enough.
Let’s make a small Store interface. The important thing is not to slide into interface pollution: the interface should reflect real needs of the service, not “everything the database can do.”
package todo
type Store interface {
Get(id int) (Task, error)
Save(t Task) error
}
And a clock is a separate small contract (Go folks love doing this because it’s simple and testable):
package todo
import "time"
type Clock interface {
Now() time.Time
}
Now Service depends explicitly on Store and Clock:
package todo
type Service struct {
store Store
clock Clock
}
func NewService(store Store, clock Clock) *Service {
return &Service{store: store, clock: clock}
}
Notice: NewService is part of the package contract. You promise: the service is created this way, and dependencies are passed this way.
Why “I’ll create it inside” is almost always worse
If we did this:
func NewService() *Service {
return &Service{
store: NewPostgresStoreFromEnv(),
clock: RealClock{},
}
}
then the service would become “magical.” It would depend on the environment, on a specific Postgres, on a specific way to read configuration—and all of that would be hidden. External code doesn’t understand why the service works this way, and it can’t easily replace dependencies.
And it also creates an unpleasant contract: to use the package, you must “end up in the right state of the world.” This usually breaks at an unexpected moment—for example, in tests or in a different environment.
6. Example: a small todo app
Now let’s assemble this into a mini picture. We have a todo package that provides a minimal API, keeps invariants, and requires dependencies explicitly. For understanding, it helps to see a file structure (this is not a command and not an “assignment,” just a visual reference):
todo/
task.go // model + invariants
errors.go // domain error causes
service.go // package use-case layer
store_mem.go // internal store implementation (type may be unexported)
main.go // app assembly (in another package)
Internal in-memory store: hide the type, choose the constructor by contract
Let’s make an in-memory store. Important: you can keep the type unexported if you don’t want to promise it outward. At the same time, you can export the constructor if you’re willing to support it as part of the API.
Below is an option: the type is not exported, while the constructor is exported and returns the Store interface (this ties external code less to a concrete struct).
package todo
import "fmt"
type memStore struct {
items map[int]Task
}
func NewInMemoryStore() Store {
return &memStore{items: make(map[int]Task)}
}
func (m *memStore) Get(id int) (Task, error) {
t, ok := m.items[id]
if !ok {
return Task{}, fmt.Errorf("%w", ErrNotFound)
}
return t, nil
}
And saving:
package todo
func (m *memStore) Save(t Task) error {
m.items[t.ID()] = t
return nil
}
Here we return ErrNotFound as the cause. You can do it without fmt.Errorf as well—just return Task{}, ErrNotFound. An “empty wrap” is usually unnecessary, but it’s useful to understand the technique: wrapping affects the contract.
Service: a “narrow” API and clear errors
Let’s add task creation via the service, where the service chooses the ID itself (yes, it’s a convention, but fine for an example). Pay attention to the happy path: it’s at the bottom.
package todo
import "fmt"
func (s *Service) Create(title string) (Task, error) {
id := int(s.clock.Now().Unix()) // simplest example, not for production
t, err := NewTask(id, title)
if err != nil {
return Task{}, fmt.Errorf("create task: %w", err)
}
if err := s.store.Save(t); err != nil {
return Task{}, fmt.Errorf("save task: %w", err)
}
return t, nil
}
Now look through the eyes of the “package user”: Create either returns a task or an error. The error carries context. External code can distinguish ErrNotFound (if that matters), but it doesn’t have to know that there is a memStore inside or what database is used.
Wiring in main: dependencies are created at the app boundary
At the app boundary we choose dependency implementations. Inside the todo package we don’t “guess” what the world needs.
package main
import (
"fmt"
"time"
"example.com/app/todo"
)
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func main() {
store := todo.NewInMemoryStore()
svc := todo.NewService(store, realClock{})
t, err := svc.Create("buy milk")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("created:", t.Title()) // created: buy milk
}
If you don’t want to promise even the NewInMemoryStore constructor outward, that’s already an application-architecture decision: you can create memStore on the main side (for example, via an internal package or an app-level factory), and keep the todo package as domain-focused as possible.
Compatibility and API changes: why “I’ll just rename it” is not that simple
Sometimes it feels like: “I’ll rename Task.Title() to Task.Name()—and that’s it.” But if it’s exported, your package may already be used somewhere else. The Go team, in the context of compatibility, explicitly emphasizes: removing or changing exported elements breaks programs that depend on them.
A useful habit: before exporting something, ask yourself: “Am I ready to support this name and this signature for a long time?” If you’re unsure, it’s better not to export it.
7. Typical mistakes
Mistake #1: exporting struct fields “for convenience.”
This usually leads to invariants ceasing to exist, and the package starts receiving invalid data “from the outside.” Then you add checks to every function, and the code turns into an obstacle course. It’s much calmer to hide the fields and provide one correct creation path.
Mistake #2: turning a package into a “dump” of everything.
When one package contains the model, parsing, printing, file handling, and networking, the contract becomes unreadable. External code can’t tell “what this package even promises.” One package—one clear responsibility.
Mistake #3: hiding dependencies inside the package.
If a package reads env itself, opens files itself, creates clients itself—the code becomes magical. It’s hard to test and hard to understand. Clear dependencies via a constructor make behavior transparent.
Mistake #4: mindlessly doing %w at all layers.
Wrapping via %w isn’t just “adding context,” it’s an API decision: you allow external code to recognize the internal cause via errors.Is/errors.As. It’s useful when the cause truly must be visible, but harmful when you leak implementation details (for example, a specific DB driver).
Mistake #5: returning “random strings” outward instead of stable causes.
If external code must distinguish “not found,” “validation,” “no access,” then a string is a bad contract. Today the text is one thing, tomorrow it’s another, and external logic breaks. It’s better to provide stable causes (sentinel/typed errors) and translate them into user-facing messages at the application boundary.
GO TO FULL VERSION