Опирается на правила:
GO-9.1…GO-9.6иGO-9.X1…GO-9.X2из Go Style Guide → раздел 9. Типы, структуры и интерфейсы.
Важно знать
- Малые интерфейсы — 1–3 метода; большой интерфейс — признак God Object, разбить.
- Принимай интерфейс, возвращай конкретный тип — конструкторы возвращают структуру, не интерфейс (кроме портов/репозиториев, где нужна мокаемость).
- Embedding — для горизонтального переиспользования метода, не для имитации наследования; явная делегация предпочтительнее при неочевидном embedding.
- Иммутабельный VO — приватные поля, конструктор с валидацией, возвращает
(T, error), мутирующие операции отдают новый экземпляр.- Деньги —
int64(минорные единицы) илиgithub.com/shopspring/decimal; никогдаfloat64.- Время —
time.Time; принимать из внешнего мира в UTC, хранить в UTC; часовые пояса применять только на выходе.any/interface{}— только для истинно полиморфных коллекций; не как обходной приём.- Один интерфейс — одно имя по роли:
Reader,Stringer,OrderRepository,PaymentGateway.
Система типов в Go не имеет наследования классов. Вместо иерархии — композиция через embedding и минималистичные интерфейсы. Это не ограничение, а проектное решение языка: маленький интерфейс легче удовлетворить, проще тестировать, сложнее случайно нарушить. Раздел раскрывает правила GO-9.*, которые проецируют этот подход на доменные структуры.
GO-9.1 — малые интерфейсы
Интерфейс с одним методом именуется по методу с суффиксом -er:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Stringer interface {
String() string
}
Интерфейс с несколькими методами описывает роль в домене:
type OrderRepository interface {
Save(ctx context.Context, o *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
}
type PaymentGateway interface {
Reserve(ctx context.Context, cmd ReserveCommand) (ReservationID, error)
Confirm(ctx context.Context, id ReservationID) error
}
Оба интерфейса — 2 метода. Большой интерфейс — признак того, что одна роль тянет несколько обязанностей:
// Антипаттерн — God Interface, видно несколько ролей
type OrderService interface {
Create(ctx context.Context, cmd CreateOrderCommand) (Order, error)
Cancel(ctx context.Context, cmd CancelOrderCommand) error
Find(ctx context.Context, filter OrderFilter) ([]Order, error)
UpdateStatus(ctx context.Context, id OrderID, status Status) error
ExportToCsv(ctx context.Context, filter OrderFilter) ([]byte, error)
NotifyCustomer(ctx context.Context, id OrderID) error
}
Разбить по обязанностям: OrderWriter, OrderReader, OrderExporter, CustomerNotifier — каждый с 1–2 методами.
GO-9.2 — принимай интерфейс, возвращай конкретный тип
Функции принимают минимальный интерфейс, конструкторы возвращают конкретную структуру.
// core/order/service.go
type CreateOrderHandler struct {
repo OrderRepository
payment PaymentGateway
notify CustomerNotifier
}
func NewCreateOrderHandler(
repo OrderRepository,
payment PaymentGateway,
notify CustomerNotifier,
) *CreateOrderHandler {
return &CreateOrderHandler{
repo: repo,
payment: payment,
notify: notify,
}
}
NewCreateOrderHandler возвращает *CreateOrderHandler, а не SomeHandler-интерфейс. Caller знает конкретный тип и получает доступ ко всем методам без лишней косвенности.
Исключение — порты и репозитории, где нужна мокаемость: там конструктор адаптера возвращает интерфейс, потому что выше по стеку хранится именно интерфейс:
// adapter/out/postgres_order_repo.go
func NewPostgresOrderRepository(pool *pgxpool.Pool) OrderRepository {
return &postgresOrderRepository{pool: pool}
}
GO-9.3 — embedding: горизонтальное переиспользование
Embedding переиспользует методы без дублирования, но не имитирует наследование.
// Корректно — embedding для переиспользования логики пагинации
type PagedQuery struct {
Limit int
Offset int
}
func (q PagedQuery) Validate() error {
if q.Limit <= 0 || q.Limit > 1000 {
return fmt.Errorf("limit must be in [1, 1000], got %d", q.Limit)
}
return nil
}
type FindOrdersQuery struct {
PagedQuery
CustomerID string
Status OrderStatus
}
FindOrdersQuery.Validate() доступен через embedding. Если логика валидации несовместима между типами — явная делегация предпочтительнее:
type FindProductsQuery struct {
paging PagedQuery
filter ProductFilter
}
func (q FindProductsQuery) Validate() error {
if err := q.paging.Validate(); err != nil {
return fmt.Errorf("paging: %w", err)
}
return q.filter.Validate()
}
Явная делегация через поле paging PagedQuery вместо embedding делает зависимость видимой в сигнатуре.
GO-9.4 — иммутабельные value objects
Value object — структура с приватными полями, конструктор проверяет инварианты и возвращает (T, error). Мутирующие операции отдают новый экземпляр:
// core/customer/vo/email.go
package vo
type Email struct {
value string
}
func NewEmail(raw string) (Email, error) {
if raw == "" {
return Email{}, fmt.Errorf("email: required")
}
if !strings.Contains(raw, "@") {
return Email{}, fmt.Errorf("email: invalid format %q", raw)
}
return Email{value: strings.ToLower(raw)}, nil
}
func (e Email) String() string { return e.value }
// core/order/vo/quantity.go
package vo
type Quantity struct {
value int
}
func NewQuantity(n int) (Quantity, error) {
if n <= 0 {
return Quantity{}, fmt.Errorf("quantity must be positive, got %d", n)
}
return Quantity{value: n}, nil
}
func (q Quantity) Value() int { return q.value }
func (q Quantity) Add(other Quantity) Quantity {
return Quantity{value: q.value + other.value}
}
Add возвращает новый Quantity — получатель q не меняется. Компилятор не запрещает присвоить поле напрямую (e.value = "x") внутри пакета, поэтому экспортируемые методы не предоставляют setter.
GO-9.5 — деньги не float64
Деньги — либо int64 в минорных единицах (копейки, центы), либо github.com/shopspring/decimal.
// core/order/vo/money.go
package vo
import "github.com/shopspring/decimal"
type Money struct {
amount decimal.Decimal
currency string
}
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
if amount.IsNegative() {
return Money{}, fmt.Errorf("money: amount must be non-negative, got %s", amount)
}
if currency == "" {
return Money{}, fmt.Errorf("money: currency required")
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, fmt.Errorf("money: currency mismatch %s vs %s", m.currency, other.currency)
}
return Money{amount: m.amount.Add(other.amount), currency: m.currency}, nil
}
func (m Money) Amount() decimal.Decimal { return m.amount }
func (m Money) Currency() string { return m.currency }
Альтернатива для систем с фиксированным масштабом (например, только рубли):
type Money struct {
kopecks int64
currency string
}
float64 даёт накапливающуюся ошибку округления — ни float32, ни float64 не представляют 0.1 точно в двоичной системе. При суммировании тысяч позиций в корзине это материально.
GO-9.6 — время в UTC
time.Time — стандартный тип; не строки, не int64-unix-timestamp внутри сервиса.
// Корректно — принимаем из HTTP в UTC, храним в UTC
type CreateOrderCommand struct {
CustomerID string
DeliveryUntil time.Time
}
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
deadline := cmd.DeliveryUntil.UTC()
if deadline.Before(time.Now().UTC()) {
return nil, &DeliveryDeadlineError{Deadline: deadline}
}
ord, err := order.New(cmd.CustomerID, deadline)
if err != nil {
return nil, fmt.Errorf("create order: %w", err)
}
return ord, nil
}
При отдаче клиенту — time.In(tz) или RFC3339 с timezone offset. Внутри сервиса — всегда UTC.
Для time.Time в структурах, где нужно сравнение: используй Equal, не ==, — метод учитывает монотонное время и location.
func (o *Order) isOverdue(now time.Time) bool {
return now.UTC().After(o.deliveryUntil.UTC())
}
GO-9.X1 — пустой интерфейс снижает безопасность
any (синоним interface{}) теряет информацию о типе и нарушает статическую проверку:
// Антипаттерн — any как «не хочу думать о типе»
type EventBus struct {
handlers map[string]func(event any)
}
func (b *EventBus) Publish(eventType string, payload any) {
if h, ok := b.handlers[eventType]; ok {
h(payload)
}
}
Caller не знает, что класть в payload. При несоответствии типа — паника в runtime.
Корректный подход — типизированный интерфейс или generic:
type DomainEvent interface {
EventType() string
OccurredAt() time.Time
}
type EventBus struct {
handlers map[string][]func(DomainEvent)
}
func (b *EventBus) Publish(event DomainEvent) {
for _, h := range b.handlers[event.EventType()] {
h(event)
}
}
any оправдан в функциях общего назначения типа json.Marshal или slog.Any — там полиморфизм по типу является частью контракта.
GO-9.X2 — type alias без нового поведения
type Alias = Existing — это точная синонимия. Введённый тип без нового поведения вносит путаницу:
// Антипаттерн — alias без смысла
type CustomerName = string // ← точная синонимия, не отличается от string
func FindByName(name CustomerName) (*Customer, error) { ... }
CustomerName и string взаимозаменяемы без конвертации — тип не даёт ограничений.
Правильный подход — именованный тип (не alias) для нового поведения:
type CustomerName string // ← именованный тип, отличается от string
func NewCustomerName(raw string) (CustomerName, error) {
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("customer name: required")
}
if len(raw) > 200 {
return "", fmt.Errorf("customer name: too long, max 200 chars")
}
return CustomerName(strings.TrimSpace(raw)), nil
}
Теперь CustomerName и string несовместимы без явного приведения, а конструктор применяет инварианты.
Type alias (=) оправдан для совместимости при миграции пакетов — одна сторона публикует под новым именем, другая продолжает использовать старое.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Интерфейс с 8+ методами, покрывает несколько обязанностей | GO-9.1 | разбить по ролям, 1–3 метода на интерфейс |
| Конструктор возвращает интерфейс без необходимости | GO-9.2 | вернуть конкретную структуру *T |
| Embedding для имитации наследования (переопределение поведения) | GO-9.3 | явная делегация через поле |
| VO с публичными полями без конструктора | GO-9.4 | приватные поля + NewX(...) с валидацией |
price float64 для хранения денег | GO-9.5 | decimal.Decimal или int64 (копейки) |
| Хранение и сравнение времени как строк или unix-int внутри сервиса | GO-9.6 | time.Time в UTC |
any как тип аргумента / поля для избежания явной типизации | GO-9.X1 | типизированный интерфейс или generic |
type OrderID = uuid.UUID без нового поведения | GO-9.X2 | именованный тип type OrderID struct{ value uuid.UUID } |
Куда дальше
- Именование — как назвать интерфейс, конструктор, булево поле.
- Управляющие структуры — guard clause, switch exhaustiveness, длина функции.
- golangci-lint —
gocritic,reviveиstaticcheckв контексте типов. - Value Object в DDD на Go — полный разбор VO с
Money,ID-типами и equality-семантикой.