MidТеория10 min

Принципы SOLID

Пять фундаментальных принципов объектно-ориентированного проектирования с примерами на PHP 8.4 и Symfony

SOLID -- это акроним из пяти принципов объектно-ориентированного проектирования, сформулированных Робертом Мартином (Uncle Bob). Эти принципы помогают создавать код, который легко поддерживать, расширять и тестировать.

Буква Принцип Суть
S Single Responsibility Один класс -- одна причина для изменения
O Open/Closed Открыт для расширения, закрыт для изменения
L Liskov Substitution Подтипы заменяют базовый тип без поломок
I Interface Segregation Много специализированных интерфейсов лучше одного общего
D Dependency Inversion Зависимости от абстракций, а не от конкретных реализаций

S -- Single Responsibility Principle

Принцип единственной ответственности: каждый класс должен иметь только одну причину для изменения. Если класс занимается и бизнес-логикой, и отправкой email, и записью в БД -- он нарушает SRP.

Проблема (до)

<?php
declare(strict_types=1);

// Bad: class has multiple responsibilities
final class OrderProcessor
{
    public function process(array $items, string $email): void
    {
        // 1. Validate order
        if (empty($items)) {
            throw new \InvalidArgumentException('Empty order');
        }

        // 2. Calculate total
        $total = 0.0;
        foreach ($items as $item) {
            $total += $item['price'] * $item['quantity'];
            if ($total > 10000) {
                $total *= 0.95; // discount
            }
        }

        // 3. Save to database
        $pdo = new \PDO('mysql:host=localhost;dbname=shop', 'root', '');
        $stmt = $pdo->prepare('INSERT INTO orders (total, email) VALUES (?, ?)');
        $stmt->execute([$total, $email]);

        // 4. Send confirmation email
        mail($email, 'Order Confirmed', "Your total: {$total}");

        // 5. Write log
        file_put_contents('/var/log/orders.log', date('Y-m-d') . ": {$total}\n", FILE_APPEND);
    }
}

Решение (после)

<?php
declare(strict_types=1);

// Each class has exactly one responsibility

final readonly class OrderItem
{
    public function __construct(
        public string $name,
        public float $price,
        public int $quantity,
    ) {}
}

final readonly class Order
{
    /** @param list<OrderItem> $items */
    public function __construct(
        public array $items,
        public string $customerEmail,
    ) {}
}

// Responsibility: price calculation only
final class PriceCalculator
{
    private const float BULK_DISCOUNT = 0.95;
    private const float BULK_THRESHOLD = 10_000.0;

    public function calculateTotal(Order $order): float
    {
        $total = array_sum(
            array_map(
                fn(OrderItem $item): float => $item->price * $item->quantity,
                $order->items,
            ),
        );

        return $total > self::BULK_THRESHOLD
            ? $total * self::BULK_DISCOUNT
            : $total;
    }
}

// Responsibility: persistence only
final readonly class OrderRepository
{
    public function __construct(
        private \PDO $pdo,
    ) {}

    public function save(Order $order, float $total): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO orders (total, email) VALUES (:total, :email)',
        );
        $stmt->execute(['total' => $total, 'email' => $order->customerEmail]);

        return (int) $this->pdo->lastInsertId();
    }
}

// Responsibility: notification only
final readonly class OrderNotifier
{
    public function __construct(
        private MailerInterface $mailer,
    ) {}

    public function sendConfirmation(Order $order, float $total): void
    {
        $this->mailer->send($order->customerEmail, 'Order Confirmed', "Total: {$total}");
    }
}

// Responsibility: orchestration only
final readonly class OrderProcessor
{
    public function __construct(
        private PriceCalculator $calculator,
        private OrderRepository $repository,
        private OrderNotifier $notifier,
    ) {}

    public function process(Order $order): int
    {
        $total = $this->calculator->calculateTotal($order);
        $orderId = $this->repository->save($order, $total);
        $this->notifier->sendConfirmation($order, $total);

        return $orderId;
    }
}

В Symfony

