Найти в Дзене
Skill Up In IT

Go SOLID

SOLID — это акроним, объединяющий пять фундаментальных принципов
объектно-ориентированного программирования и проектирования,
сформулированных Робертом Мартином (известным также как дядя Боб). Эти
принципы направлены на создание таких программных систем, которые легко
поддерживать, расширять и адаптировать к изменениям требований. Несмотря
на то, что SOLID родился в контексте классических ОО-языков (Java,
C++), его идеи универсальны и успешно применяются в самых разных языках,
включая Go. Go (Golang) — язык с уникальным
подходом к организации кода. В нём нет классов и наследования в
привычном виде, но есть структуры, интерфейсы и композиция. Это
заставляет по-новому взглянуть на реализацию SOLID-принципов. В этой
статье мы подробно разберём каждый из пяти принципов, покажем, как они
выражаются в идиоматичном Go, и приведём примеры правильного и
неправильного кода. Цель — помочь разработчикам создавать системы,
которые будут радовать своей простотой и гибкостью даже спуст
Оглавление

SOLID — это акроним, объединяющий пять фундаментальных принципов
объектно-ориентированного программирования и проектирования,
сформулированных Робертом Мартином (известным также как дядя Боб). Эти
принципы направлены на создание таких программных систем, которые легко
поддерживать, расширять и адаптировать к изменениям требований. Несмотря
на то, что SOLID родился в контексте классических ОО-языков (Java,
C++), его идеи универсальны и успешно применяются в самых разных языках,
включая Go.

Принципы SOLID в Go: руководство по созданию гибкого и поддерживаемого кода

Go (Golang) — язык с уникальным
подходом к организации кода. В нём нет классов и наследования в
привычном виде, но есть структуры, интерфейсы и композиция. Это
заставляет по-новому взглянуть на реализацию SOLID-принципов. В этой
статье мы подробно разберём каждый из пяти принципов, покажем, как они
выражаются в идиоматичном Go, и приведём примеры правильного и
неправильного кода. Цель — помочь разработчикам создавать системы,
которые будут радовать своей простотой и гибкостью даже спустя годы
эксплуатации.

Что такое SOLID?

Акроним расшифровывается так:

  • Single Responsibility Principle (Принцип единственной ответственности)
  • Open/Closed Principle (Принцип открытости/закрытости)
  • Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
  • Interface Segregation Principle (Принцип разделения интерфейса)
  • Dependency Inversion Principle (Принцип инверсии зависимостей)

Каждый из этих принципов описывает конкретные правила построения модулей, классов (или, в нашем случае, типов) и зависимостей между ними.
Соблюдение SOLID приводит к тому, что система становится слабо
связанной, легко тестируемой и расширяемой.

Особенности Go, важные для SOLID

Прежде чем перейти к деталям, напомним ключевые элементы Go, которые будут активно использоваться:

  • Структуры (struct) — основной способ группировки данных.
  • Методы — функции с получателем, привязанные к типам.
  • Интерфейсы (interface) — наборы методов, реализуемые типами неявно (duck typing).
  • Встраивание (embedding) — механизм композиции, позволяющий включать один тип в другой и автоматически делегировать методы.
  • Пакеты (package) — единица инкапсуляции и группировки кода.

Эти элементы создают благоприятную почву для применения SOLID. Далее мы увидим, как.

1. Принцип единственной ответственности (Single Responsibility Principle, SRP)

«У класса должна быть только одна причина для изменений».

Или, другими словами, модуль (тип, функция, структура) должен отвечать за
одну чётко определённую часть функциональности системы. Если у типа
несколько ответственностей, изменения в одной из них могут затронуть
другие, что делает код хрупким и сложным для понимания.

Нарушение SRP в Go

Рассмотрим типичный пример: структура, представляющая пользователя, но помимо
хранения данных она ещё занимается сохранением в базу и отправкой email.

package main

import (
"fmt"
"net/mail"
)

