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-проектах и бэкенд-разработке.
Паттерн - это словарное слово
Главная ценность паттерна - общий язык. Когда ты говоришь коллеге «тут нужен адаптер», он сразу понимает идею. Не нужно рисовать диаграммы и объяснять полчаса.
Как понять, что паттерн нужен
Паттерн не применяют «на всякий случай». Есть конкретные сигналы - слева запах в коде, справа паттерн, которым его обычно лечат:
Паттерны в 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 паттерна:
- «Program to an interface, not an implementation» - программируй к контракту, а не к конкретной реализации.
- «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 хранит детей того же типа |
Поэтому, прежде чем учить конкретный паттерн, держи в голове: они почти все - «вынеси то, что меняется, в отдельный объект, и держи на него ссылку». Это и есть композиция.
Паттерны 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.Context | Chain of Resp. | отмена/deadline пробрасывается по дереву вызовов |
bufio.NewReader(r) | Decorator | оборачивает Reader буферизацией |
io.MultiWriter(a, b) | Composite | несколько Writer как один |
template.Template | Template Method | алгоритм рендера фиксирован, данные подставляются |
errors.Is / errors.As | Iterator | обход цепочки wrapped errors |
Когда паттерны вредят
Паттерны - инструмент, а не цель. Вот типичные ошибки:
Overengineering - фабрика для одного типа, декоратор для одного слоя, Observer с одним подписчиком. Если сценарий не растёт - паттерн лишний.
Pattern-driven development - когда разработчик выбирает паттерн, а потом подгоняет задачу под него. Правильный порядок обратный: задача → проблема → паттерн.
Преждевременная абстракция - три одинаковых if в коде ещё не повод для Strategy. Дождись третьего-четвёртого случая: «Rule of Three» работает и здесь.
Антипаттерн «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 делают это автоматически - но без избыточных «фабрик фабрик».
Как мы будем изучать
Каждый урок этого трека - один паттерн:
- Проблема - какой код «болит» без паттерна
- Решение - как паттерн это исправляет (Go + PHP примеры)
- Когда НЕ использовать - границы применимости
- Связь с другими паттернами - чем отличается от похожих
- PHP - Наследование, abstract и интерфейсы - фундамент GoF паттернов в PHP: abstract class, interface, LSP и DIP на практике
Мини-задание
- Найди в проекте один большой
if/switchпо типам - Подумай, какой паттерн мог бы его убрать (часто Strategy или Factory)
- Вспомни последний раз, когда добавление фичи потребовало правок в 5+ файлах - какой паттерн мог бы помочь?
- Открой Go stdlib (
net/http,io,sort) и найди пример интерфейса, за которым скрывается паттерн