Decorator, Adapter, Builder
Три мощных паттерна проектирования для гибкой и расширяемой архитектуры. Разбираем с примерами на PHP.
Decorator — декоратор
Decorator
Структурный паттерн🎯 Когда использовать
❌ Проблема без Decorator
Представьте, что у вас есть система уведомлений. Изначально она отправляет только email. Но потом нужно добавить SMS, Slack, Telegram... И комбинации всего этого!
// Базовый класс
class EmailNotifier { }
// Нужен email + SMS? Создаём новый класс
class EmailSmsNotifier extends EmailNotifier { }
// Email + Slack?
class EmailSlackNotifier extends EmailNotifier { }
// Email + SMS + Slack?
class EmailSmsSlackNotifier extends EmailSmsNotifier { }
// Email + SMS + Slack + Telegram?
class EmailSmsSlackTelegramNotifier extends EmailSmsSlackNotifier { }
// 💥 При 5 каналах = 2^5 = 32 класса!
// 💥 При 10 каналах = 1024 класса!
✅ Решение с Decorator
Примеры кода
// Интерфейс компонента
interface NotifierInterface
{
public function send(string $message): void;
}
// Базовая реализация
class EmailNotifier implements NotifierInterface
{
public function __construct(
private string $email,
) {}
public function send(string $message): void
{
echo "📧 Email to {$this->email}: {$message}\n";
}
}
// Базовый декоратор
abstract class NotifierDecorator implements NotifierInterface
{
public function __construct(
protected NotifierInterface $wrappee,
) {}
public function send(string $message): void
{
$this->wrappee->send($message);
}
}
// Декоратор SMS
class SmsNotifierDecorator extends NotifierDecorator
{
public function __construct(
NotifierInterface $wrappee,
private string $phone,
) {
parent::__construct($wrappee);
}
public function send(string $message): void
{
parent::send($message);
echo "📱 SMS to {$this->phone}: {$message}\n";
}
}
// Декоратор Slack
class SlackNotifierDecorator extends NotifierDecorator
{
public function __construct(
NotifierInterface $wrappee,
private string $channel,
) {
parent::__construct($wrappee);
}
public function send(string $message): void
{
parent::send($message);
echo "💬 Slack to #{$this->channel}: {$message}\n";
}
}
// Декоратор Telegram
class TelegramNotifierDecorator extends NotifierDecorator
{
public function __construct(
NotifierInterface $wrappee,
private string $chatId,
) {
parent::__construct($wrappee);
}
public function send(string $message): void
{
parent::send($message);
echo "✈️ Telegram to {$this->chatId}: {$message}\n";
}
}
// Только email
$notifier = new EmailNotifier('user@test.com');
$notifier->send('Hello!');
// 📧 Email to user@test.com: Hello!
// Email + SMS
$notifier = new SmsNotifierDecorator(
new EmailNotifier('user@test.com'),
'+7-999-123-45-67'
);
$notifier->send('Hello!');
// 📧 Email to user@test.com: Hello!
// 📱 SMS to +7-999-123-45-67: Hello!
// Email + SMS + Slack + Telegram — любая комбинация!
$notifier = new TelegramNotifierDecorator(
new SlackNotifierDecorator(
new SmsNotifierDecorator(
new EmailNotifier('user@test.com'),
'+7-999-123-45-67'
),
'alerts'
),
'123456789'
);
$notifier->send('Server is down!');
// 📧 Email to user@test.com: Server is down!
// 📱 SMS to +7-999-123-45-67: Server is down!
// 💬 Slack to #alerts: Server is down!
// ✈️ Telegram to 123456789: Server is down!
🌍 Реальные примеры использования
🔥 Где встречается Decorator
// Интерфейс репозитория
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
}
// Базовая реализация
class UserRepository implements UserRepositoryInterface
{
public function findById(int $id): ?User
{
// Медленный запрос к БД
return User::query()->find($id);
}
}
// Декоратор с кэшированием
class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $repository,
private CacheInterface $cache,
private int $ttl = 3600,
) {}
public function findById(int $id): ?User
{
$key = "user:{$id}";
// Пробуем получить из кэша
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached;
}
// Кэша нет — идём в БД
$user = $this->repository->findById($id);
if ($user !== null) {
$this->cache->set($key, $user, $this->ttl);
}
return $user;
}
}
// Использование
$repository = new CachedUserRepository(
new UserRepository(),
new RedisCache(),
);
$user = $repository->findById(1); // БД
$user = $repository->findById(1); // Кэш!
- Добавляем функционал без изменения существующего кода (OCP)
- Комбинируем поведения в любом порядке
- Каждый декоратор делает одно дело (SRP)
- Легко тестировать по отдельности
Adapter — адаптер
Adapter
Структурный паттерн🎯 Когда использовать
❌ Проблема без Adapter
Вы используете один платёжный шлюз, а потом решаете добавить второй. Но у них совершенно разные API. Без адаптера придётся менять код везде.
// Stripe API
class StripeApi
{
public function createCharge(array $data): object
{
// Stripe использует cents и свой формат
return (object) ['id' => 'ch_xxx', 'status' => 'succeeded'];
}
}
// PayPal API — совсем другой интерфейс!
class PayPalApi
{
public function makePayment(float $amount, string $currency): array
{
// PayPal использует доллары и другой формат ответа
return ['payment_id' => 'PAY-xxx', 'state' => 'approved'];
}
}
// Код пронизан условиями — ужас!
class PaymentService
{
public function pay(string $gateway, float $amount): bool
{
if ($gateway === 'stripe') {
$stripe = new StripeApi();
$result = $stripe->createCharge([
'amount' => $amount * 100, // в центах
'currency' => 'usd',
]);
return $result->status === 'succeeded';
}
if ($gateway === 'paypal') {
$paypal = new PayPalApi();
$result = $paypal->makePayment($amount, 'USD');
return $result['state'] === 'approved';
}
// 💥 Добавить новый шлюз = менять этот код
throw new Exception('Unknown gateway');
}
}
✅ Решение с Adapter
Примеры кода
// Единый интерфейс для всех платёжных систем
interface PaymentGatewayInterface
{
public function charge(float $amount, string $currency): PaymentResult;
public function refund(string $transactionId): bool;
}
// Результат платежа — унифицированный
readonly class PaymentResult
{
public function __construct(
public bool $success,
public string $transactionId,
public ?string $errorMessage = null,
) {}
}
// Сторонний Stripe SDK (мы не можем его изменить)
class StripeSDK
{
public function createCharge(int $amountInCents, string $currency): object
{
// Stripe работает с центами!
return (object) [
'id' => 'ch_' . uniqid(),
'status' => 'succeeded',
'amount' => $amountInCents,
];
}
public function createRefund(string $chargeId): object
{
return (object) ['status' => 'succeeded'];
}
}
// Сторонний PayPal SDK (другой интерфейс!)
class PayPalSDK
{
public function makePayment(float $amount, string $currencyCode): array
{
// PayPal работает с долларами и массивами
return [
'payment_id' => 'PAY-' . uniqid(),
'state' => 'approved',
];
}
public function refundPayment(string $paymentId): array
{
return ['state' => 'completed'];
}
}
// Адаптер для Stripe
class StripeAdapter implements PaymentGatewayInterface
{
public function __construct(
private StripeSDK $stripe,
) {}
public function charge(float $amount, string $currency): PaymentResult
{
// Конвертируем доллары в центы для Stripe
$amountInCents = (int) ($amount * 100);
try {
$result = $this->stripe->createCharge(
$amountInCents,
strtolower($currency)
);
return new PaymentResult(
success: $result->status === 'succeeded',
transactionId: $result->id,
);
} catch (Exception $e) {
return new PaymentResult(
success: false,
transactionId: '',
errorMessage: $e->getMessage(),
);
}
}
public function refund(string $transactionId): bool
{
$result = $this->stripe->createRefund($transactionId);
return $result->status === 'succeeded';
}
}
// Адаптер для PayPal
class PayPalAdapter implements PaymentGatewayInterface
{
public function __construct(
private PayPalSDK $paypal,
) {}
public function charge(float $amount, string $currency): PaymentResult
{
try {
$result = $this->paypal->makePayment(
$amount,
strtoupper($currency)
);
return new PaymentResult(
success: $result['state'] === 'approved',
transactionId: $result['payment_id'],
);
} catch (Exception $e) {
return new PaymentResult(
success: false,
transactionId: '',
errorMessage: $e->getMessage(),
);
}
}
public function refund(string $transactionId): bool
{
$result = $this->paypal->refundPayment($transactionId);
return $result['state'] === 'completed';
}
}
// Сервис работает с интерфейсом — не знает о реализации
class PaymentService
{
public function __construct(
private PaymentGatewayInterface $gateway,
) {}
public function processOrder(Order $order): PaymentResult
{
// Один и тот же код для любого шлюза!
return $this->gateway->charge(
$order->total,
$order->currency,
);
}
}
// Конфигурация в DI-контейнере
$gateway = match (config('payment.default')) {
'stripe' => new StripeAdapter(new StripeSDK()),
'paypal' => new PayPalAdapter(new PayPalSDK()),
default => throw new Exception('Unknown gateway'),
};
$paymentService = new PaymentService($gateway);
// Использование
$result = $paymentService->processOrder($order);
if ($result->success) {
echo "Оплата прошла! ID: {$result->transactionId}";
} else {
echo "Ошибка: {$result->errorMessage}";
}
🌍 Реальные примеры использования
🔥 Где встречается Adapter
// Ваш интерфейс логирования
interface LoggerInterface
{
public function log(string $level, string $message, array $context = []): void;
}
// Сторонний логгер с другим интерфейсом
class ThirdPartyLogger
{
public function writeLog(string $msg, int $severity): void
{
echo "[{$severity}] {$msg}\n";
}
}
// Адаптер
class ThirdPartyLoggerAdapter implements LoggerInterface
{
private const LEVEL_MAP = [
'debug' => 0,
'info' => 1,
'warning' => 2,
'error' => 3,
];
public function __construct(
private ThirdPartyLogger $logger,
) {}
public function log(string $level, string $message, array $context = []): void
{
// Преобразуем формат
$severity = self::LEVEL_MAP[$level] ?? 1;
$formattedMessage = $this->formatMessage($message, $context);
$this->logger->writeLog($formattedMessage, $severity);
}
private function formatMessage(string $message, array $context): string
{
foreach ($context as $key => $value) {
$message = str_replace("{{$key}}", $value, $message);
}
return $message;
}
}
- Изолирует бизнес-логику от внешних зависимостей
- Легко заменять сторонние сервисы
- Упрощает тестирование (можно подставить мок)
- Следует принципу инверсии зависимостей (DIP)
Builder — строитель
Builder
Порождающий паттерн🎯 Когда использовать
❌ Проблема без Builder
Представьте класс с 10+ параметрами в конструкторе. Половина из них опциональна. Код создания объекта становится нечитаемым.
class Email
{
public function __construct(
private string $to,
private string $subject,
private string $body,
private ?string $from = null,
private ?string $replyTo = null,
private array $cc = [],
private array $bcc = [],
private array $attachments = [],
private array $headers = [],
private ?string $htmlBody = null,
private int $priority = 3,
private bool $trackOpens = false,
private bool $trackClicks = false,
) {}
}
// 💥 Создание объекта — кошмар!
$email = new Email(
'user@test.com',
'Hello',
'Body text',
'noreply@site.com',
null, // replyTo — что это?
['manager@site.com'],
[], // bcc — пустой
[], // attachments — пустой
[], // headers — пустой
'<h1>Hello</h1>',
1, // priority — что значит 1?
true,
false, // 🤯 Какой параметр это?
);
✅ Решение с Builder
Примеры кода
// Immutable объект Email
readonly class Email
{
public function __construct(
public string $to,
public string $subject,
public string $body,
public ?string $from = null,
public ?string $replyTo = null,
public array $cc = [],
public array $bcc = [],
public array $attachments = [],
public array $headers = [],
public ?string $htmlBody = null,
public int $priority = 3,
public bool $trackOpens = false,
public bool $trackClicks = false,
) {}
// Статический метод для получения Builder
public static function builder(): EmailBuilder
{
return new EmailBuilder();
}
}
// Builder
class EmailBuilder
{
private string $to;
private string $subject;
private string $body;
private ?string $from = null;
private ?string $replyTo = null;
private array $cc = [];
private array $bcc = [];
private array $attachments = [];
private array $headers = [];
private ?string $htmlBody = null;
private int $priority = 3;
private bool $trackOpens = false;
private bool $trackClicks = false;
public function to(string $to): self
{
$this->to = $to;
return $this;
}
public function subject(string $subject): self
{
$this->subject = $subject;
return $this;
}
public function body(string $body): self
{
$this->body = $body;
return $this;
}
public function from(string $from): self
{
$this->from = $from;
return $this;
}
public function replyTo(string $replyTo): self
{
$this->replyTo = $replyTo;
return $this;
}
public function cc(string ...$emails): self
{
$this->cc = $emails;
return $this;
}
public function bcc(string ...$emails): self
{
$this->bcc = $emails;
return $this;
}
public function attach(string $path, ?string $name = null): self
{
$this->attachments[] = ['path' => $path, 'name' => $name];
return $this;
}
public function header(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
public function html(string $html): self
{
$this->htmlBody = $html;
return $this;
}
public function highPriority(): self
{
$this->priority = 1;
return $this;
}
public function lowPriority(): self
{
$this->priority = 5;
return $this;
}
public function withTracking(): self
{
$this->trackOpens = true;
$this->trackClicks = true;
return $this;
}
public function build(): Email
{
// Валидация перед созданием
$this->validate();
return new Email(
to: $this->to,
subject: $this->subject,
body: $this->body,
from: $this->from,
replyTo: $this->replyTo,
cc: $this->cc,
bcc: $this->bcc,
attachments: $this->attachments,
headers: $this->headers,
htmlBody: $this->htmlBody,
priority: $this->priority,
trackOpens: $this->trackOpens,
trackClicks: $this->trackClicks,
);
}
private function validate(): void
{
if (!isset($this->to) || !filter_var($this->to, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Valid "to" email is required');
}
if (!isset($this->subject) || empty($this->subject)) {
throw new InvalidArgumentException('Subject is required');
}
if (!isset($this->body) || empty($this->body)) {
throw new InvalidArgumentException('Body is required');
}
}
}
// Простое письмо — читаемо и понятно!
$email = Email::builder()
->to('user@test.com')
->subject('Welcome!')
->body('Thank you for registration.')
->build();
// Сложное письмо с вложениями и трекингом
$email = Email::builder()
->to('client@company.com')
->from('sales@oursite.com')
->replyTo('support@oursite.com')
->subject('Your Invoice #12345')
->body('Please find your invoice attached.')
->html('<h1>Invoice #12345</h1><p>See attachment.</p>')
->cc('manager@company.com', 'accountant@company.com')
->attach('/invoices/12345.pdf', 'Invoice.pdf')
->header('X-Campaign-ID', 'invoice-2024')
->highPriority()
->withTracking()
->build();
// Каждый шаг понятен без комментариев!
Пример: Query Builder
class QueryBuilder
{
private string $table;
private array $select = ['*'];
private array $where = [];
private array $orderBy = [];
private ?int $limit = null;
private ?int $offset = null;
private array $joins = [];
private array $bindings = [];
public function table(string $table): self
{
$this->table = $table;
return $this;
}
public function select(string ...$columns): self
{
$this->select = $columns;
return $this;
}
public function where(string $column, mixed $value, string $operator = '='): self
{
$this->where[] = [$column, $operator, '?'];
$this->bindings[] = $value;
return $this;
}
public function whereIn(string $column, array $values): self
{
$placeholders = implode(', ', array_fill(0, count($values), '?'));
$this->where[] = [$column, 'IN', "({$placeholders})"];
$this->bindings = array_merge($this->bindings, $values);
return $this;
}
public function join(string $table, string $first, string $second): self
{
$this->joins[] = "JOIN {$table} ON {$first} = {$second}";
return $this;
}
public function leftJoin(string $table, string $first, string $second): self
{
$this->joins[] = "LEFT JOIN {$table} ON {$first} = {$second}";
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): self
{
$this->orderBy[] = "{$column} {$direction}";
return $this;
}
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
public function offset(int $offset): self
{
$this->offset = $offset;
return $this;
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->select);
$sql .= ' FROM ' . $this->table;
if (!empty($this->joins)) {
$sql .= ' ' . implode(' ', $this->joins);
}
if (!empty($this->where)) {
$conditions = array_map(
fn($w) => "{$w[0]} {$w[1]} {$w[2]}",
$this->where
);
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
if (!empty($this->orderBy)) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= ' LIMIT ' . $this->limit;
}
if ($this->offset !== null) {
$sql .= ' OFFSET ' . $this->offset;
}
return $sql;
}
public function getBindings(): array
{
return $this->bindings;
}
}
// Использование
$query = (new QueryBuilder())
->table('users')
->select('users.id', 'users.name', 'orders.total')
->leftJoin('orders', 'users.id', 'orders.user_id')
->where('users.status', 'active')
->where('users.age', 18, '>=')
->whereIn('users.role', ['admin',->whereIn('users.role', ['admin', 'moderator', 'editor'])
->orderBy('users.created_at', 'DESC')
->limit(20)
->offset(0);
echo $query->toSql();
// SELECT users.id, users.name, orders.total
// FROM users
// LEFT JOIN orders ON users.id = orders.user_id
// WHERE users.status = ? AND users.age >= ? AND users.role IN (?, ?, ?)
// ORDER BY users.created_at DESC
// LIMIT 20 OFFSET 0
print_r($query->getBindings());
// ['active', 18, 'admin', 'moderator', 'editor']
🌍 Реальные примеры использования
🔥 Где встречается Builder
class HttpRequestBuilder
{
private string $method = 'GET';
private string $url;
private array $headers = [];
private array $query = [];
private mixed $body = null;
private int $timeout = 30;
private bool $verifySSL = true;
private ?string $bearerToken = null;
public static function create(): self
{
return new self();
}
public function get(string $url): self
{
$this->method = 'GET';
$this->url = $url;
return $this;
}
public function post(string $url): self
{
$this->method = 'POST';
$this->url = $url;
return $this;
}
public function put(string $url): self
{
$this->method = 'PUT';
$this->url = $url;
return $this;
}
public function delete(string $url): self
{
$this->method = 'DELETE';
$this->url = $url;
return $this;
}
public function withHeader(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
public function withBearerToken(string $token): self
{
$this->bearerToken = $token;
return $this;
}
public function withQuery(array $params): self
{
$this->query = array_merge($this->query, $params);
return $this;
}
public function withJson(array $data): self
{
$this->body = json_encode($data);
$this->headers['Content-Type'] = 'application/json';
return $this;
}
public function withFormData(array $data): self
{
$this->body = http_build_query($data);
$this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
return $this;
}
public function timeout(int $seconds): self
{
$this->timeout = $seconds;
return $this;
}
public function withoutSSLVerification(): self
{
$this->verifySSL = false;
return $this;
}
public function send(): HttpResponse
{
// Подготовка заголовков
$headers = $this->headers;
if ($this->bearerToken) {
$headers['Authorization'] = 'Bearer ' . $this->bearerToken;
}
// Подготовка URL с query параметрами
$url = $this->url;
if (!empty($this->query)) {
$url .= '?' . http_build_query($this->query);
}
// Здесь логика отправки запроса (cURL, Guzzle и т.д.)
return new HttpResponse(/* ... */);
}
}
// Использование — очень читаемо!
$response = HttpRequestBuilder::create()
->post('https://api.example.com/users')
->withBearerToken($token)
->withHeader('X-Request-ID', uniqid())
->withJson([
'name' => 'John Doe',
'email' => 'john@example.com',
])
->timeout(10)
->send();
// GET запрос с параметрами
$response = HttpRequestBuilder::create()
->get('https://api.example.com/search')
->withQuery([
'q' => 'php patterns',
'page' => 1,
'limit' => 20,
])
->withBearerToken($token)
->send();
- Читаемый код — каждый шаг понятен
- Опциональные параметры не требуют null
- Валидация при сборке (fail fast)
- Можно создавать разные конфигурации
- IDE подсказывает доступные методы
Сравнение и выводы
Когда какой паттерн использовать
🎀 Decorator
Добавить поведение к существующему объекту, не меняя его код. Комбинировать поведения.
🔌 Adapter
Использовать класс с несовместимым интерфейсом. Интеграция со сторонними сервисами.
🏗️ Builder
Создать сложный объект пошагово. Много опциональных параметров.
| Критерий | Decorator | Adapter | Builder |
|---|---|---|---|
| Тип паттерна | Структурный | Структурный | Порождающий |
| Основная цель | Добавить функционал | Совместить интерфейсы | Создать объект |
| Когда применять | Нужно расширить поведение | Интеграция с внешним API | Сложный конструктор |
| Пример в жизни | Middleware, кэш-обёртка | Платёжные шлюзы | Query Builder, Email |
| Сохраняет интерфейс | ✅ Да | ❌ Преобразует | ➖ Создаёт новый объект |
| Сложность | Средняя | Низкая | Средняя |
🎯 Ключевые выводы
Decorator
Оборачиваем объект в «обёртки» для добавления новых возможностей без изменения исходного кода
Adapter
Создаём «переходник» между несовместимыми интерфейсами для бесшовной интеграции
Builder
Строим сложные объекты пошагово с fluent interface и валидацией
- Decorator: Используйте для middleware, кэширования, логирования, добавления функций «на лету»
- Adapter: Всегда оборачивайте сторонние библиотеки — это упростит их замену и тестирование
- Builder: Если конструктор имеет больше 3-4 параметров — рассмотрите Builder
- Паттерны можно комбинировать: Builder может создавать объекты, которые потом оборачиваются Decorator'ами
Что изучить дальше
- Facade — упрощённый интерфейс к сложной подсистеме
- Proxy — заместитель объекта для контроля доступа
- Composite — древовидная структура объектов
- Abstract Factory — семейства связанных объектов
- Prototype — клонирование объектов