GoF паттерны без боли: как не выучить 23 штуки зря

GoF паттерны без боли: как не выучить 23 штуки зря

В 1994 году четыре автора - Erich Gamma, Richard Helm, Ralph Johnson и John Vlissides - выпустили книгу «Design Patterns: Elements of Reusable Object-Oriented Software». Их прозвали Gang of Four (GoF), а книга стала классикой разработки.

В книге 23 паттерна. Но начинающему разработчику не нужно учить все - достаточно 6-8, которые встречаются в реальных проектах каждый день.

Три категории паттернов

GoF разделили паттерны на три группы. На каждой схеме насыщенные точки - паттерны, которые мы разберём в курсе; полупрозрачные - упомянуты для полноты картины.

Отвечают за то, **как** создаются объекты: скрывают конструкторы за фабриками, гарантируют единственность экземпляра, собирают сложные объекты пошагово.

Когда применять: конструктор разрастается, в коде много if/switch по типу при создании, нужно подменять реализации в зависимости от конфига или окружения.

В курсе разберём Singleton, Factory Method и Builder - три самых частых в Go и PHP.

Описывают, **как** объекты собираются в большие структуры: оборачивают, скрывают за фасадом, подменяют прокси - всё ради того, чтобы не трогать остальной код при расширении.

Когда применять: чужой SDK с неудобным интерфейсом, нужно навесить кэш/логи вокруг сервиса, клиент дёргает 5 методов вместо одного.

Разберём Adapter, Decorator, Facade и Proxy.

Регулируют, **как** объекты передают друг другу сообщения и распределяют обязанности: команды, события, стратегии, цепочки обработчиков.

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

Разберём Command, Observer и Strategy.

Из 23 мы возьмём 7 - те, которые чаще всего встречаются в Go-проектах и бэкенд-разработке.

Паттерн - это словарное слово

Главная ценность паттерна - общий язык. Когда ты говоришь коллеге «тут нужен адаптер», он сразу понимает идею. Не нужно рисовать диаграммы и объяснять полчаса.

Паттерн как словарное слово: длинное описание заменяется одним термином

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

Как понять, что паттерн нужен

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

Сигналы в коде и соответствующие паттерны GoF

Паттерны в Go и PHP: без классов, но с интерфейсами

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

// Интерфейс + структура - проще и гибче, чем абстрактный класс
type Logger interface {
    Log(msg string)
}

type ConsoleLogger struct{}
func (ConsoleLogger) Log(msg string) { fmt.Println(msg) }

type FileLogger struct{ path string }
func (l FileLogger) Log(msg string) {
    os.WriteFile(l.path, []byte(msg), 0644)
}

В Go интерфейс реализуется неявно - не нужно писать implements. Если структура имеет нужные методы - она уже реализует интерфейс. Это делает паттерны легковеснее.

interface Logger
{
    public function log(string $msg): void;
}

final class ConsoleLogger implements Logger
{
    public function log(string $msg): void
    {
        echo $msg, PHP_EOL;
    }
}

final class FileLogger implements Logger
{
    public function __construct(
        private readonly string $path,
    ) {}

    public function log(string $msg): void
    {
        file_put_contents($this->path, $msg . "\n", FILE_APPEND);
    }
}

В PHP интерфейс реализуется явно через implements - компилятор контролирует контракт. В отличие от Go, где интерфейсы неявные (тип удовлетворяет интерфейсу автоматически, если у него есть нужные методы) - в PHP контракт декларируется в сигнатуре класса. Для большинства паттернов хватает интерфейса + final-классов + DI через конструктор. Symfony и Laravel опираются на эту модель.

// В JS нет интерфейсов - есть duck-typing: «есть метод log() - значит это Logger»
class ConsoleLogger {
  log(msg) {
    console.log(msg);
  }
}

