Опирается на правила: GO-9.1GO-9.6 и GO-9.X1GO-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.5decimal.Decimal или int64 (копейки)
Хранение и сравнение времени как строк или unix-int внутри сервисаGO-9.6time.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-семантикой.