Неделя 2: чистая архитектура, слои, тесты, моки
На первой неделе main.go мог быть толстым, потому что фокус был на контрактах. Теперь резка по слоям и нормальное тестовое покрытие. Без этого сервис не масштабируется по людям: новый разработчик не понимает где править логику, где доступ к БД, где транспорт.
Цели недели
- Привести каждый сервис к чистой архитектуре с явными слоями.
- Сгенерировать моки для интерфейсов через mockery.
- Покрыть unit-тестами service-слой минимум на 40%.
- Настроить вывод coverage в README через CI и Gist.
- Поднять reusable GitHub Actions workflows: lint, test, coverage.
Структура одного сервиса
order/
├── cmd/
│ └── main.go # точка входа, сборка зависимостей
├── go.mod
└── internal/
├── api/ # transport
│ └── order/v1/
│ ├── api.go # struct реализующая интерфейс из shared/pkg/openapi
│ ├── create.go
│ ├── get.go
│ ├── pay.go
│ └── cancel.go
├── client/ # вызовы наружу
│ ├── converter/
│ │ └── part.go # proto -> domain
│ └── grpc/
│ ├── client.go # интерфейсы клиентов
│ ├── mocks/ # mockery output
│ ├── inventory/v1/ # реализация для inventory.proto
│ └── payment/v1/ # реализация для payment.proto
├── converter/
│ └── order.go # domain <-> dto api
├── model/
│ ├── order.go # domain types
│ ├── part.go
│ └── error.go # доменные ошибки
├── repository/ # хранилище
│ ├── repository.go # интерфейс
│ ├── converter/
│ │ └── order.go # domain <-> repo model
│ ├── mocks/
│ ├── model/ # repo-модель (как лежит в БД)
│ │ └── order.go
│ └── order/ # реализация (in-memory на этой неделе)
│ ├── repository.go
│ ├── create.go
│ ├── get.go
│ └── update.go
└── service/ # бизнес-логика
├── service.go # интерфейс
├── mocks/
└── order/
├── service.go
├── create.go
├── create_test.go
├── get.go
├── get_test.go
├── pay.go
├── pay_test.go
├── cancel.go
├── cancel_test.go
└── suite_test.go # общий setup для table-driven
Главные принципы:
- api не знает про БД, только про domain-типы и интерфейс service,
- service не знает про HTTP/gRPC, только про domain и интерфейсы repository/client,
- repository не знает про domain в чистом виде, у него своя модель (как лежит в БД), и converter переводит,
- client не знает про domain, у него своя модель (как пришло по proto), и converter переводит,
- converter-ы это маленькие чистые функции, легко тестируются.
Зачем разные модели для api/repo/client
Соблазн, одна модель на все слои, чтобы не дублировать поля. Это быстрая дорога к боли:
- модель в API растёт под фронт (added/removed поля по мере фичей),
- модель в БД растёт под индексы и денормализацию,
- модель в client-е растёт под чужой контракт,
- если всё одно, любое изменение в одном месте ломает три других.
Поэтому: три модели плюс converter-ы. Стоимость, +20% кода. Выгода, изменения локализованы.
Mockery, генерация моков по интерфейсам
# .mockery.yaml
filename: 'mock_{{.InterfaceName | snakecase}}.go'
dir: '{{.InterfaceDir}}/mocks'
mockname: 'Mock{{.InterfaceName}}'
outpkg: mocks
all: true
packages:
github.com/me/myproject/order/internal/repository:
config:
include-regex: ".*Repository"
github.com/me/myproject/order/internal/service:
config:
include-regex: ".*Service"
github.com/me/myproject/order/internal/client/grpc:
config:
include-regex: ".*Client"
mockery
Получаем готовые моки рядом с интерфейсами. В тестах:
mockRepo := mocks.NewMockOrderRepository(t)
mockRepo.EXPECT().Create(mock.Anything, mock.Anything).Return(nil)
mockRepo.EXPECT().Get(mock.Anything, "uuid-1").Return(testOrder, nil)
svc := order.NewService(mockRepo, mockInventory, mockPayment)
t.Cleanup в mock автоматически проверяет что все ожидания выполнены.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.