Symfony активно применяет SRP:

  • EventDispatcher -- отвечает только за диспетчеризацию событий
  • Serializer -- только сериализация/десериализация
  • Validator -- только валидация
  • Mailer -- только отправка писем

Каждый компонент Symfony -- отдельный пакет с одной ответственностью. Контроллеры должны быть тонкими и делегировать логику сервисам:

<?php
declare(strict_types=1);

// Thin controller delegates to service
final class OrderController extends AbstractController
{
    #[Route('/orders', methods: ['POST'])]
    public function create(
        Request $request,
        OrderProcessor $processor,  // Injected by DI container
    ): JsonResponse {
        $order = $this->deserialize($request);

        $orderId = $processor->process($order);

        return $this->json(['id' => $orderId], Response::HTTP_CREATED);
    }
}

O -- Open/Closed Principle

Принцип открытости/закрытости: классы должны быть открыты для расширения, но закрыты для изменения. Новое поведение добавляется через новые классы, а не через редактирование существующих.

Проблема (до)

<?php
declare(strict_types=1);

// Bad: must modify class for every new payment method
final class PaymentProcessor
{
    public function pay(string $method, float $amount): bool
    {
        return match ($method) {
            'credit_card' => $this->payCreditCard($amount),
            'paypal' => $this->payPayPal($amount),
            'bitcoin' => $this->payBitcoin($amount),
            // Adding new method = modifying this class = violating OCP
            // 'apple_pay' => $this->payApplePay($amount),
            default => throw new \InvalidArgumentException("Unknown: {$method}"),
        };
    }

    private function payCreditCard(float $amount): bool
    {
        // Credit card logic mixed into this class
        return true;
    }

    private function payPayPal(float $amount): bool
    {
        // PayPal logic mixed into this class
        return true;
    }

    private function payBitcoin(float $amount): bool
    {
        // Bitcoin logic mixed into this class
        return true;
    }
}

Решение (после)

<?php
declare(strict_types=1);

// Strategy interface: open for extension
interface PaymentGateway
{
    public function supports(PaymentMethod $method): bool;
    public function charge(Money $amount): PaymentResult;
}

enum PaymentMethod: string
{
    case CreditCard = 'credit_card';
    case PayPal = 'paypal';
    case Bitcoin = 'bitcoin';
    case ApplePay = 'apple_pay';
}

final readonly class Money
{
    public function __construct(
        public int $amount,      // in cents
        public string $currency,
    ) {}
}

final readonly class PaymentResult
{
    public function __construct(
        public bool $success,
        public string $transactionId,
    ) {}
}

// Each gateway is a separate class
final readonly class CreditCardGateway implements PaymentGateway
{
    public function supports(PaymentMethod $method): bool
    {
        return $method === PaymentMethod::CreditCard;
    }

    public function charge(Money $amount): PaymentResult
    {
        // Credit card specific logic
        return new PaymentResult(true, uniqid('cc_'));
    }
}

final readonly class PayPalGateway implements PaymentGateway
{
    public function charge(Money $amount): PaymentResult
    {
        return new PaymentResult(true, uniqid('pp_'));
    }

    public function supports(PaymentMethod $method): bool
    {
        return $method === PaymentMethod::PayPal;
    }
}

// Adding Apple Pay = adding new class, NOT modifying existing code
final readonly class ApplePayGateway implements PaymentGateway
{
    public function supports(PaymentMethod $method): bool
    {
        return $method === PaymentMethod::ApplePay;
    }

    public function charge(Money $amount): PaymentResult
    {
        return new PaymentResult(true, uniqid('ap_'));
    }
}

// Processor is CLOSED for modification
final readonly class PaymentProcessor
{
    /** @param iterable<PaymentGateway> $gateways */
    public function __construct(
        private iterable $gateways,
    ) {}

    public function pay(PaymentMethod $method, Money $amount): PaymentResult
    {
        foreach ($this->gateways as $gateway) {
            if ($gateway->supports($method)) {
                return $gateway->charge($amount);
            }
        }

        throw new \RuntimeException("No gateway for: {$method->value}");
    }
}

В Symfony

