Аббревиатура SOLID — пять принципов проектирования, которые Роберт Мартин сформулировал для классов. В Go классов нет, но принципы переносятся на типы, интерфейсы и пакеты без потерь. Их часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории. На деле принципы — это ответы на конкретные боли: «почему так тяжело менять этот код?», «почему один класс правят сразу несколько команд?», «почему замена библиотеки ломает половину системы?».
Разберём каждый принцип с нуля: в чём проблема, как её видно в коде и как исправить.
SRP — одна ответственностьспросят на собеседовании
У типа должна быть одна причина для изменения.
Представьте сервис, который со временем вырос в «свалку»:
type OrderService struct{}
func (s *OrderService) CreateOrder(req CreateOrderRequest) (OrderDTO, error) { /* 120 строк */ }
func (s *OrderService) CancelOrder(id OrderID) (OrderDTO, error) { /* 80 строк */ }
func (s *OrderService) SearchOrders(filter OrderFilter) ([]OrderDTO, error) { /* 60 строк */ }
func (s *OrderService) ExportOrders(filter OrderFilter) ([]byte, error) { /* 90 строк */ }
func (s *OrderService) RecalculateStatistics() error { /* 70 строк */ }
Когда меняется правило создания заказа — правим этот тип. Когда меняется формат экспорта — снова этот тип. Когда меняется алгоритм статистики — опять он. У типа несколько несвязанных причин для изменения, и любая правка рискует задеть остальное.
SRP говорит: у типа должна быть одна причина для изменения. На практике это значит — один тип решает одну задачу.
type CreateOrderHandler struct {
orders OrderRepository
pricing PricingPolicy
}
func NewCreateOrderHandler(orders OrderRepository, pricing PricingPolicy) *CreateOrderHandler {
return &CreateOrderHandler{orders: orders, pricing: pricing}
}
func (h *CreateOrderHandler) Handle(cmd CreateOrderCommand) (OrderDTO, error) {
order, err := NewOrder(cmd.CustomerID, cmd.Lines, h.pricing)
if err != nil {
return OrderDTO{}, err
}
if err := h.orders.Save(order); err != nil {
return OrderDTO{}, err
}
return OrderDTOFrom(order), nil
}
Этот тип меняется только когда меняется правило создания заказа. Отправка письма покупателю — другая ответственность, она живёт в отдельном обработчике события.
Хороший сигнал нарушения SRP — когда в устном описании типа звучит слово «и»: «он создаёт заказ и отправляет письмо и пересчитывает статистику».
OCP — открыт для расширения, закрыт для измененияспросят на собеседовании
Поведение системы расширяется добавлением кода, а не правкой существующего.
Типичная картина нарушения — switch или цепочка if, в которую нужно дописывать ветку каждый раз, когда появляется новый вариант:
func discount(order Order) decimal.Decimal {
switch order.Customer.Type {
case CustomerVIP:
return order.Total().Mul(decimal.NewFromFloat(0.10))
case CustomerEmployee:
return order.Total().Mul(decimal.NewFromFloat(0.20))
default:
return decimal.Zero
}
}
Новый тип клиента — правка этого метода. И всех остальных switch по тому же признаку в других местах кода.
Решение — объявить точку расширения (интерфейс), и добавлять новое поведение новым типом, не трогая существующий:
type DiscountPolicy interface {
Supports(customer Customer) bool
Discount(order Order) decimal.Decimal
}
type DiscountCalculator struct {
policies []DiscountPolicy
}
func (c *DiscountCalculator) Discount(order Order) decimal.Decimal {
for _, policy := range c.policies {
if policy.Supports(order.Customer) {
return policy.Discount(order)
}
}
return decimal.Zero
}
Новый тип скидки — новый тип, реализующий DiscountPolicy. Существующий код не трогается.
Оговорка: OCP не значит «везде плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — switch честнее. Принцип применяется там, где расширение реально ожидается.
LSP — принцип подстановки Лисковспросят на собеседовании
Реализацию можно заменить на любую другую реализацию того же контракта — и вызывающий код не заметит разницы.
Нарушение обычно выглядит как встраивание ради повторного использования кода, когда обёртка нарушает ожидания исходного контракта:
type CachedProductRepository struct {
*SQLProductRepository
cache sync.Map
}
func (r *CachedProductRepository) FindByID(id ProductID) (*Product, error) {
if cached, ok := r.cache.Load(id); ok {
return cached.(*Product), nil
}
product, err := r.SQLProductRepository.FindByID(id)
if err != nil {
return nil, err
}
r.cache.Store(id, product)
return product, nil
}
func (r *CachedProductRepository) Delete(id ProductID) error {
panic("кеш не поддерживает удаление")
}
Тип называет себя репозиторием, но Delete паникует. Код, который работал с исходным типом, со «специализированным» ломается — это и есть нарушение LSP: обёртка сузила контракт.
Правильная форма — не встраивание с перекрытием методов, а явная композиция. Новый тип реализует тот же интерфейс и честно выполняет весь его контракт:
type CachingProductRepository struct {
delegate ProductRepository
cache Cache
}
func (r *CachingProductRepository) FindByID(id ProductID) (*Product, error) {
if product, ok := r.cache.Get(id); ok {
return product, nil
}
product, err := r.delegate.FindByID(id)
if err != nil {
return nil, err
}
r.cache.Put(id, product)
return product, nil
}
func (r *CachingProductRepository) Delete(id ProductID) error {
if err := r.delegate.Delete(id); err != nil { // удаление выполняется
return err
}
r.cache.Evict(id) // и кеш инвалидируется
return nil
}
Теперь CachingProductRepository подставляется вместо любого другого ProductRepository без сюрпризов.
Правило-подсказка: если видите panic("не поддерживается") в методе, который тип обязан выполнять по интерфейсу, — почти наверняка нарушен LSP.
ISP — разделение интерфейсовспросят на собеседовании
Клиент не должен зависеть от методов, которые он не использует.
Интерфейсы имеют свойство разрастаться: сначала был Save и FindByID, потом добавили FindForListing, потом FindForExport, потом ArchiveOlderThan:
type OrderStorage interface {
Save(order *Order) error
FindByID(id OrderID) (*Order, error)
FindForListing(filter OrderFilter, page Pagination) ([]OrderListRow, error)
FindForExport(from, to time.Time) ([]OrderExportRow, error)
ArchiveOlderThan(date time.Time) error
}
Обработчик команды использует два метода из пяти, но зависит от всех. Изменение сигнатуры метода экспорта вынуждает пересобирать и его. В тестах — приходится заглушать все пять методов, хотя нужны только два.
Решение — разрезать по потребителям, не по таблице:
// для команд — только то, что нужно
type OrderRepository interface {
Save(order *Order) error
FindByID(id OrderID) (*Order, error)
}
// для чтения и отчётов — отдельно
type OrderViewRepository interface {
FindForListing(filter OrderFilter, page Pagination) ([]OrderListRow, error)
FindForExport(from, to time.Time) ([]OrderExportRow, error)
}
Одна реализация может реализовывать оба интерфейса — это нормально. Важно, что каждый потребитель зависит только от тех методов, которые реально использует.
DIP — инверсия зависимостейспросят на собеседовании
Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
Простыми словами: бизнес-логика не должна напрямую знать про конкретные инструменты (базу данных, почтовый сервер, внешний API). Иначе при смене инструмента придётся трогать бизнес-логику.
Типичное нарушение — доменная модель знает про конкретный транспорт уведомлений:
func (o *Order) Cancel(mail *SMTPMailSender) {
o.status = StatusCancelled
mail.Send(o.customer.Email, "Заказ отменён")
}
Доменная модель зависит от SMTPMailSender — конкретной инфраструктурной детали. Захотите перейти на push-уведомления — придётся менять Order. Захотите протестировать отмену без реального SMTP — сложно.
Инверсия: домен объявляет что ему нужно (интерфейс), а инфраструктура решает как это реализовать:
// интерфейс объявлен рядом с доменом, говорит на языке домена
type NotificationPort interface {
OrderCancelled(order *Order)
}
// реализация — в инфраструктурном слое
type SMTPNotificationAdapter struct {
mailer *SMTPMailer
}
func (a *SMTPNotificationAdapter) OrderCancelled(order *Order) {
a.mailer.Send(buildMessage(order))
}
Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.
Обратите внимание: зависимость идёт от SMTPNotificationAdapter к NotificationPort (который живёт рядом с доменом), а не наоборот. Направление зависимостей перевёрнуто относительно направления вызова — отсюда название «инверсия».
Глубже: как ISP и DIP встроены в сам Goрасширенноеспросят на собеседовании
В Go интерфейсы удовлетворяются неявно, и это меняет обе последние буквы. ISP превращается в правило «интерфейс объявляет потребитель»: обработчику команды нужны Save и FindByID — он и объявляет у себя интерфейс из двух методов, а реализация из соседнего пакета подходит под него сама, без слова implements. Отсюда же присказка «принимай интерфейсы, возвращай структуры» и пословица Роба Пайка: чем больше интерфейс, тем слабее абстракция — io.Reader с одним методом реализуют сотни типов.
DIP в Go — это направление импортов: пакет адаптера импортирует пакет домена, а не наоборот, и компилятор не даст сделать цикл. Что адаптер действительно подходит под порт, проверяют одной строкой на этапе компиляции:
var _ NotificationPort = (*SMTPNotificationAdapter)(nil)
Строка ничего не делает во время выполнения, но сборка упадёт, как только адаптер перестанет удовлетворять интерфейсу — например, после изменения сигнатуры метода в порту.
Коротко
- SRP: у типа одна причина для изменения. Если тип делает «и то, и это» — его пора разделить.
- OCP: новое поведение добавляется новым типом, не правкой существующего. Интерфейс как точка расширения.
- LSP: реализация заменяет оригинал без сюрпризов.
panicв методе, реализующем интерфейс, — красный флаг. - ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
- DIP: бизнес-логика зависит от интерфейсов, а не от конкретных типов. Направление зависимостей — к домену, не от него.
- В Go ISP и DIP встроены в язык: интерфейс объявляет потребитель, реализация подходит неявно, импорты идут к домену, а соответствие адаптера порту проверяет строка
var _ Port = (*Adapter)(nil).
Принципы работают вместе: тип с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми типами (OCP).
Что почитать дальше
- Паттерны GoF — конкретные приёмы, которые реализуют идеи SOLID на практике.
- GRASP на примерах — принципы распределения ответственности между типами.
- Гексагональная архитектура — DIP и ISP, доведённые до структуры пакетов.