class FileLogger {
  #path;
  constructor(path) {
    this.#path = path;
  }
  log(msg) {
    const fs = require('node:fs');
    fs.appendFileSync(this.#path, msg + '\n');
  }
}

function useLogger(logger) {
  logger.log('hello');
}

useLogger(new ConsoleLogger());
useLogger(new FileLogger('/tmp/app.log'));

В JS контракт неформальный - как в Go (по факту наличия методов), но без проверки компилятора. JSDoc-аннотации (@implements) или TypeScript-interface нужны, если хочется проверки на этапе сборки. Приватные поля через # появились в ES2022 и нужны, когда состояние не должно утечь наружу.

Композиция вместо наследования: главный мета-принцип GoF

Сама книга GoF выводит два базовых правила объектно-ориентированного дизайна, на которых стоят все 23 паттерна:

  1. «Program to an interface, not an implementation» - программируй к контракту, а не к конкретной реализации.
  2. «Favor object composition over class inheritance» - предпочитай композицию объектов наследованию классов.

Вторая идея - фундамент. Большинство «структурных» и «поведенческих» паттернов (Strategy, Decorator, Adapter, Bridge, Composite, Command, State) - это конкретные способы заменить наследование композицией.

Проблема глубоких иерархий

Классический путь в ад на любом ООП-языке:

Глубокая иерархия классов: новой роли некуда упасть

Иерархия зафиксирована на этапе компиляции. Появилась новая роль («читатель + редактор», «премиум + временный») - её некуда положить. Конкретный класс может быть только в одной точке дерева.

Композиция:

type User struct {
    Email        string
    Subscription Subscription // Free / Premium
    Roles        []Role       // [Reader, Editor, Admin]
}

// «Премиум-админ» - это просто значение
u := User{
    Email:        "a@b.c",
    Subscription: Premium{},
    Roles:        []Role{Admin{}},
}
<?php
declare(strict_types=1);

final readonly class User
{
    /** @param list<Role> $roles */
    public function __construct(
        public string $email,
        public Subscription $subscription, // Free / Premium
        public array $roles,               // [Reader, Editor, Admin]
    ) {}
}

// «Премиум-админ» - это просто значение
$user = new User('a@b.c', new Premium(), [new Admin()]);
class User {
  constructor({ email, subscription, roles }) {
    this.email = email;
    this.subscription = subscription; // Free / Premium
    this.roles = roles;               // [Reader, Editor, Admin]
  }
}

// «Премиум-админ» - это просто значение
const user = new User({
  email: 'a@b.c',
  subscription: new Premium(),
  roles: [new Admin()],
});

«Премиум-админ» - это любая комбинация полей. Никаких новых классов.

Пять причин предпочитать композицию

1. Liskov Substitution Principle (LSP) - подкласс обязан быть совместимой заменой родителя. Это требование жёсткое: подкласс не может сужать тип возврата, расширять список выбрасываемых исключений, нарушать постусловия родителя. На практике сохранять LSP по всему дереву - ад. Композиция требований не предъявляет.

2. Fragile Base Class - изменение в родителе ломает наследников. Добавил поле, переименовал protected-метод, поменял внутренний вызов - и пятеро потомков в проде сломались. У композиции «родителя» нет: класс пользуется зависимостью только через её публичный контракт.

3. Hidden coupling - наследник связан с родителем неявно через protected-API, перекрытие методов, порядок вызовов. Это «скрытая связь», которая не видна по сигнатурам. Композиция делает связь явной: зависимость в конструкторе видна каждому.

4. Отсутствие множественного наследования. PHP не поддерживает extends A, B. Нужно поведение из двух источников - есть только композиция (или trait как обходной механизм, но без полноценной типизации). В Go вообще нет наследования классов - только композиция.

5. Open/Closed Principle (OCP). При композиции расширить поведение можно, не трогая существующий класс: подменил зависимость на новую реализацию интерфейса - и готово. При наследовании ради расширения часто нужно править родителя (protected-крючки, hook-методы) - это нарушает OCP.

Когда наследование всё-таки уместно

Не «никогда», а «по делу»:

  • Шаблонный метод (abstract + final методы) - родитель фиксирует скелет алгоритма, наследник заполняет шаги. Если шагов мало и API стабильно - окей.
  • Истинное is-a - class Square extends Shape валидно: квадрат является фигурой, заменяет её везде, LSP не нарушается.
  • Расширение чужого фреймворка, который ждёт именно extends BaseController - здесь альтернативы часто нет.

Сигналы, что наследование не к месту:

