Что такое SOLID и зачем это нужно

Ты написал сервис. Он работает. Через месяц приходит задача - «добавь ещё один способ оплаты». Ты открываешь код и понимаешь: чтобы добавить одну фичу, нужно поправить 14 файлов, и каждое изменение ломает что-то в другом месте.

Знакомо? Именно эту проблему решают принципы SOLID - набор правил проектирования, которые помогают писать код, готовый к изменениям.

SOLID - это не про «красоту» и не про абстрактную теорию. Это про выживание кодовой базы в условиях постоянных изменений бизнес-требований. SOLID отвечает на тот же вопрос, что и чистый код: как не превратить проект в болото - но на уровне архитектуры модулей, а не отдельных строчек.

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

Откуда взялся 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).

**S**ingle Responsibility → **O**pen/Closed → **L**iskov Substitution → **I**nterface Segregation → **D**ependency Inversion. Каждый следующий принцип логически опирается на предыдущие.

Как 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).

В Go интерфейсы реализуются неявно - не нужно писать `implements`. Это делает ISP естественным: объявляй интерфейс там, где он используется, а не там, где реализуется.

Кейсы нарушения принципов: 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 (You Ain't Gonna Need It).

Баланс: SOLID + YAGNI + KISS

СитуацияЧто делать
Один handler, одна БД, MVPПиши просто, без интерфейсов
Второй клиент для того же репозиторияВыдели интерфейс
Тесты требуют mockОбязательно интерфейс
Бизнес-логика зависит от внешнего APIDIP - интерфейс + адаптер

Масштаб задачи - главный модификатор

Размер проекта                    Дозировка 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 пакета (один пакет = одна ответственность), DIP в `internal/domain` (не импортирует `database/sql`), ISP в `internal/ports` (тонкие интерфейсы под use case). Зацикливаться на каждом типе не нужно - смотри на границы.

Что дальше

В следующих уроках разберём каждый принцип подробно с примерами на Go:

  • SRP - как определить «одну причину меняться» и разделить комбайн на модули
  • OCP - паттерн Strategy и расширение через интерфейсы
  • LSP - контракты интерфейсов и как не нарушить ожидания
  • ISP - идиоматичные Go-интерфейсы и правило «объявляй где используешь»
  • DIP - Dependency Injection, Wire и тестируемая архитектура

Мини-задание

  • Открой любой свой Go-проект и найди структуру с 3+ зависимостями - подумай, какие ответственности она совмещает
  • Попробуй выделить один интерфейс для зависимости, которую хочется замокать в тестах
  • Найди switch/case, который растёт с каждой новой фичей - это кандидат на OCP
  • Посмотри на самый большой интерфейс в проекте - можно ли разбить его на 2-3 маленьких?
  • Ответь себе: «Где в моём коде бизнес-логика напрямую импортирует инфраструктуру?»

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.