type User struct {
ID int
Name string
Email string
Password string
}

func (u *User) Save() error {
// логика сохранения пользователя в БД
fmt.Printf("Сохранение пользователя %s в базу данных\n", u.Name)
return nil
}

func (u *User) SendWelcomeEmail() error {
// логика отправки приветственного письма
if !isValidEmail(u.Email) {
return fmt.Errorf("некорректный email")
}
fmt.Printf("Отправка приветственного email на %s\n", u.Email)
return nil
}

func (u *User) Validate() error {
// проверка данных пользователя
if u.Name == "" {
return fmt.Errorf("имя не может быть пустым")
}
if !isValidEmail(u.Email) {
return fmt.Errorf("email невалидный")
}
return nil
}

func isValidEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}

func main() {
u := &User{ID: 1, Name: "Alice", Email: "alice@example.com", Password: "secret"}
if err := u.Validate(); err != nil {
panic(err)
}
u.Save()
u.SendWelcomeEmail()
}

Здесь структура User отвечает сразу за три вещи: хранение данных пользователя, валидацию, сохранение в БД и отправку email. Это явное нарушение SRP. Причины для изменений:

  • изменение структуры таблицы в БД потребует правки метода Save;
  • изменение логики отправки писем — правки SendWelcomeEmail;
  • добавление новых правил валидации — правки Validate.

Всё это происходит в одном типе, что увеличивает риск сломать что-то несвязанное и затрудняет тестирование.

Соблюдение SRP

Правильный подход — разделить ответственности между разными типами или даже пакетами. Например:

  • User — только хранение данных (возможно, с простыми геттерами/сеттерами).
  • Отдельный сервис или репозиторий для работы с БД.
  • Отдельный сервис для отправки email.
  • Отдельный валидатор.

Вот как это может выглядеть:

package main

import (
"fmt"
"net/mail"
)

// User — только данные
type User struct {
ID int
Name string
Email string
Password string
}

// UserValidator — ответственность за валидацию
type UserValidator struct{}

func (v UserValidator) Validate(u *User) error {
if u.Name == "" {
return fmt.Errorf("имя не может быть пустым")
}
if !isValidEmail(u.Email) {
return fmt.Errorf("email невалидный")
}
return nil
}

// UserRepository — ответственность за хранение (например, в БД)
type UserRepository struct {
// здесь может быть подключение к БД
}

func (r *UserRepository) Save(u *User) error {
fmt.Printf("Сохранение пользователя %s в базу данных\n", u.Name)
return nil
}

// EmailService — ответственность за отправку email
type EmailService struct{}

func (e *EmailService) SendWelcomeEmail(u *User) error {
fmt.Printf("Отправка приветственного email на %s\n", u.Email)
return nil
}

func isValidEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}

func main() {
u := &User{ID: 1, Name: "Alice", Email: "alice@example.com", Password: "secret"}

validator := UserValidator{}
if err := validator.Validate(u); err != nil {
panic(err)
}

repo := &UserRepository{}
repo.Save(u)

emailSvc := &EmailService{}
emailSvc.SendWelcomeEmail(u)
}

Теперь каждый тип имеет одну чёткую ответственность. Тестировать их можно
независимо, изменения в логике сохранения не затронут валидацию или
отправку писем. Это соответствует SRP.

Дополнительные соображения

В Go часто применяют подход, при котором типы с данными (модели)
выносятся в отдельный пакет, а сервисы и репозитории — в свои. Также
полезно использовать интерфейсы для абстрагирования зависимостей (см.
DIP). Но главное — следить, чтобы структура не брала на себя слишком
много.

2. Принцип открытости/закрытости (Open/Closed Principle, OCP)

«Программные сущности должны быть открыты для расширения, но закрыты для изменения».

Это означает, что поведение модуля можно расширять без модификации его
исходного кода. В классическом ООП для этого используют наследование и
полиморфизм. В Go основным инструментом являются интерфейсы и
композиция.

