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
- Предусловия не могут быть усилены в подтипе
- Постусловия не могут быть ослаблены в подтипе
- Инварианты базового типа должны сохраняться
- Метод подтипа не должен выбрасывать новые исключения, которых нет у базового типа
В 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
Принцип инверсии зависимостей:
- Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали зависят от абстракций.
Проблема (до)
<?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 -- подтипы корректно заменяют абстракции