Symfony построена на OCP через tagged services и compiler passes:

  • Voters -- добавляете новый Voter для нового правила доступа, не меняя Security-компонент
  • EventSubscribers -- подписка на события без изменения диспетчера
  • Normalizers/Denormalizers -- расширение Serializer через новые классы
  • Twig Extensions -- новые функции шаблонов без модификации Twig
# services.yaml -- tagged services collect all gateways automatically
services:
    _instanceof:
        App\Payment\PaymentGateway:
            tags: ['app.payment_gateway']

    App\Payment\PaymentProcessor:
        arguments:
            $gateways: !tagged_iterator app.payment_gateway

L -- Liskov Substitution Principle

Принцип подстановки Лисков: объекты подклассов должны заменять объекты базового класса без нарушения работы программы. Если B наследует A, то B можно подставить вместо A без побочных эффектов.

Проблема (до)

<?php
declare(strict_types=1);

// Bad: classic Rectangle-Square violation
class Rectangle
{
    public function __construct(
        protected float $width,
        protected float $height,
    ) {}

    public function setWidth(float $width): void
    {
        $this->width = $width;
    }

    public function setHeight(float $height): void
    {
        $this->height = $height;
    }

    public function area(): float
    {
        return $this->width * $this->height;
    }
}

// Square violates LSP: changes both dimensions on set
class Square extends Rectangle
{
    public function setWidth(float $width): void
    {
        $this->width = $width;
        $this->height = $width;  // side effect!
    }

    public function setHeight(float $height): void
    {
        $this->width = $height;  // side effect!
        $this->height = $height;
    }
}

// This function breaks with Square
function doubleWidth(Rectangle $rect): float
{
    $rect->setWidth($rect->area() > 0 ? 10 : 5);
    $rect->setHeight(5);

    // Expected: 10 * 5 = 50 for Rectangle
    // Actual with Square: 5 * 5 = 25 -- LSP VIOLATED!
    return $rect->area();
}

Решение (после)

<?php
declare(strict_types=1);

// Good: use interface, immutable shapes
interface Shape
{
    public function area(): float;
    public function perimeter(): float;
}

final readonly class Rectangle implements Shape
{
    public function __construct(
        public float $width,
        public float $height,
    ) {
        if ($width <= 0 || $height <= 0) {
            throw new \InvalidArgumentException('Dimensions must be positive');
        }
    }

    public function area(): float
    {
        return $this->width * $this->height;
    }

    public function perimeter(): float
    {
        return 2 * ($this->width + $this->height);
    }

    public function withWidth(float $width): self
    {
        return new self($width, $this->height);
    }

    public function withHeight(float $height): self
    {
        return new self($this->width, $height);
    }
}

final readonly class Square implements Shape
{
    public function __construct(
        public float $side,
    ) {
        if ($side <= 0) {
            throw new \InvalidArgumentException('Side must be positive');
        }
    }

    public function area(): float
    {
        return $this->side ** 2;
    }

    public function perimeter(): float
    {
        return 4 * $this->side;
    }

    public function withSide(float $side): self
    {
        return new self($side);
    }
}

// Now any Shape works correctly -- LSP satisfied
function printShapeInfo(Shape $shape): void
{
    echo "Area: {$shape->area()}, Perimeter: {$shape->perimeter()}\n";
}

printShapeInfo(new Rectangle(10, 5)); // Area: 50, Perimeter: 30
printShapeInfo(new Square(7));        // Area: 49, Perimeter: 28

Правила LSP

  1. Предусловия не могут быть усилены в подтипе
  2. Постусловия не могут быть ослаблены в подтипе
  3. Инварианты базового типа должны сохраняться
  4. Метод подтипа не должен выбрасывать новые исключения, которых нет у базового типа

В Symfony

Symfony соблюдает LSP через:

  • FormType -- каждый тип формы может быть подставлен вместо AbstractType
  • Transport в Mailer -- SmtpTransport, SendmailTransport взаимозаменяемы
  • Cache Adapters -- RedisAdapter, FilesystemAdapter -- все реализуют CacheItemPoolInterface и взаимозаменяемы
<?php
declare(strict_types=1);