Пример нарушения OCP

Представим, что у нас есть функция, которая рассчитывает площадь фигуры. Изначально она поддерживает только прямоугольники. Если мы добавим круг, придётся менять код функции.

type Rectangle struct {
Width float64
Height float64
}

func Area(rect Rectangle) float64 {
return rect.Width * rect.Height
}

// Позже добавляем круг — но Area не принимает круг!
type Circle struct {
Radius float64
}

// Приходится либо перегружать функцию (невозможно в Go), либо писать новую,
// либо менять Area, добавляя проверки типа — что нарушает OCP.

Плохое решение — использовать переключатель по типу:

func Area(shape interface{}) float64 {
switch v := shape.(type) {
case Rectangle:
return v.Width * v.Height
case Circle:
return math.Pi * v.Radius * v.Radius
default:
panic("unknown shape")
}
}

Каждый раз при добавлении новой фигуры мы вынуждены изменять эту функцию, что нарушает OCP.

Соблюдение OCP через интерфейсы

Исправим ситуацию, введя интерфейс Shape с методом Area(). Тогда каждая фигура реализует этот метод, а функция Area (или клиентский код) работает с интерфейсом.

package main

import (
"fmt"
"math"
)

type Shape interface {
Area() float64
}

type Rectangle struct {
Width float64
Height float64
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

type Circle struct {
Radius float64
}

func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}

// Функция, работающая с любым Shape
func PrintArea(s Shape) {
fmt.Printf("Площадь: %.2f\n", s.Area())
}

func main() {
shapes := []Shape{
Rectangle{Width: 3, Height: 4},
Circle{Radius: 5},
}
for _, s := range shapes {
PrintArea(s)
}
}

Теперь код, использующий Shape, закрыт для изменений: мы не трогаем PrintArea при добавлении новых фигур. А сами фигуры открыты для расширения — можно добавлять сколько угодно новых типов, реализующих Shape. Это классическая иллюстрация OCP.

Композиция как способ расширения

Другой пример: мы хотим добавить логирование к какому-либо сервису, не меняя его код. Используем декоратор через композицию.

type Notifier interface {
Send(message string) error
}

type EmailNotifier struct{}

func (e EmailNotifier) Send(message string) error {
fmt.Println("Отправка email:", message)
return nil
}

// LogNotifier — декоратор, добавляющий логирование
type LogNotifier struct {
Notifier Notifier
}

func (l LogNotifier) Send(message string) error {
fmt.Println("Лог: отправка сообщения")
return l.Notifier.Send(message)
}

func main() {
base := EmailNotifier{}
logged := LogNotifier{Notifier: base}
logged.Send("Hello")
}

Здесь LogNotifier расширяет поведение, не изменяя EmailNotifier и не трогая интерфейс Notifier. Система остаётся открытой для новых декораторов (SMSNotifier, SlackNotifier и т.д.).

Важные моменты в Go

  • Интерфейсы в Go не требуют явного указания реализации, что упрощает создание новых типов, удовлетворяющих интерфейсу.
  • Встраивание (embedding) позволяет строить сложные типы из простых, также не нарушая OCP.
  • Однако
    нужно быть осторожным: если интерфейс слишком большой, его реализация
    может быть неудобной (см. ISP). Лучше проектировать маленькие
    интерфейсы.

Таким образом, OCP в Go достигается за счёт грамотного использования
интерфейсов и композиции. Код, написанный с учётом этого принципа, легко
расширяется без риска сломать существующую функциональность.

3. Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP)

«Функции, которые используют базовый тип, должны иметь возможность использовать подтипы, не зная об этом».

Или более формально: если S является подтипом T, то объекты типа T могут
быть заменены объектами типа S без изменения желательных свойств
программы (корректности, выполняемых задач и т.д.).

В классических языках LSP тесно связан с наследованием: наследник не
должен нарушать контракт предка. В Go нет наследования классов, но есть
встраивание и интерфейсы. Тем не менее, LSP остаётся важным.