  • Глубина иерархии 3+ уровня
  • Наследник переопределяет половину родительских методов
  • В наследниках встречаются if ($this instanceof X) или throw new \LogicException('not supported')
  • Хочется унаследоваться сразу от двух баз

Композиция как реализация паттернов

Большинство паттернов из этого трека - это композиция в чистом виде:

ПаттернЧто внутри - наследование или композиция?
Strategyкомпозиция: контекст хранит ссылку на Strategy
Decoratorкомпозиция: декоратор оборачивает Component через поле
Adapterкомпозиция (Object Adapter): адаптер хранит адаптируемое
Commandкомпозиция: Invoker держит Command, Command - Receiver
Observerкомпозиция: Subject хранит список Observer'ов
Bridgeкомпозиция: Abstraction делегирует Implementor'у
Compositeкомпозиция: Composite хранит детей того же типа

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

«Favor composition over inheritance» работает в паре с **[Dependency Inversion](../solid/06-dip.md)** из [SOLID](../solid/01-intro.md): класс зависит от абстракции (интерфейса) и получает её через конструктор. Эта пара - `composition + DI` - фундамент Clean Architecture и Hexagonal Architecture.

Паттерны GoF в stdlib и фреймворках

Авторы Go встроили классические паттерны в стандартную библиотеку - обычно так глубоко, что их не замечают. В PHP-мире та же роль у PSR-стандартов и Symfony. Когда узнаёшь - понимаешь, что уже пользуешься ими каждый день.

Потоки чтения - Strategy

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }

// Один и тот же json.Decoder работает с любой стратегией чтения
json.NewDecoder(os.Stdin).Decode(&v)              // из stdin
json.NewDecoder(file).Decode(&v)                  // из файла
json.NewDecoder(http.Response.Body).Decode(&v)    // из HTTP-ответа
json.NewDecoder(strings.NewReader(s)).Decode(&v)  // из строки
<?php
declare(strict_types=1);

use Psr\Http\Message\StreamInterface;

// PSR-7 StreamInterface - тот же приём: один интерфейс над разными источниками
function decode(StreamInterface $stream): array
{
    /** @var array<string, mixed> $data */
    $data = json_decode((string) $stream, associative: true, flags: JSON_THROW_ON_ERROR);

    return $data;
}

decode($psrRequest->getBody());          // из HTTP-запроса
decode(Utils::streamFor($fileResource)); // из файлового ресурса
decode(Utils::streamFor($jsonString));   // из строки
// В Node.js Web Streams API играет ту же роль - единый ReadableStream
// над файлом, HTTP-телом, in-memory буфером
async function decode(stream) {
  const text = await new Response(stream).text();
  return JSON.parse(text);
}

await decode(request.body);                                // из HTTP-запроса
await decode(fs.createReadStream('data.json'));            // из файла
await decode(new Blob([jsonString]).stream());             // из строки

Любая функция, принимающая io.Reader (Go) или StreamInterface (PHP/PSR-7), работает с любым источником: файлом, HTTP-телом, сетевым сокетом, gzip-потоком, in-memory буфером. Алгоритм декодирования не знает, что у него за источник - это и есть Strategy на уровне интерфейса в один-два метода.

HTTP Handler - Adapter

type Handler interface { ServeHTTP(w ResponseWriter, r *Request) }

// HandlerFunc - адаптер, превращающий обычную функцию в Handler
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))
})
// HandleFunc внутри делает: handler := HandlerFunc(f); router.Register(handler)
<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

// PSR-15 RequestHandlerInterface - интерфейс с одним методом handle()
// Адаптер превращает обычное замыкание в RequestHandlerInterface
final class CallableHandler implements RequestHandlerInterface
{
    /** @param callable(ServerRequestInterface): ResponseInterface $fn */
    public function __construct(
        private $fn,
    ) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return ($this->fn)($request);
    }
}