// All cache adapters satisfy LSP -- any can replace another
use Symfony\Contracts\Cache\CacheInterface;

final readonly class ProductService
{
    public function __construct(
        private CacheInterface $cache, // Redis? File? Memcached? Does not matter
    ) {}

    public function getProduct(int $id): Product
    {
        return $this->cache->get(
            "product_{$id}",
            fn(): Product => $this->loadFromDb($id),
        );
    }
}

I -- Interface Segregation Principle

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

Проблема (до)

<?php
declare(strict_types=1);

// Bad: fat interface forces empty implementations
interface WorkerInterface
{
    public function work(): void;
    public function eat(): void;
    public function sleep(): void;
    public function code(): void;
    public function managePeople(): void;
    public function attendMeetings(): void;
}

// Robot must implement eat/sleep -- makes no sense
final class RobotWorker implements WorkerInterface
{
    public function work(): void { /* OK */ }
    public function eat(): void { /* Does not apply! */ }
    public function sleep(): void { /* Does not apply! */ }
    public function code(): void { /* OK */ }
    public function managePeople(): void { /* Does not apply! */ }
    public function attendMeetings(): void { /* Does not apply! */ }
}

// Manager must implement code() -- makes no sense
final class Manager implements WorkerInterface
{
    public function work(): void { /* OK */ }
    public function eat(): void { /* OK */ }
    public function sleep(): void { /* OK */ }
    public function code(): void { /* Does not apply! */ }
    public function managePeople(): void { /* OK */ }
    public function attendMeetings(): void { /* OK */ }
}

Решение (после)

<?php
declare(strict_types=1);

// Good: small focused interfaces
interface Workable
{
    public function work(): void;
}

interface Feedable
{
    public function eat(): void;
    public function sleep(): void;
}

interface Codeable
{
    public function code(): void;
}

interface Manageable
{
    public function managePeople(): void;
    public function attendMeetings(): void;
}

// Classes implement only what they need
final class Developer implements Workable, Feedable, Codeable
{
    public function work(): void { echo "Working\n"; }
    public function eat(): void { echo "Eating\n"; }
    public function sleep(): void { echo "Sleeping\n"; }
    public function code(): void { echo "Coding in PHP 8.4\n"; }
}

final class ProjectManager implements Workable, Feedable, Manageable
{
    public function work(): void { echo "Working\n"; }
    public function eat(): void { echo "Eating\n"; }
    public function sleep(): void { echo "Sleeping\n"; }
    public function managePeople(): void { echo "Managing\n"; }
    public function attendMeetings(): void { echo "In meeting\n"; }
}

final class RobotWorker implements Workable, Codeable
{
    public function work(): void { echo "Working 24/7\n"; }
    public function code(): void { echo "Generating code\n"; }
}

// Functions accept only what they need
function assignCodingTask(Codeable $worker): void
{
    $worker->code();
}

assignCodingTask(new Developer());    // OK
assignCodingTask(new RobotWorker());  // OK
// assignCodingTask(new ProjectManager()); // TypeError -- correct!

В Symfony

Symfony разделяет интерфейсы повсюду:

  • LoggerInterface (PSR-3) -- только логирование, ничего лишнего
  • CacheInterface vs TagAwareCacheInterface -- базовый кеш и кеш с тегами отдельно
  • MessageBusInterface vs MessageHandlerInterface -- отправка и обработка разделены
  • NormalizerInterface vs DenormalizerInterface -- сериализация и десериализация раздельно
<?php
declare(strict_types=1);

// Symfony separates serialization concerns into small interfaces
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

// Only normalizes (object -> array), does not need to denormalize
final class ProductApiNormalizer implements NormalizerInterface
{
    public function normalize(
        mixed $data,
        ?string $format = null,
        array $context = [],
    ): array {
        /** @var Product $data */
        return [
            'id' => $data->id,
            'name' => $data->name,
            'price' => $data->price->format(),
        ];
    }

    public function supportsNormalization(
        mixed $data,
        ?string $format = null,
        array $context = [],
    ): bool {
        return $data instanceof Product;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [Product::class => true];
    }
}

