1. Kontrakt pakietu: co dokładnie obiecujesz kodowi z zewnątrz
Kiedy piszesz kod w jednym pliku, wydaje się, że wszystko jest pod kontrolą: „no przecież wiem, co tu się dzieje”. Ale gdy tylko pojawia się pakiet, kod zamienia się w małą bibliotekę. I nagle okazuje się, że twoim przyszłym „użytkownikiem” jesteś ty sam za miesiąc (i już bez świeżych wspomnień), twój kolega, testy, a czasem nawet inny projekt.
Kontrakt pakietu to zestaw obietnic, które pakiet składa światu zewnętrznemu. W Go najbardziej bezpośrednim językiem kontraktu jest eksportowane API: eksportowane typy, funkcje, metody, zmienne, stałe. Zmienisz kontrakt — możesz złamać użytkowników. To dosłownie jeden z podstawowych sensów kompatybilności: nie można „po prostu wziąć i usunąć API”, bo ktoś mógł już się na nim oprzeć.
Myślmy praktycznie: dobry kontrakt sprawia, że pakiet jest przewidywalny. Zły kontrakt sprawia, że pakiet staje się „pudełkiem niespodzianek”, gdzie każda próba „ulepszenia” implementacji grozi wysadzeniem kodu zewnętrznego.
Minimalne API: eksport
Istnieje prosta (i trochę smutna) prawda: eksportowanej nazwy prawie nigdy nie da się „bezboleśnie zabrać z powrotem”. Oczywiście można ogłosić „przestarzałe” i trzymać w kodzie wieczne muzeum, ale to już osobny rodzaj sztuki.
Dlatego jednym z najcenniejszych nawyków w Go jest zaczynanie od małego eksportu. Logika jest taka: to, co nie zostało wyeksportowane, można zmieniać nawet codziennie, dopóki testy przechodzą i zachowanie pozostaje takie samo. To, co zostało wyeksportowane, zaczyna żyć według zasad „to obiecaliśmy”.
Wyobraź sobie, że pakiet to kawiarnia. Eksportowane API to menu. Jeśli codziennie drukujesz 80 stron menu, goście tracą czas nie na jedzenie, ale na czytanie. Minimalne API to krótkie menu, w którym naprawdę zamawia się każde danie.
Tabelka, która pomaga „otrzeźwieć”
| Element | Wyeksportowano? | Co to oznacza w praktyce |
|---|---|---|
|
Tak/Nie | Z zewnątrz można tworzyć/porównywać/kopiować wartości i oczekiwać stabilności |
| Pola struktury | Tak/Nie | Eksport pola = kod zewnętrzny może zmieniać je bezpośrednio (niezmienniki wyciekają) |
|
Tak | Obiecujesz sposób poprawnego tworzenia |
|
Tak | Obiecujesz rozpoznawalną przyczynę błędu (będzie sprawdzana przez errors.Is) |
|
Tak | Obiecujesz zachowanie i sygnaturę; zmiana nazwy to zmiana łamiąca |
Główna idea: eksportujemy nie dlatego, że „jest wygodnie teraz”, ale dlatego, że „to naprawdę jest część kontraktu”.
2. Niezmienniki modelu: dlaczego lepiej ukrywać pola
Bardzo często początkujący (a czasem nie tylko początkujący) robią tak: tworzą strukturę, eksportują pola — i cieszą się, że „teraz wszystko jest proste”. Ale to prostota na poziomie „zdjąłem drzwi z lodówki — produkty teraz łatwiej wyjmować”. Tak, łatwiej. Ale jest haczyk: temperatura też stała się „wygodniejsza”, bo już jej nie ma.
Niezmienniki to zasady poprawności danych. Na przykład dla zadania w aplikacji TODO łatwo wymyślić niezmienniki: id musi być dodatnie, title nie może być puste, done to stan logiczny, a nie ciąg znaków "yes pls".
Jeśli pola są eksportowane, dowolny kod zewnętrzny może zrobić:
t := todo.Task{}
t.ID = -100
t.Title = ""
I to wszystko — twój pakiet przestaje kontrolować własny model.
Praktyka: robimy model bezpieczny
Poniżej mały, ale bardzo typowy wzorzec: pola ukrywamy, tworzenie robimy przez konstruktor, a na zewnątrz dajemy metody dostępu.
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
}
Zwróć uwagę: Task{} nie da się poprawnie utworzyć z zewnątrz, bo pola są niewidoczne. Za to można utworzyć przez NewTask, a tam utrzymasz niezmienniki.
Dalej dodajmy metody (i tak, krótki receiver jest całkowicie normalny):
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 }
I osobno metoda, która zmienia stan — ale już pod twoją kontrolą:
package todo
func (t *Task) MarkDone() {
t.done = true
}
Teraz kod zewnętrzny nie może „przez przypadek” zrobić zadania niepoprawnym, omijając twój pakiet.
4. Błędy jako część kontraktu: co można obiecać na zewnątrz
Błędy w Go to wartości, z którymi pracuje kod, a nie tylko tekst na ekranie. To ważne właśnie dla kontraktów pakietów: w momencie, gdy kod zewnętrzny zaczyna rozróżniać przyczyny błędów, nie zwracasz już tylko „ciągu znaków”, ale przekazujesz sygnał, na którym można się oprzeć.
„Wrap czy nie wrap” — decyzja na poziomie API
Oto kluczowa myśl, którą warto zapamiętać: jeśli owijasz błąd przez %w, czynisz oryginalny błąd częścią API, bo kod zewnętrzny będzie mógł napisać errors.Is(...) i zacząć na nim polegać.
Dlaczego to ważne? Bo czasem to jest właściwe (na przykład, jeśli źródło błędu dostarczył sam użytkownik pakietu), a czasem jest to wyciek szczegółów implementacji.
W Go‑blogu o błędach jest bardzo praktyczny przykład: jeśli twój pakiet wewnętrznie używa database/sql i zwraca na zewnątrz sql.ErrNoRows przez wrapping, to kod zewnętrzny zacznie sprawdzać właśnie sql.ErrNoRows. A potem będziesz chciał zmienić implementację przechowywania — i nagle okaże się, że nie możesz, nie łamiąc użytkowników.
Czyli wrapping to nie „ładniej”, ale „otwieram wnętrze na zewnątrz”.
Stabilne przyczyny błędów dla własnego pakietu
Dla pakietu TODO zazwyczaj chcemy, aby kod zewnętrzny mógł rozróżniać kilka przyczyn: „nie znaleziono”, „walidacja”, „błąd wewnętrzny”. Zacznijmy od najprostszego: sentinel error.
package todo
import "errors"
var ErrNotFound = errors.New("not found")
Teraz w kodzie pakietu możemy zagwarantować: jeśli zadanie nie zostanie znalezione, na zewnątrz wyjdzie błąd rozpoznawany jako ErrNotFound.
Przykład funkcji, która dodaje kontekst i zachowuje przyczynę:
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
}
Ale tu jest ważne zastrzeżenie: co dokładnie leży w err? Jeśli s.store to wewnętrzny szczegół, a jego błędów nie chcesz eksportować na zewnątrz, to %w może być zbędne. I to już jest decyzja kontraktowa.
Często robi się tak: na dole (wewnątrz pakietu) rozróżnia się szczegóły, a na zewnątrz oddaje się błędy własnej domeny, nie ujawniając „jaki tam dokładnie jest sterownik/pliki/format”.
5. Jasne zależności: „jawnie przekaż to, czego potrzebujesz”
W złym kodzie zależności pojawiają się jak grzyby po deszczu: wewnątrz funkcji nagle tworzony jest logger, otwierany jest plik, pobierany jest bieżący czas, łączymy się z siecią, czytamy konfigurację z env. Czytelnik otwiera funkcję — i widzi nie logikę biznesową, tylko szamanizm i efekty uboczne.
W dobrym kodzie Go zależności stara się robić jawnie. To nie jest kwestia „modne”, ale przewidywalności. Gdy zależność jest jawna, łatwiej zrozumieć, od czego zależy zachowanie funkcji, łatwiej podmienić zależność w testach i łatwiej nie ukrywać stanu globalnego.
Mini‑zasada: pakiet nie powinien sam decydować, „skąd wziąć świat”
Pakiet powinien umieć pracować z tym, co dostał. Jeśli potrzebuje „teraz”, niech dostanie „zegary”. Jeśli potrzebuje przechowywania, niech dostanie „magazyn”. I nie trzeba od razu zamieniać tego w kosmiczny kontener DI: w Go najczęściej wystarcza konstruktor.
Zróbmy mały interfejs Store. Ważne, żeby nie popaść w interface pollution: interfejs powinien odzwierciedlać realne potrzeby serwisu, a nie „wszystko, co potrafi baza”.
package todo
type Store interface {
Get(id int) (Task, error)
Save(t Task) error
}
A zegar — osobny mały kontrakt (w Go bardzo lubi się tak robić, bo jest to proste i testowalne):
package todo
import "time"
type Clock interface {
Now() time.Time
}
Teraz Service zależy jawnie od Store i Clock:
package todo
type Service struct {
store Store
clock Clock
}
func NewService(store Store, clock Clock) *Service {
return &Service{store: store, clock: clock}
}
Zwróć uwagę: NewService jest częścią kontraktu pakietu. Obiecujesz: serwis tworzy się tak, a zależności przekazuje się tak.
Dlaczego „stworzę wewnątrz” jest prawie zawsze gorsze
Gdybyśmy zrobili tak:
func NewService() *Service {
return &Service{
store: NewPostgresStoreFromEnv(),
clock: RealClock{},
}
}
to serwis stałby się „magiczny”. Zależałby od środowiska, od konkretnego Postgresa, od konkretnego sposobu odczytu konfiguracji — i wszystko to byłoby ukryte. Kod zewnętrzny nie rozumie, dlaczego serwis działa właśnie tak, i nie może łatwo podmienić zależności.
A to tworzy nieprzyjemny kontrakt: żeby korzystać z pakietu, trzeba „trafić w prawidłowy stan świata”. Zwykle psuje się to w nieoczekiwanym momencie — na przykład w testach albo w innym środowisku.
6. Przykład: mała aplikacja todo
Teraz złożymy to w miniaturowy obraz. Mamy pakiet todo, który udostępnia minimalne API, pilnuje niezmienników i wymaga zależności jawnie. Dla zrozumienia warto widzieć strukturę plików (to nie jest komenda ani „zadanie”, tylko po prostu orientacja dla oka):
todo/
task.go // model + niezmienniki
errors.go // domenowe przyczyny błędów
service.go // warstwa use-case pakietu
store_mem.go // wewnętrzna implementacja store (typ może być nieeksportowany)
main.go // budowa aplikacji (w innym pakiecie)
Wewnętrzny magazyn w pamięci: typ ukrywamy, konstruktor wybieramy zgodnie z kontraktem
Zróbmy in-memory store. Ważne: typ można nie eksportować, jeśli nie chcesz obiecywać go na zewnątrz. Jednocześnie konstruktor można eksportować, jeśli jesteś gotów wspierać go jako część API.
Poniżej wariant: typ nie jest eksportowany, a konstruktor jest eksportowany i zwraca interfejs Store (w ten sposób mniej przywiązujesz kod zewnętrzny do konkretnej struktury).
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
}
Zapis:
package todo
func (m *memStore) Save(t Task) error {
m.items[t.ID()] = t
return nil
}
Tutaj zwracamy ErrNotFound jako przyczynę. Można też bez fmt.Errorf, po prostu return Task{}, ErrNotFound. „Pusty wrap” zwykle nie jest potrzebny, ale samą technikę warto rozumieć: wrapping wpływa na kontrakt.
Serwis: „wąskie” API i jasne błędy
Dodajmy tworzenie zadania przez serwis, gdzie serwis sam wybiera ID (tak, to uproszczenie, ale do przykładu pasuje). Zwróć uwagę na happy path: na dole.
package todo
import "fmt"
func (s *Service) Create(title string) (Task, error) {
id := int(s.clock.Now().Unix()) // najprostszy przykład, nie do produkcji
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
}
Teraz patrz oczami „użytkownika pakietu”: Create albo zwraca zadanie, albo błąd. Błąd niesie kontekst. Kod zewnętrzny może rozróżniać ErrNotFound (jeśli to ważne), ale nie musi wiedzieć, że wewnątrz działa memStore albo jaka tam jest baza.
Złożenie w main: zależności tworzone są na granicy aplikacji
Na granicy aplikacji wybieramy implementację zależności. We wnętrzu pakietu todo nie „zgadujemy”, czego potrzebuje świat.
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("kupić mleko")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("created:", t.Title()) // created: kupić mleko
}
Jeśli nie chcesz obiecywać na zewnątrz nawet konstruktora NewInMemoryStore, to jest już decyzja na poziomie architektury aplikacji: można tworzyć memStore po stronie main (na przykład przez wewnętrzny pakiet albo fabrykę na poziomie aplikacji), a pakiet todo pozostawić maksymalnie „domenowym”.
Kompatybilność i zmiany API: dlaczego „po prostu zmienię nazwę” nie jest takie proste
Czasem wydaje się: „zmienię nazwę Task.Title() na Task.Name() — i po sprawie”. Ale jeśli to jest eksportowane, twój pakiet mógł już być używany gdzieś indziej. Zespół Go w kontekście kompatybilności wyraźnie podkreśla: usunięcie lub zmiana eksportowanych elementów łamie programy, które są od nich zależne.
Dobra praktyka: zanim coś wyeksportujesz, zadaj sobie pytanie: „czy jestem gotów długo wspierać tę nazwę i tę sygnaturę?” Jeśli masz wątpliwości — lepiej nie eksportować.
7. Typowe błędy
Błąd nr 1: eksportowanie pól struktury „żeby było wygodnie”.
Zwykle prowadzi to do tego, że niezmienniki przestają istnieć, a pakiet zaczyna przyjmować niepoprawne dane „z zewnątrz”. Potem dodajesz sprawdzanie w każdej funkcji i kod zamienia się w tor przeszkód. Dużo spokojniej jest ukryć pola i dać jedną poprawną ścieżkę tworzenia.
Błąd nr 2: zamienianie pakietu w „składowisko” wszystkiego naraz.
Gdy w jednym pakiecie żyją model, parsowanie, drukowanie, praca z plikami i sieć, kontrakt staje się nieczytelny. Kodowi zewnętrznemu trudno zrozumieć, „co ten pakiet w ogóle obiecuje”. Jeden pakiet — jedna jasna odpowiedzialność.
Błąd nr 3: ukrywanie zależności wewnątrz pakietu.
Jeśli pakiet sam czyta env, sam otwiera pliki, sam tworzy klientów — kod staje się magiczny. Trudno go testować i trudno zrozumieć. Jasne zależności przez konstruktor czynią zachowanie przejrzystym.
Błąd nr 4: bezmyślne używanie %w na wszystkich poziomach.
Wrapping przez %w to nie tylko „dodanie kontekstu”, ale decyzja dotycząca API: pozwalasz kodowi zewnętrznemu rozpoznawać wewnętrzną przyczynę przez errors.Is/errors.As. To przydatne, gdy przyczyna rzeczywiście powinna być widoczna, ale szkodliwe, gdy ujawniasz szczegóły implementacji (na przykład konkretny sterownik bazy danych).
Błąd nr 5: wystawianie na zewnątrz „losowych ciągów” zamiast stabilnych przyczyn.
Jeśli kod zewnętrzny ma rozróżniać „nie znaleziono”, „walidacja”, „brak dostępu”, to łańcuch znaków jest złym kontraktem. Dzisiaj tekst jest jeden, jutro inny, a logika kodu zewnętrznego się psuje. Lepiej dać stabilne przyczyny (sentinel/typed errors), a już na granicy aplikacji tłumaczyć je na komunikaty dla użytkownika.
GO TO FULL VERSION