HardТеория8 min

Принципы SOLID

Принципы SOLID в контексте Go: определения, примеры, антипаттерны и реальные применения

Принципы SOLID в Go

SOLID -- пять принципов объектно-ориентированного проектирования, сформулированных Robert C. Martin. В Go они интерпретируются иначе, чем в классических ООП-языках, потому что Go не имеет классов и наследования. Тем не менее, эти принципы прекрасно ложатся на систему интерфейсов и пакетов Go.

S -- Single Responsibility Principle

Каждый модуль должен иметь одну и только одну причину для изменения.

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

Плохо: God Struct

// Bad: UserManager does EVERYTHING related to users
type UserManager struct {
    db    *sql.DB
    cache *redis.Client
    mailer *smtp.Client
}

func (m *UserManager) Create(ctx context.Context, u *User) error {
    // Validates user
    // Saves to database
    // Invalidates cache
    // Sends welcome email
    // Logs the action
    // Updates metrics
    return nil
}

func (m *UserManager) SendPasswordReset(ctx context.Context, email string) error { ... }
func (m *UserManager) GenerateReport(ctx context.Context) ([]byte, error) { ... }
func (m *UserManager) ExportToCSV(ctx context.Context) ([]byte, error) { ... }

Причины для изменения: БД-схема, формат email, кэширование, формат отчёта -- слишком много.

Хорошо: разделение ответственности

// UserRepository handles persistence only
type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) Create(ctx context.Context, u *User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
        u.ID, u.Name, u.Email,
    )
    return err
}

func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    // ...
}

// UserService orchestrates business logic
type UserService struct {
    repo     UserStore
    notifier Notifier
    cache    Cache
}

func (s *UserService) Register(ctx context.Context, u *User) error {
    if err := u.Validate(); err != nil {
        return fmt.Errorf("validating user: %w", err)
    }

    if err := s.repo.Create(ctx, u); err != nil {
        return fmt.Errorf("creating user: %w", err)
    }

    s.cache.Invalidate(ctx, "users")

    if err := s.notifier.SendWelcome(ctx, u.Email); err != nil {
        // Non-critical: log but don't fail
        slog.Error("sending welcome email", "err", err, "user", u.ID)
    }

    return nil
}

// Notifier handles email/push notifications
type Notifier struct {
    mailer *smtp.Client
}

func (n *Notifier) SendWelcome(ctx context.Context, email string) error { ... }

В стандартной библиотеке

  • net/http -- HTTP-протокол
  • encoding/json -- JSON-сериализация
  • database/sql -- абстракция БД
  • log/slog -- структурированное логирование

Каждый пакет -- одна чётко определённая область.


O -- Open/Closed Principle

Код должен быть открыт для расширения, но закрыт для модификации.

В Go это достигается через интерфейсы: новое поведение добавляется через новые реализации, без изменения существующего кода.

Плохо: switch на конкретные типы

// Bad: adding a new notification type requires modifying this function
func SendNotification(notifType string, message string) error {
    switch notifType {
    case "email":
        return sendEmail(message)
    case "sms":
        return sendSMS(message)
    case "push":
        return sendPush(message)
    // Adding Telegram? Must modify this function!
    default:
        return fmt.Errorf("unknown notification type: %s", notifType)
    }
}

Хорошо: интерфейс с подключаемыми реализациями

// Notifier is open for extension via new implementations
type Notifier interface {
    Notify(ctx context.Context, msg Message) error
}

// EmailNotifier sends email notifications
type EmailNotifier struct {
    client *smtp.Client
}

func (n *EmailNotifier) Notify(ctx context.Context, msg Message) error {
    return n.client.Send(msg.To, msg.Subject, msg.Body)
}

// SlackNotifier sends Slack notifications
type SlackNotifier struct {
    webhookURL string
}

func (n *SlackNotifier) Notify(ctx context.Context, msg Message) error {
    // POST to Slack webhook
    return nil
}

// NotificationService works with any Notifier -- closed for modification
type NotificationService struct {
    notifiers []Notifier
}