Пример нарушения LSP

Рассмотрим пример с геометрическими фигурами, но теперь добавим квадрат как "подтип" прямоугольника. В математике квадрат — это частный случай
прямоугольника. Но в программировании такое представление может нарушить
LSP.

type Rectangle struct {
Width, Height float64
}

func (r *Rectangle) SetWidth(w float64) {
r.Width = w
}

func (r *Rectangle) SetHeight(h float64) {
r.Height = h
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

// Square "наследует" Rectangle через встраивание
type Square struct {
Rectangle
}

func (s *Square) SetWidth(w float64) {
s.Width = w
s.Height = w // сохраняем квадратность
}

func (s *Square) SetHeight(h float64) {
s.Height = h
s.Width = h
}

func main() {
var r *Rectangle = &Square{Rectangle{}} // в Go так нельзя: Square не является Rectangle
// Но если бы было можно, то следующий код сломал бы ожидания:
// r.SetWidth(5)
// r.SetHeight(4)
// fmt.Println(r.Area()) // ожидаем 20, а получим 16 (если квадрат подправил ширину под высоту)
}

В Go такой код не скомпилируется, потому что Square не является Rectangle. Однако если мы попытаемся использовать интерфейс, то может возникнуть похожая проблема:

type Resizable interface {
SetWidth(float64)
SetHeight(float64)
Area() float64
}

func ResizeAndPrint(r Resizable) {
r.SetWidth(5)
r.SetHeight(4)
fmt.Println("Ожидаемая площадь 20, фактическая:", r.Area())
}

Если передать сюда *Square, то из-за его логики поддержания равенства сторон после SetWidth(5) и SetHeight(4) ширина станет 4, и площадь будет 16, а не 20. Это нарушение LSP: клиент ожидает, что установка ширины и высоты независима, а квадрат нарушает это ожидание.

Как соблюдать LSP в Go

  1. Избегайте встраивания, если подтип не является полноценной заменой. Вместо встраивания лучше использовать композицию или отдельный тип без претензий на подтип.
  2. Проектируйте интерфейсы с чёткими контрактами. Если метод интерфейса подразумевает определённое поведение (например, SetWidth и SetHeight независимы), то все реализации должны ему следовать.
  3. Используйте интерфейсы как абстракции поведения, а не иерархии. В Go принято выделять маленькие интерфейсы, которые фокусируются на одном действии. Например, вместо Resizable можно сделать отдельные интерфейсы WidthSetter, HeightSetter, если логика действительно разная.

Хороший пример соблюдения LSP — интерфейс io.Reader. Любой тип, реализующий Read(p []byte) (n int, err error), обязан следовать контракту: читать данные в p, возвращать количество прочитанных байт и ошибку, если она возникла. Это позволяет использовать os.File, bytes.Buffer, strings.Reader и другие взаимозаменяемо.

Таким образом, в Go LSP сводится к тому, что реализации интерфейсов должны
вести себя в соответствии с ожиданиями, заложенными в интерфейсе
(документация, комментарии). Наследственности нет, поэтому проблемы,
связанные с изменением поведения предка, встречаются реже.

4. Принцип разделения интерфейса (Interface Segregation Principle, ISP)

«Клиенты не должны зависеть от интерфейсов, которые они не используют».

Иными словами, лучше иметь много маленьких специализированных интерфейсов, чем один большой «толстый» интерфейс. Если интерфейс содержит методы, не нужные конкретному клиенту, изменения в этих методах могут затронуть клиента, даже если он их не вызывает. Кроме того, большие интерфейсы сложнее реализовывать.

Пример нарушения ISP

Допустим, мы разрабатываем систему для работы с документами. Создадим интерфейс Document, который включает все возможные операции:

type Document interface {
Read() ([]byte, error)
Write(data []byte) error
Print() error
Share(emails []string) error
}

Теперь разные клиенты могут использовать этот интерфейс. Например, модуль чтения документов ожидает только Read. Модуль печати — только Print. Однако все они зависят от полного интерфейса. Если мы добавим новый метод Archive() в Document, придётся обновить все реализации, даже тем клиентам, которым этот метод не нужен. Кроме того, некоторые документы могут быть только для чтения — они не смогут реализовать Write, но будут вынуждены либо паниковать, либо оставлять заглушки. Это нарушение ISP.

Соблюдение ISP в Go

В Go сообщество активно продвигает идею маленьких интерфейсов. Стандартная библиотека полна примеров: io.Reader, io.Writer, io.Closer, fmt.Stringer и т.д. Вместо одного большого интерфейса мы разделяем его на несколько:

type Reader interface {
Read() ([]byte, error)
}

type Writer interface {
Write(data []byte) error
}

type Printer interface {
Print() error
}

type Sharer interface {
Share(emails []string) error
}

Теперь каждый клиент зависит только от того интерфейса, который ему нужен. Например:

func ProcessDocument(r Reader) {
data, _ := r.Read()
// обработка
}

func PrintDocument(p Printer) {
p.Print()
}

Если документ может и читаться, и печататься, мы можем объединить интерфейсы через композицию:

type ReadablePrintable interface {
Reader
Printer
}

Но это уже по желанию клиента. Главное, что каждый интерфейс несёт одну ответственность.

Пример с толстым интерфейсом и его разделением

Рассмотрим ещё пример: система уведомлений.

Плохой интерфейс:

type Notifier interface {
SendEmail(to, subject, body string) error
SendSMS(phone, message string) error
SendPush(deviceToken, title, body string) error
}

Теперь каждый уведомитель должен реализовать все три метода, даже если он, например, только для email. Лучше разделить:

type EmailNotifier interface {
SendEmail(to, subject, body string) error
}

type SMSNotifier interface {
SendSMS(phone, message string) error
}

type PushNotifier interface {
SendPush(deviceToken, title, body string) error
}

Тогда конкретные реализации могут выбирать, какие интерфейсы реализовывать. Клиенты тоже выбирают только нужное.

Преимущества ISP в Go

  • Меньше связанности. Изменение одного метода не затрагивает клиентов, которым он не нужен.
  • Проще тестирование. Можно использовать заглушки, реализующие только один метод.
  • Легче реализовывать. Не нужно писать пустые заглушки для неиспользуемых методов.
  • Следует идиоматике Go. Маленькие интерфейсы — это «дзен» Go.

Таким образом, ISP — это естественный стиль программирования на Go, и его
соблюдение не требует особых усилий, если разработчик помнит о нём.

5. Принцип инверсии зависимостей (Dependency Inversion Principle, DIP)

«Модули
верхних уровней не должны зависеть от модулей нижних уровней. Оба типа
модулей должны зависеть от абстракций. Абстракции не должны зависеть от
деталей. Детали должны зависеть от абстракций».

Проще говоря, код должен зависеть от интерфейсов (абстракций), а не от
конкретных реализаций. Это позволяет легко заменять реализации,
тестировать и расширять систему.

Пример нарушения DIP

Предположим, у нас есть сервис регистрации пользователей, который напрямую
использует конкретную реализацию репозитория (например, PostgreSQL) и
конкретную реализацию почтового сервиса (SendGrid).

type UserService struct {
repo *PostgresUserRepository
email *SendGridEmailService
}

func (s *UserService) Register(user *User) error {
err := s.repo.Save(user)
if err != nil {
return err
}
return s.email.SendWelcomeEmail(user.Email)
}

Проблемы:

  • UserService жёстко привязан к конкретным типам. Чтобы использовать другую БД (например, MongoDB), нужно менять код сервиса.
  • Тестировать UserService сложно: придётся поднимать реальную БД и отправлять реальные письма, либо использовать костыли.
  • Нарушается DIP: модуль верхнего уровня (бизнес-логика) зависит от деталей низкого уровня (конкретные реализации).

Соблюдение DIP через интерфейсы

Введём интерфейсы, которые описывают требуемое поведение:

type UserRepository interface {
Save(user *User) error
}

type EmailService interface {
SendWelcomeEmail(to string) error
}

type UserService struct {
repo UserRepository
email EmailService
}

func NewUserService(repo UserRepository, email EmailService) *UserService {
return &UserService{repo: repo, email: email}
}

func (s *UserService) Register(user *User) error {
if err := s.repo.Save(user); err != nil {
return err
}
return s.email.SendWelcomeEmail(user.Email)
}

Теперь UserService зависит только от абстракций (интерфейсов). Конкретные реализации передаются через конструктор (внедрение зависимостей). Это позволяет:

  • Легко подменять реализации (например, для тестов использовать in-memory репозиторий и мок-почтовик).
  • Менять БД или почтовый провайдер без изменения кода UserService.
  • Писать модульные тесты, передавая заглушки.

Пример теста:

type mockRepo struct {
users []*User
}

func (m *mockRepo) Save(u *User) error {
m.users = append(m.users, u)
return nil
}

type mockEmail struct {
sent []string
}

func (m *mockEmail) SendWelcomeEmail(to string) error {
m.sent = append(m.sent, to)
return nil
}

func TestRegister(t *testing.T) {
repo := &mockRepo{}
email := &mockEmail{}
svc := NewUserService(repo, email)

user := &User{Email: "test@example.com"}
err := svc.Register(user)
if err != nil {
t.Fatal(err)
}
if len(repo.users) != 1 {
t.Error("пользователь не сохранён")
}
if len(email.sent) != 1 {
t.Error("письмо не отправлено")
}
}

Дополнительные аспекты DIP в Go

  • Интерфейсы определяет потребитель.
    В Go часто интерфейсы объявляются там, где они используются (в пакете
    клиента), а не там, где реализуются. Это соответствует идее: детали
    зависят от абстракций, определённых потребителем. Например, пакет service определяет интерфейс UserRepository, а пакет postgres реализует его, не зная о сервисе. Это достигается за счёт неявной реализации.
  • Используйте внедрение зависимостей (DI). Вручную или с помощью DI-контейнеров передавайте зависимости в конструкторы.
  • Избегайте синглтонов и глобальных переменных. Они создают скрытые зависимости и мешают тестированию.

Соблюдение DIP делает код гибким, тестируемым и независимым от внешних библиотек и инфраструктуры.

Заключение

Принципы SOLID не теряют своей актуальности при переходе на Go. Напротив,
идиоматичный Go — с его маленькими интерфейсами, композицией и неявной
реализацией — создаёт отличную основу для их применения. Рассмотрим
кратко, как каждый принцип реализуется в Go:

  • SRP: структуры и пакеты должны иметь одну ответственность; разделяйте код на мелкие, сфокусированные компоненты.
  • OCP: используйте интерфейсы и композицию, чтобы расширять поведение без изменения существующего кода.
  • LSP:
    следите, чтобы реализации интерфейсов соблюдали контракты; избегайте
    ситуаций, когда встроенный тип нарушает поведение ожидаемое от
    интерфейса.
  • ISP:
    проектируйте маленькие интерфейсы, сосредоточенные на одной задаче; не
    заставляйте клиентов зависеть от методов, которые они не используют.
  • DIP: полагайтесь на абстракции, а не на конкретные реализации; внедряйте зависимости через интерфейсы.

Следование SOLID помогает создавать системы, которые легко поддерживать,
тестировать и развивать. Go, с его прагматичным подходом, делает это
естественно и без лишнего церемониала. Осознанное применение этих
принципов позволит вам писать код, который будет служить долго и
приносить радость как вам, так и вашим коллегам.

Надеюсь, эта статья помогла вам лучше понять, как адаптировать классические
принципы проектирования к миру Go. Удачи в разработке!