D -- Dependency Inversion Principle

Принцип инверсии зависимостей:

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

Проблема (до)

<?php
declare(strict_types=1);

// Bad: high-level class depends on concrete low-level class
final class MySqlUserRepository
{
    public function findById(int $id): array
    {
        $pdo = new \PDO('mysql:host=localhost;dbname=app', 'root', '');
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);

        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
    }
}

// Tightly coupled to MySQL -- cannot test, cannot switch DB
final class UserService
{
    private MySqlUserRepository $repository;

    public function __construct()
    {
        // Creates dependency inside -- impossible to test
        $this->repository = new MySqlUserRepository();
    }

    public function getUser(int $id): array
    {
        return $this->repository->findById($id);
    }
}

Решение (после)

<?php
declare(strict_types=1);

// Abstraction: defined in domain layer
interface UserRepositoryInterface
{
    public function findById(int $id): ?User;
    public function save(User $user): void;
    public function delete(int $id): void;
}

final readonly class User
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
    ) {}
}

// Detail: concrete implementation depends on abstraction
final readonly class DoctrineUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function findById(int $id): ?User
    {
        return $this->em->find(User::class, $id);
    }

    public function save(User $user): void
    {
        $this->em->persist($user);
        $this->em->flush();
    }

    public function delete(int $id): void
    {
        $user = $this->findById($id);
        if ($user !== null) {
            $this->em->remove($user);
            $this->em->flush();
        }
    }
}

// High-level module depends on abstraction, not on Doctrine
final readonly class UserService
{
    public function __construct(
        private UserRepositoryInterface $repository,
    ) {}

    public function getUser(int $id): User
    {
        return $this->repository->findById($id)
            ?? throw new UserNotFoundException($id);
    }
}

// For tests: easy to create fake implementation
final class InMemoryUserRepository implements UserRepositoryInterface
{
    /** @var array<int, User> */
    private array $users = [];

    public function findById(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

    public function save(User $user): void
    {
        $this->users[$user->id] = $user;
    }

    public function delete(int $id): void
    {
        unset($this->users[$id]);
    }
}

В Symfony

DIP -- основа Symfony DI-контейнера:

  • Autowiring -- автоматически подставляет реализацию по интерфейсу
  • Interface binding -- привязка интерфейса к конкретной реализации в services.yaml
  • #[AutoconfigureTag] -- автоматическая конфигурация через атрибуты
# services.yaml
services:
    # Bind interface to implementation
    App\Repository\UserRepositoryInterface:
        alias: App\Repository\DoctrineUserRepository

    # Or using bind
    _defaults:
        bind:
            App\Repository\UserRepositoryInterface: '@App\Repository\DoctrineUserRepository'
<?php
declare(strict_types=1);

// Symfony controller -- depends on abstractions, not implementations
final class UserController extends AbstractController
{
    #[Route('/users/{id}', methods: ['GET'])]
    public function show(
        int $id,
        UserRepositoryInterface $repository,  // Autowired by container
    ): JsonResponse {
        $user = $repository->findById($id)
            ?? throw $this->createNotFoundException();

        return $this->json($user);
    }
}

Как принципы SOLID связаны между собой

      S ──────── Маленькие классы с одной задачей
      │
      O ──────── Расширение через новые классы
      │
      L ──────── Корректная замена подтипов
      │
      I ──────── Узкие интерфейсы
      │
      D ──────── Зависимости от абстракций
      │
      ▼
   Гибкий, тестируемый, поддерживаемый код
  • SRP + ISP -- маленькие классы с маленькими интерфейсами
  • OCP + DIP -- расширение через новые реализации абстракций
  • LSP + DIP -- подтипы корректно заменяют абстракции

Проверь себя

Что из перечисленного является примером нарушения Interface Segregation Principle?

Как правильно реализовать Open/Closed Principle при добавлении нового способа оплаты?

Какой код соответствует Dependency Inversion Principle?

Почему классический пример Square extends Rectangle нарушает LSP?

Какой принцип SOLID нарушается, если класс OrderService одновременно рассчитывает цену, сохраняет заказ в БД и отправляет email?