func (s *NotificationService) NotifyAll(ctx context.Context, msg Message) error {
    var errs []error
    for _, n := range s.notifiers {
        if err := n.Notify(ctx, msg); err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

// Adding Telegram? Just create a new type -- no existing code changes
type TelegramNotifier struct { botToken string }
func (n *TelegramNotifier) Notify(ctx context.Context, msg Message) error { ... }

В стандартной библиотеке

  • io.Reader / io.Writer -- любой тип может реализовать
  • sort.Interface -- сортировка без изменения пакета sort
  • http.Handler -- middleware добавляется без изменения net/http
  • encoding.TextMarshaler -- кастомная сериализация без изменения encoding/json

L -- Liskov Substitution Principle

Если S является подтипом T, то объекты типа T могут быть заменены объектами типа S без нарушения корректности программы.

В Go нет наследования, но LSP применяется к интерфейсам: любая реализация интерфейса должна полностью выполнять его контракт.

Плохо: реализация нарушает контракт

// ReadCloser requires both Read and Close to work
type ReadCloser interface {
    Read(p []byte) (n int, err error)
    Close() error
}

// Bad: NopCloser that doesn't actually close
type BrokenReadCloser struct {
    data []byte
}

func (b *BrokenReadCloser) Read(p []byte) (int, error) {
    return copy(p, b.data), io.EOF
}

func (b *BrokenReadCloser) Close() error {
    panic("not implemented") // Violates LSP!
}

Хорошо: маленькие интерфейсы, полная реализация

// Small interfaces make LSP natural
type Reader interface {
    Read(p []byte) (n int, err error)
}

// strings.Reader fully implements Reader contract
var _ Reader = (*strings.Reader)(nil) // compile-time check

// bytes.Buffer fully implements Reader contract
var _ Reader = (*bytes.Buffer)(nil) // compile-time check

Ключевой момент: в Go неявная реализация интерфейсов естественно поддерживает LSP. Если тип объявляет методы интерфейса, он обязан правильно их реализовать.

Проверка на этапе компиляции

// Compile-time assertion: ensure *MyType implements Interface
var _ Interface = (*MyType)(nil)

// This is a Go idiom to catch LSP violations early
var _ io.ReadWriteCloser = (*MyConn)(nil)

В стандартной библиотеке

io.NopCloser из stdlib -- правильный пример: Close() возвращает nil (no-op), что корректно для io.Closer.


I -- Interface Segregation Principle

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

Это самый естественный принцип для Go! Маленькие интерфейсы -- визитная карточка языка.

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

// Bad: forces implementers to define methods they don't need
type DataStore interface {
    Get(ctx context.Context, key string) ([]byte, error)
    Set(ctx context.Context, key string, value []byte) error
    Delete(ctx context.Context, key string) error
    List(ctx context.Context, prefix string) ([]string, error)
    Watch(ctx context.Context, key string) (<-chan Event, error)
    Transaction(ctx context.Context, fn func(Tx) error) error
    Backup(ctx context.Context, w io.Writer) error
    Restore(ctx context.Context, r io.Reader) error
}

// ReadOnlyCache only needs Get, but must implement everything
type ReadOnlyCache struct{}
func (c *ReadOnlyCache) Set(ctx context.Context, key string, value []byte) error {
    return fmt.Errorf("not supported") // Forced to implement unused methods!
}
// ... all other methods also not supported

Хорошо: маленькие составные интерфейсы

// Small, focused interfaces
type Reader interface {
    Get(ctx context.Context, key string) ([]byte, error)
}

type Writer interface {
    Set(ctx context.Context, key string, value []byte) error
    Delete(ctx context.Context, key string) error
}

type Lister interface {
    List(ctx context.Context, prefix string) ([]string, error)
}

type Watcher interface {
    Watch(ctx context.Context, key string) (<-chan Event, error)
}

// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}

type Store interface {
    Reader
    Writer
    Lister
}

// Functions accept only what they need
func loadConfig(ctx context.Context, r Reader) (*Config, error) {
    data, err := r.Get(ctx, "config")
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }
    // ...
}

// ReadOnlyCache implements only Reader -- clean!
type ReadOnlyCache struct {
    data map[string][]byte
}

func (c *ReadOnlyCache) Get(ctx context.Context, key string) ([]byte, error) {
    v, ok := c.data[key]
    if !ok {
        return nil, ErrNotFound
    }
    return v, nil
}

Сравнение с Java

Go Java
io.Reader (1 метод) java.io.InputStream (14+ методов)
io.Writer (1 метод) java.io.OutputStream (7+ методов)
fmt.Stringer (1 метод) Object.toString() + 10 других методов
http.Handler (1 метод) javax.servlet.Servlet (5 методов)

В стандартной библиотеке

// io package: perfect ISP examples
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }
type Seeker interface { Seek(offset int64, whence int) (int64, error) }

// Composed only when needed
type ReadWriter interface { Reader; Writer }
type ReadCloser interface { Reader; Closer }
type ReadWriteCloser interface { Reader; Writer; Closer }
type ReadWriteSeeker interface { Reader; Writer; Seeker }

D -- Dependency Inversion Principle

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.

В Go: функции и типы принимают интерфейсы, а не конкретные реализации.

Плохо: зависимость от конкретной реализации

