Примеры кода — справочник
Сервис заявок целиком в файлах: домен и переходы статусов, сервис, адаптер Postgres с optimistic locking, HTTP с DTO и ошибками, сборка в main, тесты и миграция.
Все файлы собраны в один модуль и проверены go build, go vet и go test. Структура и правила — в листе «Архитектура»; здесь те же правила в коде.
1. Дерево проекта
orders/
go.mod
cmd/
api/
main.go
internal/
order/
order.go
order_test.go
service.go
service_test.go
postgres/
repo.go
httpapi/
handler.go
handler_test.go
migrations/
001_init.sql
Зависимости идут в одну сторону: cmd/api знает всех, httpapi знает order, order не знает ни HTTP, ни SQL. order/postgres знает order и database/sql.
| Файл | Ответственность |
|---|---|
order/order.go | типы, статусы, ошибки, переходы |
order/service.go | сценарии и интерфейс хранилища |
order/postgres/repo.go | SQL и перевод ошибок базы |
httpapi/handler.go | HTTP, JSON, статусы |
cmd/api/main.go | конфигурация и сборка графа |
2. Домен: заявка и переходы
Статус — не строка в обработчике, а тип с набором значений. Переход — метод домена: он проверяет предусловие и меняет состояние. Ошибки домена объявлены здесь же, потому что это часть контракта.
// internal/order/order.go
package order
import (
"errors"
"fmt"
)
type Status string
const (
Draft Status = "draft"
Submitted Status = "submitted"
Approved Status = "approved"
)
type Order struct {
ID int64
Version int64
Title string
Amount int64
Status Status
}
var (
ErrNotFound = errors.New("order not found")
ErrConflict = errors.New("order version conflict")
ErrInvalidTransition = errors.New("invalid status transition")
ErrEmptyTitle = errors.New("empty title")
)
func (o *Order) Submit() error {
if o.Status != Draft {
return fmt.Errorf("submit from %q: %w", o.Status, ErrInvalidTransition)
}
o.Status = Submitted
return nil
}
func (o *Order) Approve() error {
if o.Status != Submitted {
return fmt.Errorf("approve from %q: %w", o.Status, ErrInvalidTransition)
}
o.Status = Approved
return nil
}
Version — не бизнес-поле, а средство конкурентного контроля. Домен о нём не рассуждает, но хранилище использует его при записи.
3. Сервис: сценарии
Сервис знает, что нужно сделать, но не знает, как это хранится. Интерфейс Repository объявлен здесь — на стороне потребителя, — и описывает только два нужных метода.
// internal/order/service.go
package order
import "context"
type Repository interface {
Get(ctx context.Context, id int64) (Order, error)
Save(ctx context.Context, o Order) error
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Get(ctx context.Context, id int64) (Order, error) {
return s.repo.Get(ctx, id)
}
func (s *Service) Submit(ctx context.Context, id int64) error {
o, err := s.repo.Get(ctx, id)
if err != nil {
return err
}
if err := o.Submit(); err != nil {
return err
}
return s.repo.Save(ctx, o)
}
func (s *Service) Approve(ctx context.Context, id int64) error {
o, err := s.repo.Get(ctx, id)
if err != nil {
return err
}
if err := o.Approve(); err != nil {
return err
}
return s.repo.Save(ctx, o)
}
Сценарий «прочитать — изменить — записать» не оборачивается в транзакцию на уровне сервиса: конкурентную запись ловит версия в Save. Если бы правило требовало нескольких записей, транзакция жила бы в адаптере, а не в домене.
4. Postgres: адаптер и optimistic locking
Адаптер переводит строку базы в домен, sql.ErrNoRows — в ErrNotFound, а нулевое число обновлённых строк — в ErrConflict. Условие version = $5 не даёт двум читателям записать поверх друг друга.
// internal/order/postgres/repo.go
package postgres
import (
"context"
"database/sql"
"errors"
"example.com/orders/internal/order"
)
type Repo struct {
db *sql.DB
}
func New(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) Get(ctx context.Context, id int64) (order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx,
`select id, version, title, amount, status from orders where id = $1`, id).
Scan(&o.ID, &o.Version, &o.Title, &o.Amount, &o.Status)
if errors.Is(err, sql.ErrNoRows) {
return order.Order{}, order.ErrNotFound
}
return o, err
}
func (r *Repo) Save(ctx context.Context, o order.Order) error {
tag, err := r.db.ExecContext(ctx,
`update orders
set title = $1, amount = $2, status = $3, version = version + 1
where id = $4 and version = $5`,
o.Title, o.Amount, o.Status, o.ID, o.Version)
if err != nil {
return err
}
n, err := tag.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return order.ErrConflict
}
return nil
}
Ошибки драйвера возвращаются как есть: сервис и транспорт не должны знать про pgconn.PgError. Если нужен SQLSTATE, его разбирают здесь и переводят в ошибку домена.
5. HTTP: DTO и ошибки
Транспорт не отдаёт доменный тип наружу: DTO фиксирует JSON-контракт. Ошибки домена переводятся в статусы в одном месте, fail; внутренние ошибки логируются и наружу уходят как internal.
// internal/httpapi/handler.go
package httpapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"example.com/orders/internal/order"
)
type Handler struct {
orders *order.Service
}
func NewHandler(orders *order.Service) *Handler {
return &Handler{orders: orders}
}
type orderDTO struct {
ID int64 `json:"id"`
Title string `json:"title"`
Amount int64 `json:"amount"`
Status string `json:"status"`
Version int64 `json:"version"`
}
func toDTO(o order.Order) orderDTO {
return orderDTO{
ID: o.ID,
Title: o.Title,
Amount: o.Amount,
Status: string(o.Status),
Version: o.Version,
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"code": code, "message": msg})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
return
}
o, err := h.orders.Get(r.Context(), id)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, toDTO(o))
}
func (h *Handler) Submit(w http.ResponseWriter, r *http.Request) {
h.transition(w, r, h.orders.Submit)
}
func (h *Handler) Approve(w http.ResponseWriter, r *http.Request) {
h.transition(w, r, h.orders.Approve)
}
func (h *Handler) transition(
w http.ResponseWriter,
r *http.Request,
action func(ctx context.Context, id int64) error,
) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
return
}
if err := action(r.Context(), id); err != nil {
h.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) fail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, order.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "order not found")
case errors.Is(err, order.ErrConflict):
writeError(w, http.StatusConflict, "conflict", "order was changed")
case errors.Is(err, order.ErrInvalidTransition):
writeError(w, http.StatusUnprocessableEntity, "invalid_transition", "transition not allowed")
default:
slog.Error("order request", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "internal error")
}
}
6. Сборка в main
main — единственное место, где виден весь граф: база, репозиторий, сервис, обработчик, маршруты, сервер. Здесь же конфигурация, сигнал и graceful shutdown.
// cmd/api/main.go
package main
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"example.com/orders/internal/httpapi"
"example.com/orders/internal/order"
"example.com/orders/internal/order/postgres"
)
type Config struct {
Addr string
DatabaseURL string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Addr: envOr("ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
ShutdownTimeout: 10 * time.Second,
}
if cfg.DatabaseURL == "" {
return Config{}, errors.New("DATABASE_URL is required")
}
return cfg, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
cfg, err := Load()
if err != nil {
return err
}
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
return serve(ctx, cfg, db)
}
func serve(ctx context.Context, cfg Config, db *sql.DB) error {
repo := postgres.New(db)
svc := order.NewService(repo)
api := httpapi.NewHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", api.Get)
mux.HandleFunc("POST /orders/{id}/submit", api.Submit)
mux.HandleFunc("POST /orders/{id}/approve", api.Approve)
srv := &http.Server{
Addr: cfg.Addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
sql.Open не проверяет соединение. Если нужен ранний отказ, добавьте db.PingContext(ctx) в run — до запуска сервера.
7. Тесты
Три уровня: домен без зависимостей, сервис с фейковым хранилищем, HTTP через ServeMux и httptest. SQL-адаптер проверяется отдельно на настоящем Postgres.
Домен
// internal/order/order_test.go
package order
import (
"errors"
"testing"
)
func TestTransitions(t *testing.T) {
tests := []struct {
name string
start Status
action func(*Order) error
want Status
wantErr bool
}{
{"submit draft", Draft, (*Order).Submit, Submitted, false},
{"submit submitted", Submitted, (*Order).Submit, Submitted, true},
{"approve submitted", Submitted, (*Order).Approve, Approved, false},
{"approve draft", Draft, (*Order).Approve, Draft, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := Order{Status: tt.start}
err := tt.action(&o)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("err = %v, want ErrInvalidTransition", err)
}
if o.Status != tt.want {
t.Fatalf("status = %q, want %q", o.Status, tt.want)
}
})
}
}
Сервис с фейком
// internal/order/service_test.go
package order
import (
"context"
"testing"
)
type fakeRepo struct {
o Order
}
func (f *fakeRepo) Get(ctx context.Context, id int64) (Order, error) {
return f.o, nil
}
func (f *fakeRepo) Save(ctx context.Context, o Order) error {
f.o = o
return nil
}
func TestServiceSubmit(t *testing.T) {
repo := &fakeRepo{o: Order{ID: 1, Status: Draft}}
svc := NewService(repo)
if err := svc.Submit(context.Background(), 1); err != nil {
t.Fatal(err)
}
if repo.o.Status != Submitted {
t.Fatalf("status = %q, want %q", repo.o.Status, Submitted)
}
}
HTTP
// internal/httpapi/handler_test.go
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"example.com/orders/internal/order"
)
type stubRepo struct {
o order.Order
}
func (s *stubRepo) Get(ctx context.Context, id int64) (order.Order, error) {
return s.o, nil
}
func (s *stubRepo) Save(ctx context.Context, o order.Order) error {
s.o = o
return nil
}
func TestSubmit(t *testing.T) {
repo := &stubRepo{o: order.Order{ID: 1, Status: order.Draft}}
h := NewHandler(order.NewService(repo))
mux := http.NewServeMux()
mux.HandleFunc("POST /orders/{id}/submit", h.Submit)
req := httptest.NewRequest(http.MethodPost, "/orders/1/submit", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body)
}
if repo.o.Status != order.Submitted {
t.Fatalf("status = %q", repo.o.Status)
}
}
Фейк проверяет сценарий, но не SQL. Адаптер тестируют на реальной базе: транзакция на тест с откатом, см. лист «SQL и Postgres».
8. Миграция
Схема повторяет домен, но не диктует его: status ограничен check, version хранит конкурентный контроль, amount неотрицателен.
-- migrations/001_init.sql
create table orders (
id bigint generated always as identity primary key,
version bigint not null default 1,
title text not null,
amount bigint not null check (amount >= 0),
status text not null check (status in ('draft', 'submitted', 'approved')),
created_at timestamptz not null default now()
);
create index orders_status_idx on orders (status);
Ограничения в базе дублируют правила домена намеренно: домен защищает от неверного сценария, база — от неверной записи любым путём.
9. Что менять при росте
| Появляется | Что менять |
|---|---|
| второй транспорт (gRPC) | новый пакет рядом с httpapi; домен и сервис не трогаются |
| второе хранилище | новый адаптер, реализующий order.Repository |
| события при смене статуса | интерфейс публикации в сервисе или outbox в адаптере |
| аутентификация | middleware в транспорте; домен получает уже проверенного субъекта |
| несколько шагов в одной транзакции | транзакция в адаптере, сервис принимает Repository с нужным контрактом |
| чтение с фильтрами | отдельный read-метод или read-модель, не расширение домена |
Рост не повод сразу вводить фреймворк DI, CQRS и микросервисы. Сначала появляется второй потребитель или второе хранилище — тогда и выделяется шов.
10. Первоисточники
- Organizing a Go module —
cmd,internal, серверный проект - Go Code Review Comments — интерфейсы, ошибки, синхронные функции
- pkg.go.dev/net/http —
ServeMux,PathValue,Server - pkg.go.dev/net/http/httptest — тесты транспорта
- pkg.go.dev/database/sql —
RowsAffected,ErrNoRows - PostgreSQL: Constraints —
checkиnot null