Что такое SOLID и зачем это нужно
Ты написал сервис. Он работает. Через месяц приходит задача - «добавь ещё один способ оплаты». Ты открываешь код и понимаешь: чтобы добавить одну фичу, нужно поправить 14 файлов, и каждое изменение ломает что-то в другом месте.
Знакомо? Именно эту проблему решают принципы SOLID - набор правил проектирования, которые помогают писать код, готовый к изменениям.
SOLID - это не про «красоту» и не про абстрактную теорию. Это про выживание кодовой базы в условиях постоянных изменений бизнес-требований. SOLID отвечает на тот же вопрос, что и чистый код: как не превратить проект в болото - но на уровне архитектуры модулей, а не отдельных строчек.
Откуда взялся SOLID
Аббревиатуру SOLID популяризировал Robert C. Martin (Uncle Bob) в начале 2000-х. Сами принципы формировались раньше:
- Single Responsibility - Роберт Мартин сформулировал на основе идеи cohesion (связность) из работ Тома ДеМарко и Мейлира Пейдж-Джонса 1970-80-х.
- Open/Closed - Бертран Мейер описал в книге Object-Oriented Software Construction (1988).
- Liskov Substitution - Барбара Лисков представила в 1987 году на конференции OOPSLA.
- Interface Segregation - Мартин сформулировал при работе над проектом для Xerox в 1990-х.
- Dependency Inversion - Мартин опубликовал в статье 1996 года.
В 2004 году Майкл Фэзерс предложил акроним S.O.L.I.D., и название прижилось. С тех пор принципы стали стандартным набором знаний для любого backend-разработчика.
Пять принципов - обзор
S - Single Responsibility Principle (SRP)
У модуля должна быть одна и только одна причина меняться. Если структура отвечает и за валидацию, и за отправку email, и за запись в БД - она делает слишком много. Изменение логики email сломает валидацию. SRP помогает разделить ответственности так, чтобы изменения были локальными.
O - Open/Closed Principle (OCP)
Модуль должен быть открыт для расширения, но закрыт для изменения. Когда появляется новый тип оплаты, ты не правишь существующий switch на 500 строк - ты добавляешь новую реализацию интерфейса. Старый код не трогаешь, а значит не ломаешь. Архитектурно это обоснование для паттернов Strategy и Decorator.
L - Liskov Substitution Principle (LSP)
Подтипы должны быть взаимозаменяемы с базовым типом без поломки логики. Если функция принимает интерфейс Storage, любая реализация - PostgresStorage, RedisStorage, MockStorage - должна работать корректно. Никаких сюрпризов.
I - Interface Segregation Principle (ISP)
Клиенты не должны зависеть от методов, которые не используют. Интерфейс с 15 методами - это боль. Лучше три маленьких интерфейса по 2-3 метода. В Go это особенно естественно: io.Reader, io.Writer, io.Closer - классический пример ISP (подробнее про интерфейсы Go).
D - Dependency Inversion Principle (DIP)
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. Бизнес-логика не импортирует database/sql напрямую - она работает через интерфейс. Это позволяет подменять реализацию в тестах и менять инфраструктуру без переписывания домена (см. также DI в PHP).
Как SOLID выглядит в Go
Go не имеет классов и наследования. Но SOLID ложится на Go отлично - интерфейсы, композиция и пакетная система делают принципы естественными. В PHP принципы тоже органичны: интерфейсы, final-классы и DI через конструктор - основа современного Symfony/Laravel-кода.
Плохо: всё в одной структуре
// OrderService делает слишком много - нарушает SRP
type OrderService struct {
db *sql.DB
smtp *smtp.Client
}
func (s *OrderService) Create(order Order) error {
// валидация
if order.Total <= 0 {
return errors.New("invalid total")
}
// сохранение в БД
_, err := s.db.Exec("INSERT INTO orders ...", order.Total)
if err != nil {
return err
}
// отправка email
return s.smtp.SendMail("admin@example.com", nil, "New order", nil)
}
// OrderService делает слишком много - нарушает SRP
final class OrderService {
public function __construct(
private PDO $db,
private SMTPClient $smtp,
) {}
public function create(Order $order): void {
// валидация
if ($order->total <= 0) {
throw new InvalidArgumentException('invalid total');
}
// сохранение в БД
$this->db->prepare('INSERT INTO orders ...')->execute([$order->total]);
// отправка email
$this->smtp->send('admin@example.com', 'New order', '');
}
}
// OrderService делает слишком много - нарушает SRP
class OrderService {
#db;
#smtp;
constructor(db, smtp) {
this.#db = db;
this.#smtp = smtp;
}
async create(order) {
// валидация
if (order.total <= 0) {
throw new Error('invalid total');
}
// сохранение в БД
await this.#db.query('INSERT INTO orders ...', [order.total]);
// отправка email
await this.#smtp.send('admin@example.com', 'New order', '');
}
}
Проблемы: нельзя протестировать без БД и SMTP-сервера, добавление нового канала уведомлений потребует правки OrderService.
Хорошо: разделяем ответственности через интерфейсы
// Каждый интерфейс - одна ответственность (SRP + ISP)
type OrderRepository interface {
Save(ctx context.Context, order Order) error
}
type Notifier interface {
Notify(ctx context.Context, event string, data any) error
}
// OrderService зависит от абстракций (DIP)
type OrderService struct {
repo OrderRepository
notifier Notifier
}
func (s *OrderService) Create(ctx context.Context, order Order) error {
if err := order.Validate(); err != nil {
return fmt.Errorf("validation: %w", err)
}
if err := s.repo.Save(ctx, order); err != nil {
return fmt.Errorf("save order: %w", err)
}
return s.notifier.Notify(ctx, "order.created", order)
}
// Каждый интерфейс - одна ответственность (SRP + ISP)
interface OrderRepository
{
public function save(Order $order): void;
}
interface Notifier
{
public function notify(string $event, mixed $data): void;
}
// OrderService зависит от абстракций (DIP)
final class OrderService
{
public function __construct(
private readonly OrderRepository $repo,
private readonly Notifier $notifier,
) {}
public function create(Order $order): void
{
$order->validate();
$this->repo->save($order);
$this->notifier->notify('order.created', $order);
}
}
// Контракты задаём через JSDoc и duck typing - каждая «роль» отдельная
/** @typedef {{ save: (order: Order) => Promise<void> }} OrderRepository */
/** @typedef {{ notify: (event: string, data: unknown) => Promise<void> }} Notifier */
// OrderService зависит от абстракций (DIP)
class OrderService {
#repo;
#notifier;
/**
* @param {OrderRepository} repo
* @param {Notifier} notifier
*/
constructor(repo, notifier) {
this.#repo = repo;
this.#notifier = notifier;
}
async create(order) {
order.validate();
await this.#repo.save(order);
await this.#notifier.notify('order.created', order);
}
}
Теперь OrderService легко тестировать - подставляем mock-реализации интерфейсов. Добавить Telegram-уведомления? Создаём новую реализацию Notifier - OrderService не меняется (OCP).
Кейсы нарушения принципов: before/after
Сжатые примеры - по одному нарушению на принцип. В следующих уроках каждый разберём подробнее.
SRP - «один тип, одна причина меняться»
// User одновременно: домен, валидация, хеширование пароля, БД, email
type User struct{ ID, Email, PasswordHash string }
func (u *User) Validate() error { /* ... */ }
func (u *User) HashPassword(p string) error { /* bcrypt */ }
func (u *User) Save(db *sql.DB) error { /* SQL */ }
func (u *User) SendWelcome(s *smtp.Client) error { /* ... */ }
// Каждый тип имеет одну причину меняться
type User struct{ ID, Email, PasswordHash string } // доменный объект
type UserValidator struct{ /* правила */ } // валидация
type PasswordHasher interface { Hash(p string) (string, error) }
type UserRepo interface { Save(ctx context.Context, u User) error }
type Mailer interface { SendWelcome(ctx context.Context, u User) error }
// User одновременно: домен, валидация, хеширование, БД, email
final class User {
public function validate(): void { /* ... */ }
public function hashPassword(string $p): void { /* password_hash */ }
public function save(PDO $db): void { /* SQL */ }
public function sendWelcome(Mailer $m): void { /* ... */ }
}
// Каждый тип имеет одну причину меняться
final class User { public string $id; public string $email; /* ... */ } // домен
final class UserValidator { /* правила */ } // валидация
interface PasswordHasher { public function hash(string $p): string; }
interface UserRepo { public function save(User $u): void; }
interface Mailer { public function sendWelcome(User $u): void; }
// User одновременно: домен, валидация, хеширование, БД, email
class User {
validate() { /* ... */ }
async hashPassword(p) { /* bcrypt */ }
async save(db) { /* SQL */ }
async sendWelcome(mailer) { /* ... */ }
}
// Каждый тип имеет одну причину меняться
class User { /* id, email, passwordHash - только домен */ }
class UserValidator { /* правила */ }
/** @typedef {{ hash: (p: string) => Promise<string> }} PasswordHasher */
/** @typedef {{ save: (u: User) => Promise<void> }} UserRepo */
/** @typedef {{ sendWelcome: (u: User) => Promise<void> }} Mailer */
Меняется политика паролей - правим один тип. Меняется БД - правим другой. Меняется email-провайдер - третий. Три разные причины - три разных модуля.
OCP - «открыт для расширения, закрыт для изменения»
// Switch растёт при каждом новом провайдере
func Charge(method string, amount int) error {
switch method {
case "stripe": return chargeStripe(amount)
case "yookassa": return chargeYookassa(amount)
case "paypal": return chargePaypal(amount)
// новый провайдер → правим эту функцию
}
return fmt.Errorf("unknown method: %s", method)
}
// Новый провайдер = новая реализация, существующий код не меняется
type PaymentProvider interface {
Charge(ctx context.Context, amount int) error
}
type Service struct{ providers map[string]PaymentProvider }
func (s *Service) Charge(ctx context.Context, method string, amount int) error {
p, ok := s.providers[method]
if !ok { return fmt.Errorf("unknown method: %s", method) }
return p.Charge(ctx, amount)
}
// Регистрация в композиционном корне:
svc := &Service{providers: map[string]PaymentProvider{
"stripe": stripeProvider,
"yookassa": yookassaProvider,
}}
// Switch растёт при каждом новом провайдере
function charge(string $method, int $amount): void {
match ($method) {
'stripe' => chargeStripe($amount),
'yookassa' => chargeYookassa($amount),
'paypal' => chargePaypal($amount),
default => throw new InvalidArgumentException("unknown: $method"),
};
}
// Новый провайдер = новый класс, существующий код не меняется
interface PaymentProvider
{
public function charge(int $amount): void;
}
final class PaymentService
{
/** @param array<string, PaymentProvider> $providers */
public function __construct(
private readonly array $providers,
) {}
public function charge(string $method, int $amount): void
{
if (!isset($this->providers[$method])) {
throw new InvalidArgumentException("unknown: $method");
}
$this->providers[$method]->charge($amount);
}
}
// В Symfony: помечаем реализации #[AsTaggedItem('payment')],
// контейнер автоматически собирает их в массив.
// Switch растёт при каждом новом провайдере
async function charge(method, amount) {
switch (method) {
case 'stripe': return chargeStripe(amount);
case 'yookassa': return chargeYookassa(amount);
case 'paypal': return chargePaypal(amount);
default:
throw new Error(`unknown method: ${method}`);
}
}
// Новый провайдер = новая реализация, существующий код не меняется
/** @typedef {{ charge: (amount: number) => Promise<void> }} PaymentProvider */
class PaymentService {
/** @param {Record<string, PaymentProvider>} providers */
constructor(providers) {
this.providers = providers;
}
async charge(method, amount) {
const provider = this.providers[method];
if (!provider) throw new Error(`unknown method: ${method}`);
await provider.charge(amount);
}
}
// Регистрация: добавляем yookassa, не трогая существующий код
const svc = new PaymentService({
stripe: stripeProvider,
yookassa: yookassaProvider,
});
LSP - «подтип ведёт себя как базовый»
// ReadOnlyRepo «реализует» Repo, но Save паникует - нарушение контракта
type Repo interface {
Get(id int64) (*User, error)
Save(u User) error
}
type ReadOnlyRepo struct{ data map[int64]User }
func (r *ReadOnlyRepo) Get(id int64) (*User, error) { ... }
func (r *ReadOnlyRepo) Save(u User) error { panic("read-only!") }
// Вызывающий код ждёт от Repo, что Save сохранит - а получает panic
// Разделяем по возможностям - клиент знает, что ему нужно
type Reader interface { Get(id int64) (*User, error) }
type Writer interface { Save(u User) error }
type Repo interface { Reader; Writer } // композиция
func ListUsers(r Reader) { /* нужен только Get */ }
func ImportUsers(rw Repo) { /* нужен Get и Save */ }
// ReadOnlyRepo «реализует» Repo, но save() кидает - нарушение контракта
interface Repo {
public function get(int $id): User;
public function save(User $u): void;
}
final class ReadOnlyRepo implements Repo {
public function get(int $id): User { /* ... */ }
public function save(User $u): void { throw new LogicException('read-only!'); }
}
// Вызывающий код ждёт, что save() сохранит - а получает исключение
// Разделяем по возможностям - клиент знает, что ему нужно
interface Reader { public function get(int $id): User; }
interface Writer { public function save(User $u): void; }
interface Repo extends Reader, Writer {} // композиция через extends
function listUsers(Reader $r): void { /* нужен только get */ }
function importUsers(Repo $rw): void { /* нужен get и save */ }
// ReadOnlyRepo «реализует» Repo, но save() кидает - нарушение контракта
class ReadOnlyRepo {
async get(id) { /* ... */ }
async save(u) { throw new Error('read-only!'); }
}
// Вызывающий код ждёт, что save() сохранит - а получает исключение
// Разделяем по возможностям - клиент знает, что ему нужно
/** @typedef {{ get: (id: number) => Promise<User> }} Reader */
/** @typedef {{ save: (u: User) => Promise<void> }} Writer */
/** @typedef {Reader & Writer} Repo */
async function listUsers(reader) { /* нужен только get */ }
async function importUsers(repo) { /* нужен get и save */ }
ISP - «не заставляй клиента зависеть от методов, которые он не использует»
// «Жирный» интерфейс - клиент получает 12 методов, нужны 2
type Storage interface {
Get(id string) ([]byte, error)
Put(id string, data []byte) error
Delete(id string) error
List(prefix string) ([]string, error)
Move(from, to string) error
Copy(from, to string) error
Lock(id string) (Unlock, error)
// ... ещё 5 методов
}
func ServeAvatar(s Storage, id string) ([]byte, error) {
return s.Get(id) // 11 ненужных методов в зависимостях
}
// Интерфейс там, где используется - узкий и по делу
type AvatarSource interface {
Get(id string) ([]byte, error)
}
func ServeAvatar(s AvatarSource, id string) ([]byte, error) {
return s.Get(id)
}
// MinIO-стораж реализует и Storage и AvatarSource - без правок
// «Жирный» интерфейс - клиент получает 12 методов, нужны 2
interface Storage {
public function get(string $id): string;
public function put(string $id, string $data): void;
public function delete(string $id): void;
public function list(string $prefix): array;
public function move(string $from, string $to): void;
public function copy(string $from, string $to): void;
public function lock(string $id): Unlock;
// ... ещё 5 методов
}
final class AvatarController {
public function __construct(private Storage $s) {} // тащит 11 ненужных методов
public function serve(string $id): string { return $this->s->get($id); }
}
// Узкий интерфейс на стороне потребителя
interface AvatarSource
{
public function get(string $id): string;
}
final class AvatarController
{
public function __construct(
private readonly AvatarSource $s,
) {}
public function serve(string $id): string
{
return $this->s->get($id);
}
}
// MinIO-стораж имплементит и Storage и AvatarSource - без правок
// «Жирный» контракт - клиент получает 12 методов, нужны 2
class AvatarController {
/** @param {Storage} storage - тащит все 12 методов */
constructor(storage) { this.storage = storage; }
async serve(id) { return this.storage.get(id); }
}
// Узкий контракт на стороне потребителя
/** @typedef {{ get: (id: string) => Promise<Buffer> }} AvatarSource */
class AvatarController {
/** @param {AvatarSource} source */
constructor(source) { this.source = source; }
async serve(id) { return this.source.get(id); }
}
// MinIO-стораж удовлетворяет и Storage, и AvatarSource - без правок
Идиома Go: «Accept interfaces, return structs» + интерфейсы объявляй на стороне потребителя. В PHP тот же подход работает естественно через DI и интерфейсы под use case.
DIP - «зависим от абстракции, а не от реализации»
// Бизнес-логика жёстко завязана на конкретную БД
import "database/sql"
type OrderService struct{ db *sql.DB }
func (s *OrderService) Place(o Order) error {
_, err := s.db.Exec("INSERT INTO orders ...") // нельзя протестировать без БД
return err
}
// Сервис зависит от интерфейса, конкретика - на границе системы
type OrderRepo interface {
Save(ctx context.Context, o Order) error
}
type OrderService struct{ repo OrderRepo }
func (s *OrderService) Place(ctx context.Context, o Order) error {
return s.repo.Save(ctx, o)
}
// В тестах:
type fakeRepo struct{ saved []Order }
func (f *fakeRepo) Save(_ context.Context, o Order) error {
f.saved = append(f.saved, o); return nil
}
// В main.go (composition root):
svc := NewOrderService(postgres.NewOrderRepo(db))
// Бизнес-логика жёстко завязана на конкретную БД
final class OrderService {
public function __construct(private PDO $db) {}
public function place(Order $o): void {
$this->db->prepare('INSERT INTO orders ...')->execute(); // тест без БД невозможен
}
}
// Сервис зависит от интерфейса, конкретика - на границе системы
interface OrderRepo
{
public function save(Order $o): void;
}
final class OrderService
{
public function __construct(
private readonly OrderRepo $repo,
) {}
public function place(Order $o): void
{
$this->repo->save($o);
}
}
// В тестах:
final class FakeOrderRepo implements OrderRepo
{
/** @var Order[] */
public array $saved = [];
public function save(Order $o): void
{
$this->saved[] = $o;
}
}
// В Symfony контейнер сам подставит PdoOrderRepo в конструктор OrderService
// благодаря autowiring: связь interface → реализация задаётся в services.yaml.
// Бизнес-логика жёстко завязана на конкретную БД
class OrderService {
#db;
constructor(db) { this.#db = db; }
async place(order) {
// тест без БД невозможен
await this.#db.query('INSERT INTO orders ...');
}
}
// Сервис зависит от абстракции, конкретика - на границе системы
/** @typedef {{ save: (o: Order) => Promise<void> }} OrderRepo */
class OrderService {
#repo;
/** @param {OrderRepo} repo */
constructor(repo) { this.#repo = repo; }
async place(order) { await this.#repo.save(order); }
}
// В тестах: фейк без БД
class FakeOrderRepo {
saved = [];
async save(o) { this.saved.push(o); }
}
// В composition root (например, index.js):
const svc = new OrderService(new PgOrderRepo(pool));
SOLID в Go: почему интерфейсы делают это естественнее
Принцип Механизм в Go Почему «легче»
─────── ─────────────────────────────────────────────── ────────────────────
SRP пакеты + малые типы; нет наследования → структурный «вес»
нет соблазна слепить базовый класс «на всё» принципа отсутствует
OCP интерфейс как точка расширения + composition root регистрация реализаций
(map[string]Provider или DI-контейнер) тривиальна
LSP неявная реализация → если поведение не подходит, нет «наследования по
тип просто не присваивается переменной интерфейса ошибке» - `implements` явный
ISP «accept interfaces, return structs» + интерфейсы маленькие интерфейсы
на стороне потребителя идиоматичны (io.Reader)
DIP конструкторы-функции `New(deps) *X`; Wire/Fx генерят DI не требует фреймворка,
композиционный корень язык уже даёт всё нужное
Поэтому идиоматичный Go-код, написанный без знания SOLID, часто оказывается SOLID-совместимым: маленькие интерфейсы, композиция вместо наследования, инъекция через конструктор - это норма стиля, а не подвиг архитектора.
Когда SOLID вредит
SOLID - это инструмент, а не религия. Слепое следование принципам приводит к over-engineering:
Абстракция ради абстракции. Если у тебя одна реализация Storage и не планируется вторая - интерфейс не нужен. Добавишь, когда появится реальная потребность.
Слишком мелкие интерфейсы. ISP говорит «делай интерфейсы маленькими», но интерфейс с одним методом на каждое действие - это другая крайность. Группируй по смыслу.
Преждевременное обобщение. OCP не означает «делай всё расширяемым с первого дня». Сначала пойми, какие точки расширения реально понадобятся.
// Over-engineering: интерфейс ради интерфейса
type StringFormatter interface {
Format(s string) string
}
// Достаточно обычной функции
func FormatUserName(name string) string {
return strings.TrimSpace(strings.ToLower(name))
}
Баланс: SOLID + YAGNI + KISS
| Ситуация | Что делать |
|---|---|
| Один handler, одна БД, MVP | Пиши просто, без интерфейсов |
| Второй клиент для того же репозитория | Выдели интерфейс |
| Тесты требуют mock | Обязательно интерфейс |
| Бизнес-логика зависит от внешнего API | DIP - интерфейс + адаптер |
Масштаб задачи - главный модификатор
Размер проекта Дозировка SOLID
───────────────────────────── ─────────────────────────────────────────────
30-строчный скрипт Никакого SOLID. Функция main, что нужно -
(одноразовая миграция, делает напрямую. Через неделю удалим.
утилита для CSV)
Один сервис, MVP, 2-4 endpoint-а SRP/DIP «по верхам»: handler → service → repo
стек. Без интерфейсов, пока не появится
второй клиент или тест с mock.
Один сервис в проде, активное Полный SOLID на границах слоёв (repo, mailer,
развитие, команда 2-5 человек payment). Внутри слоя - KISS, без лишних
абстракций.
10+ сервисов, разные команды, SOLID + DDD + Hex Arc + контракты между
SLA, регуляции сервисами. Здесь over-engineering менее
опасен, чем undocumented coupling.
Главный вопрос - «сколько раз этот код будет читаться/меняться разными людьми?». Один раз - пиши прямо. Сотни раз годами - инвестируй в архитектуру.
Что дальше
В следующих уроках разберём каждый принцип подробно с примерами на Go:
- SRP - как определить «одну причину меняться» и разделить комбайн на модули
- OCP - паттерн Strategy и расширение через интерфейсы
- LSP - контракты интерфейсов и как не нарушить ожидания
- ISP - идиоматичные Go-интерфейсы и правило «объявляй где используешь»
- DIP - Dependency Injection, Wire и тестируемая архитектура
Мини-задание
- Открой любой свой Go-проект и найди структуру с 3+ зависимостями - подумай, какие ответственности она совмещает
- Попробуй выделить один интерфейс для зависимости, которую хочется замокать в тестах
- Найди switch/case, который растёт с каждой новой фичей - это кандидат на OCP
- Посмотри на самый большой интерфейс в проекте - можно ли разбить его на 2-3 маленьких?
- Ответь себе: «Где в моём коде бизнес-логика напрямую импортирует инфраструктуру?»