Структурные паттерны (Structural Patterns)
Структурные паттерны отвечают за композицию классов и объектов в более крупные структуры, сохраняя гибкость и эффективность.
Adapter
Проблема
Есть класс с нужной функциональностью, но его интерфейс несовместим с остальным кодом. Переписать класс нельзя -- это внешняя библиотека или легаси-код.
Решение
<?php
declare(strict_types=1);
// Target interface our code expects
interface PaymentGateway
{
public function charge(float $amount, string $currency): PaymentResult;
}
// Result DTO
final readonly class PaymentResult
{
public function __construct(
public bool $success,
public string $transactionId,
public ?string $error = null,
) {}
}
// External SDK with incompatible interface (can't modify)
final class StripeApiClient
{
public function createCharge(int $amountInCents, string $cur, string $source): array
{
// External API call to Stripe...
return ['id' => 'ch_123abc', 'status' => 'succeeded'];
}
}
// Adapter wraps the incompatible class
final readonly class StripePaymentAdapter implements PaymentGateway
{
public function __construct(
private StripeApiClient $stripe,
private string $defaultSource = 'tok_visa',
) {}
public function charge(float $amount, string $currency): PaymentResult
{
$amountInCents = (int) round($amount * 100);
try {
$result = $this->stripe->createCharge(
amountInCents: $amountInCents,
cur: strtolower($currency),
source: $this->defaultSource,
);
return new PaymentResult(
success: $result['status'] === 'succeeded',
transactionId: $result['id'],
);
} catch (\Throwable $e) {
return new PaymentResult(
success: false,
transactionId: '',
error: $e->getMessage(),
);
}
}
}
// Client code works with unified interface
function processOrder(PaymentGateway $gateway, float $total): void
{
$result = $gateway->charge($total, 'USD');
if ($result->success) {
echo "Payment successful: {$result->transactionId}\n";
}
}
Когда использовать
- Интеграция сторонних библиотек с несовместимым интерфейсом
- Унификация нескольких разных API под одним интерфейсом
- Работа с легаси-кодом без его переписывания
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
// Symfony Serializer normalizers -- adapters for different data formats
final class LegacyUserNormalizer implements NormalizerInterface
{
public function normalize(
mixed $data,
?string $format = null,
array $context = [],
): array {
/** @var LegacyUser $data */
// Adapt legacy user format to modern API format
return [
'id' => $data->getUserId(),
'full_name' => $data->getFirstName() . ' ' . $data->getLastName(),
'email' => $data->getElectronicMail(),
];
}
public function supportsNormalization(
mixed $data,
?string $format = null,
array $context = [],
): bool {
return $data instanceof LegacyUser;
}
public function getSupportedTypes(?string $format): array
{
return [LegacyUser::class => true];
}
}
Decorator
Проблема
Нужно добавить объекту новое поведение динамически, не изменяя его класс. Наследование не подходит, потому что расширения комбинируются в разных вариациях.
Решение
<?php
declare(strict_types=1);
// Component interface
interface Logger
{
public function log(string $level, string $message): void;
}
// Concrete component
final class FileLogger implements Logger
{
public function __construct(
private readonly string $path,
) {}
public function log(string $level, string $message): void
{
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), $level, $message);
file_put_contents($this->path, $line, FILE_APPEND);
}
}
// Base decorator
abstract class LoggerDecorator implements Logger
{
public function __construct(
protected readonly Logger $inner,
) {}
}
// Concrete decorator: adds timestamp prefix
final class TimestampLogger extends LoggerDecorator
{
public function log(string $level, string $message): void
{
$message = '[' . microtime(true) . '] ' . $message;
$this->inner->log($level, $message);
}
}
// Concrete decorator: filters by level
final class MinLevelLogger extends LoggerDecorator
{
private const LEVELS = ['debug' => 0, 'info' => 1, 'warning' => 2, 'error' => 3];
public function __construct(
Logger $inner,
private readonly string $minLevel = 'info',
) {
parent::__construct($inner);
}
public function log(string $level, string $message): void
{
if ((self::LEVELS[$level] ?? 0) >= (self::LEVELS[$this->minLevel] ?? 0)) {
$this->inner->log($level, $message);
}
}
}
// Concrete decorator: adds context info
final class ContextLogger extends LoggerDecorator
{
public function __construct(
Logger $inner,
private readonly string $context,
) {
parent::__construct($inner);
}
public function log(string $level, string $message): void
{
$this->inner->log($level, "[{$this->context}] {$message}");
}
}
// Stack decorators in any combination
$logger = new ContextLogger(
inner: new MinLevelLogger(
inner: new TimestampLogger(
inner: new FileLogger('/var/log/app.log'),
),
minLevel: 'warning',
),
context: 'OrderService',
);
$logger->log('debug', 'Skip this'); // Filtered out
$logger->log('error', 'Payment failed'); // Written with context + timestamp
Когда использовать
- Динамическое добавление/удаление обязанностей объекта
- Невозможность использовать наследование (final класс)
- Комбинации поведений без взрыва подклассов
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
// Symfony native decorator support
#[AsDecorator(decorates: Logger::class)]
final class CachingLogger implements Logger
{
private array $cache = [];
public function __construct(
#[AutowireDecorated]
private readonly Logger $inner,
) {}
public function log(string $level, string $message): void
{
$key = md5($level . $message);
if (!isset($this->cache[$key])) {
$this->inner->log($level, $message);
$this->cache[$key] = true;
}
}
}
Facade
Проблема
Подсистема состоит из множества классов с множеством методов. Клиентский код не должен знать детали и управлять ими напрямую.
Решение
<?php
declare(strict_types=1);
// Complex subsystem classes
final class InventoryService
{
public function checkStock(int $productId, int $quantity): bool
{
// Check warehouse...
return true;
}
public function reserve(int $productId, int $quantity): string
{
return 'RSV-' . uniqid();
}
}
final class PaymentService
{
public function authorize(float $amount, string $cardToken): string
{
return 'AUTH-' . uniqid();
}
public function capture(string $authorizationId): bool
{
return true;
}
}
final class ShippingService
{
public function calculateCost(string $address, float $weight): float
{
return 9.99;
}
public function createShipment(string $address, string $reservationId): string
{
return 'SHIP-' . uniqid();
}
}
final class NotificationService
{
public function sendOrderConfirmation(string $email, string $orderId): void
{
// Send email...
}
}
// Facade simplifies interaction with subsystem
final readonly class OrderFacade
{
public function __construct(
private InventoryService $inventory,
private PaymentService $payment,
private ShippingService $shipping,
private NotificationService $notification,
) {}
/**
* One method to place an order -- hides subsystem complexity
*/
public function placeOrder(
int $productId,
int $quantity,
float $price,
string $cardToken,
string $address,
string $email,
): OrderConfirmation {
// Step 1: Check and reserve inventory
if (!$this->inventory->checkStock($productId, $quantity)) {
throw new \RuntimeException('Product out of stock');
}
$reservationId = $this->inventory->reserve($productId, $quantity);
// Step 2: Calculate total with shipping
$shippingCost = $this->shipping->calculateCost($address, weight: 1.5);
$total = ($price * $quantity) + $shippingCost;
// Step 3: Process payment
$authId = $this->payment->authorize($total, $cardToken);
$this->payment->capture($authId);
// Step 4: Create shipment
$shipmentId = $this->shipping->createShipment($address, $reservationId);
// Step 5: Notify customer
$orderId = 'ORD-' . uniqid();
$this->notification->sendOrderConfirmation($email, $orderId);
return new OrderConfirmation(
orderId: $orderId,
shipmentId: $shipmentId,
total: $total,
);
}
}
final readonly class OrderConfirmation
{
public function __construct(
public string $orderId,
public string $shipmentId,
public float $total,
) {}
}
// Client code: ONE call instead of managing 4 services
$facade = new OrderFacade(
new InventoryService(),
new PaymentService(),
new ShippingService(),
new NotificationService(),
);
$confirmation = $facade->placeOrder(
productId: 42,
quantity: 2,
price: 29.99,
cardToken: 'tok_visa',
address: '123 Main St',
email: '[email protected]',
);
Когда использовать
- Нужен простой интерфейс к сложной подсистеме
- Слишком много зависимостей между клиентом и подсистемой
- Хотите разделить подсистему на слои
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
// Symfony Mailer -- facade over transport, formatting, queuing
final readonly class NotificationFacade
{
public function __construct(
private MailerInterface $mailer, // Hides SMTP, transport, etc.
) {}
public function notifyOrderShipped(string $to, string $orderId): void
{
$email = (new Email())
->from('[email protected]')
->to($to)
->subject("Order #{$orderId} shipped!")
->text("Your order #{$orderId} is on its way.");
$this->mailer->send($email);
}
}
Proxy
Проблема
Нужно контролировать доступ к объекту: добавить ленивую загрузку, кеширование, логирование или проверку прав -- без изменения самого объекта.
Решение
<?php
declare(strict_types=1);
// Subject interface
interface UserRepository
{
public function find(int $id): ?UserDto;
public function findAll(): array;
}
final readonly class UserDto
{
public function __construct(
public int $id,
public string $name,
public string $email,
) {}
}
// Real subject -- expensive database calls
final readonly class DatabaseUserRepository implements UserRepository
{
public function find(int $id): ?UserDto
{
// Heavy database query
return new UserDto(id: $id, name: 'John', email: '[email protected]');
}
public function findAll(): array
{
// Heavy query returning thousands of rows
return [];
}
}
// Caching proxy
final class CachingUserRepository implements UserRepository
{
/** @var array<int, UserDto> */
private array $cache = [];
public function __construct(
private readonly UserRepository $real,
) {}
public function find(int $id): ?UserDto
{
if (!isset($this->cache[$id])) {
$this->cache[$id] = $this->real->find($id);
}
return $this->cache[$id];
}
public function findAll(): array
{
return $this->real->findAll();
}
}
// Logging proxy
final readonly class LoggingUserRepository implements UserRepository
{
public function __construct(
private UserRepository $real,
private Logger $logger,
) {}
public function find(int $id): ?UserDto
{
$this->logger->log('info', "Finding user #{$id}");
$start = microtime(true);
$result = $this->real->find($id);
$duration = round((microtime(true) - $start) * 1000, 2);
$this->logger->log('info', "Found user #{$id} in {$duration}ms");
return $result;
}
public function findAll(): array
{
$this->logger->log('info', 'Finding all users');
return $this->real->findAll();
}
}
// Stack proxies
$repository = new LoggingUserRepository(
real: new CachingUserRepository(
real: new DatabaseUserRepository(),
),
logger: new FileLogger('/var/log/app.log'),
);
$user = $repository->find(1); // Logged + cached
$user = $repository->find(1); // Logged + from cache (no DB hit)
PHP 8.4: Lazy proxy с ghost objects
<?php
declare(strict_types=1);
// PHP 8.4 lazy objects (built-in lazy proxy support)
final class HeavyReport
{
public readonly string $data;
public function __construct(int $reportId)
{
// Expensive initialization
sleep(2);
$this->data = "Report #{$reportId} data loaded";
}
}
// PHP 8.4 lazy ghost proxy via ReflectionClass
$reflector = new \ReflectionClass(HeavyReport::class);
$lazyReport = $reflector->newLazyGhost(
function (HeavyReport $report): void {
// Called only when property is first accessed
$report->__construct(42);
},
);
// Object exists but NOT yet initialized
echo $lazyReport instanceof HeavyReport; // true
// Initialization happens HERE on first property access
echo $lazyReport->data; // "Report #42 data loaded"
Когда использовать
- Ленивая инициализация тяжёлых объектов
- Кеширование результатов
- Контроль доступа (проверка прав)
- Логирование вызовов
В Symfony
<?php
declare(strict_types=1);
// Doctrine uses proxy for lazy loading entities
// When you access $order->getCustomer(), Doctrine creates a proxy
// that loads the customer from DB only when you access its properties
// Symfony services can be lazy too:
// services.yaml
// App\Service\HeavyService:
// lazy: true # Creates a proxy, initialized on first method call
Composite
Проблема
Нужно работать с древовидной структурой объектов, обращаясь к отдельным элементам и группам единообразно. Например, файловая система, меню, организационная структура.
Решение
<?php
declare(strict_types=1);
// Component interface
interface MenuComponent
{
public function render(int $depth = 0): string;
public function getPrice(): float;
}
// Leaf
final readonly class MenuItem implements MenuComponent
{
public function __construct(
private string $name,
private float $price,
) {}
public function render(int $depth = 0): string
{
$indent = str_repeat(' ', $depth);
return "{$indent}- {$this->name}: \${$this->price}\n";
}
public function getPrice(): float
{
return $this->price;
}
}
// Composite
final class MenuCategory implements MenuComponent
{
/** @var list<MenuComponent> */
private array $children = [];
public function __construct(
private readonly string $name,
) {}
public function add(MenuComponent $component): self
{
$this->children[] = $component;
return $this;
}
public function render(int $depth = 0): string
{
$indent = str_repeat(' ', $depth);
$output = "{$indent}[{$this->name}]\n";
foreach ($this->children as $child) {
$output .= $child->render($depth + 1);
}
return $output;
}
public function getPrice(): float
{
return array_sum(
array_map(
fn(MenuComponent $c): float => $c->getPrice(),
$this->children,
),
);
}
}
// Build tree structure
$menu = new MenuCategory('Restaurant Menu');
$appetizers = new MenuCategory('Appetizers');
$appetizers->add(new MenuItem('Soup', 5.99));
$appetizers->add(new MenuItem('Salad', 7.99));
$mains = new MenuCategory('Main Courses');
$mains->add(new MenuItem('Steak', 24.99));
$mains->add(new MenuItem('Pasta', 16.99));
$menu->add($appetizers);
$menu->add($mains);
// Uniform access: render whole tree or any branch
echo $menu->render();
echo "Total: \$" . $menu->getPrice(); // Sum of all items
Когда использовать
- Древовидная структура данных (меню, каталоги, организации)
- Единообразная обработка листьев и контейнеров
- Рекурсивная композиция объектов
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\Form\FormInterface;
// Symfony Form -- classic Composite pattern
// A form can contain fields (leaves) and sub-forms (composites)
// $form->isValid() recursively validates all children
// Symfony Finder -- composite for file system traversal
use Symfony\Component\Finder\Finder;
$finder = new Finder();
$finder->files()->in('/src')->name('*.php');
foreach ($finder as $file) {
echo $file->getRealPath() . "\n";
}
Bridge
Проблема
Нужно разделить абстракцию и реализацию, чтобы они могли изменяться независимо. Без Bridge каждая комбинация абстракция + реализация требует отдельного класса.
Решение
<?php
declare(strict_types=1);
// Implementation interface (how to render)
interface Renderer
{
public function renderTitle(string $title): string;
public function renderParagraph(string $text): string;
public function renderImage(string $url, string $alt): string;
}
final class HtmlRenderer implements Renderer
{
public function renderTitle(string $title): string
{
return "<h1>{$title}</h1>";
}
public function renderParagraph(string $text): string
{
return "<p>{$text}</p>";
}
public function renderImage(string $url, string $alt): string
{
return "<img src=\"{$url}\" alt=\"{$alt}\">";
}
}
final class MarkdownRenderer implements Renderer
{
public function renderTitle(string $title): string
{
return "# {$title}";
}
public function renderParagraph(string $text): string
{
return "\n{$text}\n";
}
public function renderImage(string $url, string $alt): string
{
return "";
}
}
// Abstraction (what to render)
abstract class Page
{
public function __construct(
protected readonly Renderer $renderer,
) {}
abstract public function render(): string;
}
// Refined abstractions
final class ArticlePage extends Page
{
public function __construct(
Renderer $renderer,
private readonly string $title,
private readonly string $body,
private readonly ?string $imageUrl = null,
) {
parent::__construct($renderer);
}
public function render(): string
{
$output = $this->renderer->renderTitle($this->title);
$output .= "\n" . $this->renderer->renderParagraph($this->body);
if ($this->imageUrl !== null) {
$output .= "\n" . $this->renderer->renderImage($this->imageUrl, $this->title);
}
return $output;
}
}
final class ProductPage extends Page
{
public function __construct(
Renderer $renderer,
private readonly string $name,
private readonly float $price,
private readonly string $description,
) {
parent::__construct($renderer);
}
public function render(): string
{
$output = $this->renderer->renderTitle($this->name);
$output .= "\n" . $this->renderer->renderParagraph("Price: \${$this->price}");
$output .= "\n" . $this->renderer->renderParagraph($this->description);
return $output;
}
}
// Any page type + any renderer without class explosion
$htmlArticle = new ArticlePage(new HtmlRenderer(), 'Title', 'Body text');
$mdArticle = new ArticlePage(new MarkdownRenderer(), 'Title', 'Body text');
echo $htmlArticle->render(); // <h1>Title</h1><p>Body text</p>
echo $mdArticle->render(); // # Title\nBody text
Когда использовать
- Множество вариаций в двух измерениях (тип + реализация)
- Нужно менять реализацию в рантайме
- Хотите избежать взрыва наследования
В Symfony
<?php
declare(strict_types=1);
// Symfony Cache: abstraction (CacheInterface) + implementation (adapter)
// The "bridge" allows switching between Redis, Memcached, Filesystem
// without changing business code
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final readonly class ProductService
{
public function __construct(
private CacheInterface $cache, // Abstraction -- doesn't know the adapter
) {}
public function getProduct(int $id): array
{
return $this->cache->get("product_{$id}", function (ItemInterface $item): array {
$item->expiresAfter(3600);
// Load from database...
return ['id' => 1, 'name' => 'Widget'];
});
}
}
Flyweight
Проблема
Приложению нужно создавать тысячи или миллионы объектов, каждый из которых содержит повторяющиеся данные. Например, текстовый редактор хранит объект для каждого символа в документе -- шрифт, размер, цвет, начертание. Документ из 100 000 символов при 20 уникальных стилях будет хранить 100 000 объектов с дублирующимся состоянием, что съедает всю оперативную память.
Решение
Разделяем состояние объекта на внутреннее (intrinsic -- общее, разделяемое) и внешнее (extrinsic -- уникальное для каждого контекста). Flyweight хранит только внутреннее состояние и переиспользуется через фабрику с пулом.
<?php
declare(strict_types=1);
// Intrinsic state: shared between all characters with the same style
final readonly class GlyphStyle
{
public function __construct(
public string $fontFamily,
public int $fontSize,
public string $color,
public bool $bold,
public bool $italic,
) {}
public function getKey(): string
{
$flags = ($this->bold ? 'B' : '') . ($this->italic ? 'I' : '');
return "{$this->fontFamily}:{$this->fontSize}:{$this->color}:{$flags}";
}
}
// Flyweight factory: ensures shared instances
final class GlyphStyleFactory
{
/** @var array<string, GlyphStyle> */
private array $pool = [];
public function getStyle(
string $fontFamily,
int $fontSize,
string $color,
bool $bold = false,
bool $italic = false,
): GlyphStyle {
$style = new GlyphStyle($fontFamily, $fontSize, $color, $bold, $italic);
$key = $style->getKey();
if (!isset($this->pool[$key])) {
$this->pool[$key] = $style;
}
return $this->pool[$key];
}
public function getPoolSize(): int
{
return count($this->pool);
}
}
// Extrinsic state: unique per character (position, character itself)
final readonly class Glyph
{
public function __construct(
public string $character,
public int $row,
public int $col,
public GlyphStyle $style, // Shared flyweight reference
) {}
public function render(): string
{
$weight = $this->style->bold ? 'bold' : 'normal';
$fontStyle = $this->style->italic ? 'italic' : 'normal';
return sprintf(
'<span style="font-family:%s;font-size:%dpx;color:%s;font-weight:%s;font-style:%s">%s</span>',
$this->style->fontFamily,
$this->style->fontSize,
$this->style->color,
$weight,
$fontStyle,
htmlspecialchars($this->character),
);
}
}
// Text editor document using flyweight styles
final class Document
{
/** @var list<Glyph> */
private array $glyphs = [];
public function __construct(
private readonly GlyphStyleFactory $styleFactory,
) {}
public function addCharacter(
string $char,
int $row,
int $col,
string $fontFamily,
int $fontSize,
string $color,
bool $bold = false,
bool $italic = false,
): void {
// Factory returns shared style instance
$style = $this->styleFactory->getStyle($fontFamily, $fontSize, $color, $bold, $italic);
$this->glyphs[] = new Glyph(
character: $char,
row: $row,
col: $col,
style: $style,
);
}
public function getGlyphCount(): int
{
return count($this->glyphs);
}
public function getUniqueStyleCount(): int
{
return $this->styleFactory->getPoolSize();
}
}
// Client code: 10 000 characters but only a few shared styles
$factory = new GlyphStyleFactory();
$document = new Document($factory);
$text = str_repeat('Lorem ipsum dolor sit amet. ', 400); // ~10 000 chars
$col = 0;
$row = 0;
foreach (mb_str_split($text) as $char) {
// Alternate between 3 styles -- only 3 GlyphStyle objects created
$style = match (true) {
$char === '.' => ['Arial', 12, '#999999', false, false],
ctype_upper($char) => ['Arial', 14, '#000000', true, false],
default => ['Arial', 12, '#333333', false, false],
};
$document->addCharacter($char, $row, $col, ...$style);
$col++;
if ($char === "\n" || $col > 80) {
$row++;
$col = 0;
}
}
echo "Characters: {$document->getGlyphCount()}\n"; // ~10 000
echo "Unique styles: {$document->getUniqueStyleCount()}\n"; // 3
// Without Flyweight: 10 000 style objects (~640 KB)
// With Flyweight: 3 style objects (~0.2 KB) -- memory savings ~99.9%
Когда использовать
- Приложение создаёт огромное количество объектов с повторяющимися данными
- Большая часть состояния объекта может быть вынесена как внешнее (extrinsic)
- Объекты можно группировать по общему внутреннему состоянию (intrinsic)
- Приложение не зависит от идентичности объектов (flyweight-объекты разделяются)
В Symfony
<?php
declare(strict_types=1);
// Symfony Twig: скомпилированные шаблоны кешируются и переиспользуются
// как flyweight-объекты -- один скомпилированный шаблон на множество рендеров
// Symfony Form: FormType создаётся один раз и переиспользуется
// для множества форм одного типа -- внутреннее состояние (конфигурация)
// разделяется, внешнее (данные) уникально для каждой формы
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
// FormType -- flyweight: конфигурация полей общая,
// данные формы -- внешнее состояние
final class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// This configuration is created once and reused
$builder
->add('street', TextType::class)
->add('city', TextType::class)
->add('zipCode', TextType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AddressDto::class,
]);
}
}
// Symfony DI: shared services (по умолчанию singleton)
// -- каждый сервис создаётся один раз и инжектируется во все зависимости.
// Это фактически Flyweight на уровне контейнера.
// services.yaml:
// App\Service\CurrencyConverter:
// shared: true # default -- one instance reused everywhere
Сравнение структурных паттернов
| Паттерн | Ключевая идея | Типичный случай |
|---|---|---|
| Adapter | Совмещение интерфейсов | Интеграция SDK |
| Decorator | Добавление поведения | Логирование, кеш |
| Facade | Упрощение доступа | Сложные подсистемы |
| Proxy | Контроль доступа | Lazy loading, кеш |
| Composite | Древовидные структуры | Меню, формы |
| Bridge | Разделение абстракция/реализация | Рендеринг, хранение |
| Flyweight | Разделение общего состояния | Кеш объектов |