// Bad: OrderService directly depends on PostgresDB and SMTPMailer
type OrderService struct {
    db     *PostgresDB   // concrete type -- can't test without Postgres!
    mailer *SMTPMailer    // concrete type -- can't test without SMTP server!
}

func NewOrderService() *OrderService {
    db := NewPostgresDB("localhost:5432") // hardcoded!
    mailer := NewSMTPMailer("smtp.gmail.com") // hardcoded!
    return &OrderService{db: db, mailer: mailer}
}

func (s *OrderService) PlaceOrder(ctx context.Context, o *Order) error {
    s.db.Save(ctx, o)               // tied to Postgres
    s.mailer.Send(o.CustomerEmail)   // tied to SMTP
    return nil
}

Хорошо: зависимость от интерфейсов

// Interfaces defined by the consumer (in service package, not repo package!)
type OrderStore interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

type OrderNotifier interface {
    NotifyOrderPlaced(ctx context.Context, order *Order) error
}

// OrderService depends on abstractions
type OrderService struct {
    store    OrderStore
    notifier OrderNotifier
    logger   *slog.Logger
}

// Constructor injection: dependencies provided externally
func NewOrderService(store OrderStore, notifier OrderNotifier, logger *slog.Logger) *OrderService {
    return &OrderService{
        store:    store,
        notifier: notifier,
        logger:   logger,
    }
}

func (s *OrderService) PlaceOrder(ctx context.Context, o *Order) error {
    if err := o.Validate(); err != nil {
        return fmt.Errorf("validating order: %w", err)
    }

    if err := s.store.Save(ctx, o); err != nil {
        return fmt.Errorf("saving order: %w", err)
    }

    // Non-critical notification
    if err := s.notifier.NotifyOrderPlaced(ctx, o); err != nil {
        s.logger.Error("notifying order placed", "err", err, "orderID", o.ID)
    }

    return nil
}

Реализации

// PostgreSQL implementation
type PostgresOrderStore struct {
    db *pgxpool.Pool
}

func (s *PostgresOrderStore) Save(ctx context.Context, order *Order) error {
    _, err := s.db.Exec(ctx,
        "INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)",
        order.ID, order.CustomerID, order.Total,
    )
    return err
}

// In-memory implementation for tests
type InMemoryOrderStore struct {
    mu     sync.RWMutex
    orders map[string]*Order
}

func (s *InMemoryOrderStore) Save(_ context.Context, order *Order) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.orders[order.ID] = order
    return nil
}

Тестирование

func TestOrderService_PlaceOrder(t *testing.T) {
    store := &InMemoryOrderStore{orders: make(map[string]*Order)}
    notifier := &MockNotifier{}
    logger := slog.New(slog.NewTextHandler(io.Discard, nil))

    svc := NewOrderService(store, notifier, logger)

    order := &Order{
        ID:         "order-1",
        CustomerID: "cust-1",
        Total:      99.99,
    }

    err := svc.PlaceOrder(context.Background(), order)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Verify order was saved
    stored, err := store.FindByID(context.Background(), "order-1")
    if err != nil {
        t.Fatalf("finding order: %v", err)
    }
    if stored.Total != 99.99 {
        t.Errorf("got total %v, want 99.99", stored.Total)
    }
}

DI-фреймворки для Go

Для крупных проектов с множеством зависимостей:

Фреймворк Тип Описание
wire (Google) Compile-time Генерирует код инъекции на этапе компиляции
fx (Uber) Runtime Рефлексия, автоматическое связывание
dig (Uber) Runtime Низкоуровневый DI-контейнер (fx построен поверх)
Ручной DI -- main() создаёт зависимости и передаёт в конструкторы

Для большинства проектов ручной DI в main() -- лучший выбор: просто, явно, без магии.

func main() {
    // Manual DI in main -- clear dependency graph
    db := connectDB()
    defer db.Close()

    userRepo := postgres.NewUserRepo(db)
    orderRepo := postgres.NewOrderRepo(db)
    mailer := smtp.NewMailer(smtpConfig)

    userSvc := service.NewUserService(userRepo)
    orderSvc := service.NewOrderService(orderRepo, mailer, slog.Default())

    handler := api.NewHandler(userSvc, orderSvc)

    srv := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }

    srv.ListenAndServe()
}

Проверь себя

Какой подход к Dependency Injection рекомендуется для большинства Go-проектов?

Почему маленькие интерфейсы (1-3 метода) лучше больших в Go?

Как Open/Closed Principle реализуется в Go?

Где в Go-проекте обычно определяются интерфейсы согласно Dependency Inversion?

Как принцип Single Responsibility применяется на уровне пакетов Go?