$router->add('/hello', new CallableHandler(
    fn (ServerRequestInterface $r) => $responses->text('hello'),
));
// В Express маршрут принимает callback (req, res) - это и есть адаптер
// функции под обработчик. Класс с методом handle() оборачивается так же:
app.get('/hello', (req, res) => {
  res.send('hello');
});

class HelloHandler {
  handle(req, res) { res.send('hello'); }
}

const handler = new HelloHandler();
app.get('/hello', (req, res) => handler.handle(req, res));

Сервер ждёт интерфейс с одним методом, а у тебя обычная функция той же сигнатуры - обёртка соединяет их. В Go это http.HandlerFunc, в PHP - invokable-класс или адаптер вроде показанного выше.

Middleware-цепочки - Decorator

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("req", "path", r.URL.Path, "took", time.Since(start))
    })
}

handler = recovery(logging(auth(myHandler)))
<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

final class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $t = microtime(true);
        try {
            return $handler->handle($request);
        } finally {
            $ms = (int) ((microtime(true) - $t) * 1000);
            $this->logger->info(sprintf('%s %d ms', $request->getUri(), $ms));
        }
    }
}

// Стек = recovery + logging + auth + handler
$pipeline = new Pipeline([$recovery, $logging, $auth], $myHandler);
// Express: middleware = (req, res, next) => { ... next(); }
function logging(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.url} ${Date.now() - start} ms`);
  });
  next();
}

// Стек = recovery + logging + auth + handler
app.use(recovery);
app.use(logging);
app.use(auth);
app.get('/me', myHandler);

Каждое middleware «оборачивает» handler, добавляя поведение (логирование, recovery, auth) без изменения внутреннего обработчика - это классический Decorator.

Ленивая инициализация - Singleton без боли

var (
    once sync.Once
    db   *sql.DB
)

func GetDB() *sql.DB {
    once.Do(func() {
        db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
    })
    return db
}
<?php
declare(strict_types=1);

// В Symfony DI-контейнер сам делает сервис лениво-singleton-ом.
// Без контейнера - простой кэш на статическом поле фабрики.
final class DbFactory
{
    private static ?PDO $db = null;

    public static function get(): PDO
    {
        return self::$db ??= new PDO(getenv('DATABASE_URL'));
    }
}

$pdo = DbFactory::get();
// Идиома Node.js: ленивый кэш модульного уровня. Модуль грузится один раз,
// поэтому переменная по сути singleton.
let db = null;

export function getDb() {
  return db ??= new Pool({ connectionString: process.env.DATABASE_URL });
}

Singleton как «глобальная переменная с приватным конструктором» - классический boilerplate из языков без встроенных DI-контейнеров. В Go вместо паттерна - стандартный примитив sync.Once: thread-safe, ленивый, без двойной проверки. В PHP - контейнер из Symfony/Laravel, который сам управляет жизненным циклом сервисов. В большинстве случаев передача зависимости через параметр конструктора лучше Singleton-а - но если уж нужен - sync.Once или DI-контейнер.

Сортировка - Strategy + Template Method

type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}
func Sort(data Interface)        // алгоритм фиксирован, шаги переопределяются
<?php
declare(strict_types=1);

// usort - алгоритм сортировки фиксирован, стратегия сравнения передаётся колбэком
$users = [/* ... */];

usort($users, static fn (User $a, User $b): int => $a->createdAt <=> $b->createdAt);
// Array.prototype.sort - алгоритм фиксирован (стабильный TimSort с ES2019),
// стратегия сравнения передаётся колбэком
const users = [/* ... */];

users.sort((a, b) => a.createdAt - b.createdAt);

Алгоритм сортировки (pdqsort в Go, в PHP - стабильный Timsort с 8.0) фиксирован - это Template Method. А реализация сравнения - твоя «стратегия». С Go 1.21 появился slices.SortFunc - более удобная generic-альтернатива, но идея та же.

Другие явные паттерны в stdlib

stdlibПаттернЧто делает
context.ContextChain of Resp.отмена/deadline пробрасывается по дереву вызовов
bufio.NewReader(r)Decoratorоборачивает Reader буферизацией
io.MultiWriter(a, b)Compositeнесколько Writer как один
template.TemplateTemplate Methodалгоритм рендера фиксирован, данные подставляются
errors.Is / errors.AsIteratorобход цепочки wrapped errors

Когда паттерны вредят

Паттерны - инструмент, а не цель. Вот типичные ошибки:

Overengineering - фабрика для одного типа, декоратор для одного слоя, Observer с одним подписчиком. Если сценарий не растёт - паттерн лишний.

Pattern-driven development - когда разработчик выбирает паттерн, а потом подгоняет задачу под него. Правильный порядок обратный: задача → проблема → паттерн.

Преждевременная абстракция - три одинаковых if в коде ещё не повод для Strategy. Дождись третьего-четвёртого случая: «Rule of Three» работает и здесь.

Когда у тебя есть молоток, всё кажется гвоздём. Если ты только выучил Factory - не надо оборачивать каждое создание объекта в фабрику. Применяй паттерн когда он решает конкретную проблему.

Антипаттерн «Factory factory»

Классический симптом перебора, который иногда тянут в Go и PHP: фабрика, которая создаёт фабрику, которая создаёт объект.

// Over-engineering
type ServiceFactoryFactory interface {
    NewServiceFactory(env string) ServiceFactory
}
type ServiceFactory interface {
    NewService(cfg Config) Service
}
type Service interface {
    Do(ctx context.Context, req Req) (Resp, error)
}

// Чтобы получить Service:
ff := NewProdFactoryFactory()
f  := ff.NewServiceFactory("prod")
s  := f.NewService(cfg)
<?php
declare(strict_types=1);

// Over-engineering
interface ServiceFactoryFactory
{
    public function newServiceFactory(string $env): ServiceFactory;
}

interface ServiceFactory
{
    public function newService(Config $cfg): Service;
}

interface Service
{
    public function do(Request $req): Response;
}

// Чтобы получить Service:
$ff = new ProdFactoryFactory();
$f  = $ff->newServiceFactory('prod');
$s  = $f->newService($cfg);
// Over-engineering
class ProdFactoryFactory {
  newServiceFactory(env) { return new ProdServiceFactory(env); }
}
class ProdServiceFactory {
  newService(cfg) { return new Service(cfg); }
}

// Чтобы получить Service:
const ff = new ProdFactoryFactory();
const f  = ff.newServiceFactory('prod');
const s  = f.newService(cfg);
// В Go обычно достаточно конструктора-функции
func NewService(cfg Config) (*Service, error) {
    return &Service{cfg: cfg}, nil
}
s, err := NewService(cfg)
<?php
declare(strict_types=1);

// В PHP обычно достаточно конструктора + DI-контейнера (Symfony)
final class Service
{
    public function __construct(
        private readonly Config $cfg,
    ) {}
}

$service = new Service($cfg);
// Обычно достаточно конструктора + DI через параметры
class Service {
  #cfg;
  constructor(cfg) { this.#cfg = cfg; }
}

const service = new Service(cfg);

Признаки, что ты ушёл слишком далеко:

  • В коде больше слов Factory, Provider, Manager, Builder, Strategy чем доменных терминов.
  • Чтобы создать один объект, нужно пройти 3+ уровня абстракций.
  • Половина кода - «связь между интерфейсами», бизнес-логика теряется.

В Go идиома проще: конструктор-функция NewX(deps) *X + Dependency Injection через параметры. Wire или Fx делают это автоматически - но без избыточных «фабрик фабрик».

Как мы будем изучать

Каждый урок этого трека - один паттерн:

  1. Проблема - какой код «болит» без паттерна
  2. Решение - как паттерн это исправляет (Go + PHP примеры)
  3. Когда НЕ использовать - границы применимости
  4. Связь с другими паттернами - чем отличается от похожих

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

  • Найди в проекте один большой if/switch по типам
  • Подумай, какой паттерн мог бы его убрать (часто Strategy или Factory)
  • Вспомни последний раз, когда добавление фичи потребовало правок в 5+ файлах - какой паттерн мог бы помочь?
  • Открой Go stdlib (net/http, io, sort) и найди пример интерфейса, за которым скрывается паттерн

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