Фундамент программиста: system design, SOLID, GoF, функциональные паттерны и структуры данных
Этот материал — не конспект “для собеседования на два вечера”. Это карта фундамента, который помогает программисту проектировать системы, читать чужой код, выбирать структуры данных, не тащить паттерны ради паттернов и объяснять архитектурные решения так, чтобы команда могла их поддерживать.
Хороший инженер отличается не тем, что знает названия всех паттернов, а тем, что понимает силы, которые действуют на систему: нагрузка, изменяемость, отказоустойчивость, стоимость изменения, когнитивная сложность, границы ответственности, модель данных, конкурентность, latency, consistency, безопасность и человеческая способность не утонуть в собственном коде.
В этом посте мы соберем базу в одну систему:
- что такое system design и как думать о системах;
- как формулировать требования и ограничения;
- что дают SOLID-принципы и где они ломаются;
- все 23 GoF-паттерна: зачем нужны, когда применять, какие риски;
- функциональные паттерны разработки: композиция, чистые функции, Result/Either, пайплайны, редьюсеры, алгебраические типы;
- классические структуры данных и их сложность;
- иммутабельные и persistent structures: structural sharing, HAMT, persistent vector, copy-on-write;
- как выбирать решение без архитектурного театра;
- упражнения, чтобы превратить чтение в навык.
Как пользоваться этим материалом
Не надо читать все линейно за один заход. Лучше идти слоями.
Маршрут junior/middle:
- Прочитать разделы про system design mindset, SOLID, базовые структуры данных.
- Разобрать GoF на уровне “какую проблему решает”.
- Сделать упражнения: LRU cache, очередь задач, URL shortener.
Маршрут senior/lead:
- Читать через призму trade-off: latency vs consistency, coupling vs duplication, abstraction vs locality.
- Смотреть на паттерны как на язык обсуждения, а не как на каталог классов.
- Проработать system design чеклисты и ADR-шаблоны.
Маршрут frontend/fullstack:
- Особое внимание уделить состоянию, immutability, reducers, memoization, event-driven UI.
- Понять, что frontend тоже система: данные приходят асинхронно, состояние расходится, сеть падает, пользователь кликает быстрее API.
Маршрут backend/platform:
- Больше внимания storage, очередям, consistency, idempotency, retry, observability.
- На каждый паттерн задавать вопрос: “что будет при конкуренции, отказе, повторной доставке, миграции схемы?”
1. Что такое system design
System design — это дисциплина проектирования системы как набора компонентов, данных, контрактов, ограничений и процессов эксплуатации. Система — это не только код. Это API, база данных, кеш, очереди, CDN, права доступа, мониторинг, деплой, миграции, rollback, документация, люди, которые будут чинить инцидент ночью, и бизнес, который меняет требования через неделю после релиза.
Проектировать систему значит отвечать на вопросы:
- какую проблему решаем;
- для кого;
- какие операции самые частые;
- какие операции самые дорогие;
- что должно быть быстрым;
- что должно быть надежным;
- где допустима eventual consistency;
- какие данные являются источником истины;
- что будет, если зависимость недоступна;
- как мы узнаем, что система сломалась;
- как мы восстановимся;
- как мы изменим систему через полгода.
Главная ошибка новичка
Новичок часто начинает с технологий: “возьмем PostgreSQL, Redis, Kafka, Kubernetes”. Это не дизайн, это список инструментов.
Нормальный дизайн начинается с формы нагрузки и модели данных:
- сколько пользователей;
- сколько чтений;
- сколько записей;
- какие запросы должны быть realtime;
- какие данные можно пересчитать;
- какие данные нельзя потерять;
- какие данные можно кэшировать;
- какие данные требуют строгой транзакционности;
- где система должна деградировать красиво.
System design как цикл
System design не заканчивается диаграммой. Диаграмма — это снимок решения. Живой дизайн включает критерии изменения: когда мы должны шардировать, когда выносить очередь, когда менять storage, когда удалять старую абстракцию.
2. Требования: функциональные и нефункциональные
Функциональные требования описывают, что система делает:
- пользователь может зарегистрироваться;
- студент может открыть урок;
- заказ можно оплатить;
- администратор может выгрузить отчет;
- сервис отправляет уведомление.
Нефункциональные требования описывают, какой должна быть система:
- p95 latency меньше 200 мс;
- 99.9% availability;
- данные заказа нельзя потерять;
- поиск обновляется в течение 30 секунд;
- персональные данные хранятся в РФ;
- система выдерживает 1000 RPS чтения и 50 RPS записи;
- восстановление после сбоя занимает не больше 15 минут.
Нефункциональные требования часто важнее функциональных. Кнопка “купить” может быть одинаковой в двух проектах, но одна система должна пережить 100 заказов в месяц, а другая — 100 заказов в секунду. Код формы может быть похожим, архитектура будет разной.
Checklist требований
| Область | Вопросы |
|---|---|
| Пользователи | Кто использует систему? Какие роли? Какие сценарии критичны? |
| Нагрузка | Сколько чтений/записей? Есть ли пики? Есть ли сезонность? |
| Данные | Что является source of truth? Какие данные immutable? Какие можно пересчитать? |
| Consistency | Где нужна строгая консистентность? Где достаточно eventual consistency? |
| Latency | Какие операции должны быть быстрыми? Какие можно отправить в background? |
| Failure | Что происходит, если падает БД, очередь, платежка, email, внешний API? |
| Security | Кто имеет доступ? Какие данные чувствительные? Как логируем? |
| Compliance | 152-ФЗ, GDPR, audit trail, retention, удаление данных |
| Operations | Метрики, логи, алерты, rollback, backup, runbook |
3. Базовые строительные блоки system design
API gateway / edge
Edge-слой принимает запросы, завершает TLS, применяет rate limiting, маршрутизирует трафик, может делать auth pre-check, compression, caching, CORS, request id.
Риск: превращать gateway в “бизнес-логику на входе”. Gateway должен быть тонким. Если там начинают жить правила заказа, скидки и статусы пользователя, система становится тяжелой для тестирования и локальной разработки.
Application service
Application service содержит use cases: создать заказ, начать курс, отправить уведомление, пересчитать прогресс. Он координирует домен, storage, интеграции и события.
Хороший application service:
- не знает деталей HTTP;
- не зависит напрямую от конкретной БД, если это мешает тестированию;
- явно управляет транзакцией;
- возвращает понятный результат;
- не прячет ошибки в
console.log.
Database
База данных — не “просто место хранения”. Это модель консистентности, индексы, транзакции, блокировки, миграции, backup, restore, права доступа и стоимость запросов.
Типовой выбор:
- PostgreSQL/MySQL — транзакционные бизнес-данные;
- Redis — кеш, rate limit, короткоживущие ключи, pub/sub с осторожностью;
- Elasticsearch/OpenSearch — полнотекстовый поиск;
- ClickHouse — аналитика и события;
- S3-compatible storage — файлы и большие объекты;
- graph database — когда связи являются главной моделью, а не украшением.
Cache
Кеш ускоряет чтение и снижает нагрузку, но добавляет проблему invalidation.
Типы кеша:
- browser cache;
- CDN cache;
- application memory cache;
- Redis/memcached;
- database query cache;
- materialized views.
Вопросы к кешу:
- кто пишет;
- кто инвалидирует;
- какой TTL;
- что будет при stale data;
- можно ли показать старое значение;
- есть ли cache stampede;
- что произойдет при падении Redis.
Queue / stream
Очередь отделяет быстрый пользовательский запрос от тяжелой фоновой работы.
Примеры:
- отправить email после регистрации;
- обработать видео;
- пересчитать рекомендации;
- синхронизировать CRM;
- доставить webhook;
- записать audit event.
Очереди не бесплатны. Они требуют:
- idempotency;
- retry policy;
- dead-letter queue;
- ordering strategy;
- backpressure;
- monitoring lag;
- схемы событий и совместимости версий.
Search
Поиск почти всегда eventually consistent. Пользователь создал курс, но он появляется в поиске через несколько секунд. Это нормально, если продукт объясняет состояние и не ломает основной workflow.
Observability
Observability — способность понять, что происходит внутри системы, не деплоив новый debug-код.
Минимум:
- structured logs;
- metrics;
- traces;
- request id / correlation id;
- business metrics;
- error tracking;
- dashboards;
- alerts;
- runbooks.
Лог “something went wrong” — почти бесполезен. Лог с request_id, user_id, order_id, operation, duration_ms, status, error_code уже может спасти инцидент.
4. Consistency, availability и реальные компромиссы
В распределенных системах нельзя одновременно идеально получить все: строгую консистентность, доступность при сетевых разделениях и отсутствие сложностей. Но на практике важнее не повторять CAP как мантру, а понимать конкретные компромиссы.
Strong consistency: после записи все читают новое значение. Нужно для денег, заказов, прав доступа, лимитов.
Eventual consistency: система постепенно сходится. Подходит для поиска, аналитики, counters, feed, рекомендаций, read models.
Read your writes: пользователь после своего действия видит свой результат, даже если другие увидят позже.
Monotonic reads: пользователь не должен видеть данные “назад во времени”.
Пример: покупка курса
Нельзя eventual consistency для факта оплаты внутри заказа: если деньги списались, доступ должен выдаться надежно. Но можно eventual consistency для аналитики продаж, письма “спасибо за покупку”, обновления витрины популярных курсов.
Важные вопросы:
- webhook может прийти два раза;
- webhook может прийти раньше redirect пользователя;
- provider может быть недоступен;
- пользователь может закрыть страницу;
- email может не отправиться;
- доступ должен выдаться один раз;
- повторная обработка
OrderPaidне должна создать дубль.
5. SOLID: что это и зачем
SOLID — набор принципов объектно-ориентированного дизайна. Они не гарантируют хороший код, но дают язык для обсуждения связности, расширяемости и подстановки.
SOLID особенно полезен, когда код растет, появляются новые сценарии, несколько команд работают в одной области, а изменения начинают ломать соседние части системы.
S — Single Responsibility Principle
У модуля должна быть одна причина для изменения. Это не значит “один класс делает одну микроскопическую операцию”. Это значит, что бизнес-правила, форматирование ответа, доступ к БД и отправка email не должны жить в одной куче.
Плохой признак:
OrderServiceвалидирует HTTP request;- считает скидки;
- пишет SQL;
- отправляет email;
- логирует analytics;
- форматирует JSON для фронта.
Лучше разделить:
- controller/handler;
- application use case;
- domain policy;
- repository;
- notification port;
- presenter/DTO mapper.
O — Open/Closed Principle
Код должен быть открыт для расширения и закрыт для изменения. На практике это значит: новый тип поведения не должен заставлять править длинный switch в десяти местах.
Пример: способы доставки уведомлений.
interface NotificationChannel {
send(message: Message): Promise<Result<void, SendError>>;
}
class EmailChannel implements NotificationChannel { /* ... */ }
class TelegramChannel implements NotificationChannel { /* ... */ }
class SmsChannel implements NotificationChannel { /* ... */ }
class NotificationService {
constructor(private readonly channels: NotificationChannel[]) {}
async broadcast(message: Message) {
return Promise.all(this.channels.map((channel) => channel.send(message)));
}
}
Но OCP не означает, что каждую if надо заменять на Strategy. Если вариантов два, они стабильны и локальны, обычный if лучше абстрактного зоопарка.
L — Liskov Substitution Principle
Подтип должен подставляться вместо базового типа без сюрпризов. Если функция ожидает Repository, любой конкретный repository должен соблюдать контракт.
Нарушения:
- метод базового типа обещает сохранять, а наследник иногда молча игнорирует;
- базовый тип разрешает любое значение, наследник падает на части значений;
- метод возвращает
User, а наследник возвращаетnull, хотя контракт этого не обещал; - наследник усиливает preconditions или ослабляет postconditions.
LSP — это не про наследование как синтаксис. Это про поведенческий контракт.
I — Interface Segregation Principle
Клиенты не должны зависеть от методов, которые им не нужны.
Плохой интерфейс:
interface UserStorage {
find(id: string): Promise<User>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
exportCsv(): Promise<string>;
rebuildSearchIndex(): Promise<void>;
}
Если use case только читает пользователя, ему нужен UserReader, а не бог-интерфейс.
D — Dependency Inversion Principle
Высокоуровневые политики не должны зависеть от низкоуровневых деталей. Use case не должен знать, что email отправляется через конкретный SDK, а заказ хранится именно через конкретный SQL-клиент.
DIP помогает тестировать и менять детали. Но если проект маленький, преждевременное выделение портов и адаптеров может сделать код тяжелее. Принцип полезен там, где зависимость действительно меняется или мешает тестированию.
6. GoF-паттерны: зачем они нужны
GoF — это 23 классических объектно-ориентированных паттерна из книги “Design Patterns”. Их важно знать не для того, чтобы везде писать AbstractFactoryProviderManager, а чтобы узнавать повторяющиеся формы решений и говорить с командой коротко.
Паттерн — это не код. Это схема сил:
- что меняется;
- что должно остаться стабильным;
- какой контракт вводим;
- какие зависимости развязываем;
- какую сложность покупаем.
Creational patterns
Factory Method
Идея: объект создается через метод, который можно переопределить или заменить.
Когда нужен: создание зависит от контекста, но клиенту не важен конкретный класс.
Пример: разные парсеры файлов: JSON, CSV, XML.
Риск: простую конструкцию new Parser() превращают в фабрику без причины.
Abstract Factory
Идея: фабрика создает семейство связанных объектов.
Когда нужен: нужно переключать целый набор реализаций: UI-компоненты для разных платформ, storage adapters для разных окружений, платежные провайдеры.
Риск: слишком рано проектировать “семейства”, когда в системе один вариант.
Builder
Идея: сложный объект собирается пошагово, а процесс сборки отделен от представления.
Когда нужен: много опциональных параметров, нужна валидация промежуточных шагов, хочется читаемый API.
Пример: построение search query, HTTP client, email template, test data.
Риск: builder для объекта из трех полей — лишний шум.
Prototype
Идея: новый объект создается клонированием существующего.
Когда нужен: создание дорогое, а объект удобно копировать с небольшими изменениями.
Пример: шаблоны документов, конфигурации, scene graph, immutable updates.
Риск: shallow copy там, где нужен deep copy; незаметное разделение mutable state.
Singleton
Идея: у класса один экземпляр и глобальная точка доступа.
Когда нужен: редко. Например, процессный registry или configuration object, но и там лучше dependency injection.
Риск: глобальное состояние, трудные тесты, скрытые зависимости, проблемы конкурентности.
Практическое правило: если хочется Singleton, сначала попробуй явно передать зависимость.
Structural patterns
Adapter
Идея: привести несовместимый интерфейс к нужному контракту.
Пример: внешний payment SDK возвращает statusCode, а домену нужен PaymentResult.
Польза: домен не знает грязные детали интеграции.
Bridge
Идея: разделить абстракцию и реализацию, чтобы они менялись независимо.
Пример: report renderer и output destination: PDF/HTML/CSV отдельно от file/email/S3.
Риск: сложность выше, чем у Adapter. Использовать, когда есть две независимые оси изменения.
Composite
Идея: работать с одиночным объектом и группой объектов через общий интерфейс.
Пример: дерево UI-компонентов, файловая система, меню, AST.
Риск: общий интерфейс становится слишком широким.
Decorator
Идея: добавить поведение объекту, обернув его.
Пример: cache decorator, retry decorator, logging decorator, metrics decorator.
class CachedUserReader implements UserReader {
constructor(private readonly inner: UserReader, private readonly cache: Cache) {}
async find(id: string) {
return this.cache.getOrSet(`user:${id}`, () => this.inner.find(id));
}
}
Facade
Идея: дать простой интерфейс к сложной подсистеме.
Пример: CheckoutFacade скрывает order, payment, inventory, notification.
Риск: facade превращается в god service.
Flyweight
Идея: разделять общее состояние между множеством мелких объектов.
Пример: символы в редакторе, графические элементы, interned strings, справочники.
Риск: сложно отделить intrinsic state от extrinsic state.
Proxy
Идея: объект-заместитель контролирует доступ к настоящему объекту.
Пример: lazy loading, remote proxy, protection proxy, caching proxy.
Риск: скрытая сеть или тяжелая операция под видом локального вызова.
Behavioral patterns
Chain of Responsibility
Идея: запрос проходит цепочку обработчиков, каждый может обработать или передать дальше.
Пример: middleware, validation pipeline, auth checks.
Риск: трудно понять, кто в итоге обработал запрос.
Command
Идея: действие оформляется как объект.
Пример: undo/redo, job queue, audit trail, CQRS commands.
Риск: слишком много классов-команд без необходимости.
Interpreter
Идея: описать грамматику языка и интерпретировать выражения.
Пример: query language, rules engine, filters, formula engine.
Риск: писать свой язык там, где достаточно конфигурации.
Iterator
Идея: последовательный доступ к элементам без раскрытия внутренней структуры.
Пример: обход коллекций, pagination cursor, streaming API.
Риск: iterator с побочными эффектами удивляет клиента.
Mediator
Идея: объекты общаются через посредника, а не напрямую.
Пример: UI dialog, message bus внутри модуля.
Риск: mediator становится god object.
Memento
Идея: сохранить состояние объекта для восстановления, не раскрывая внутренности.
Пример: undo, drafts, checkpoints.
Риск: большие snapshots съедают память.
Observer
Идея: подписчики реагируют на изменения publisher-а.
Пример: events, UI subscriptions, domain events.
Риск: неявный порядок, memory leaks, каскадные side effects.
State
Идея: поведение объекта зависит от состояния, состояние оформлено отдельными объектами.
Пример: order lifecycle, media player, workflow.
Риск: если состояний мало, state machine может быть проще таблицей переходов.
Strategy
Идея: алгоритм выбирается через общий интерфейс.
Пример: pricing strategy, sorting, recommendation algorithm, compression.
Риск: стратегии с одинаковым кодом и разницей в одной строке лучше делать параметрами.
Template Method
Идея: базовый алгоритм фиксирован, шаги переопределяются.
Пример: pipeline импорта, report generation.
Риск: наследование создает жесткую связность; composition часто гибче.
Visitor
Идея: добавить операции к структуре объектов, не меняя сами классы.
Пример: AST traversal, compiler passes, export разных форматов.
Риск: трудно добавлять новые типы элементов.
7. GoF в виде таблицы
| Паттерн | Категория | Когда вспоминать | Частый запах |
|---|---|---|---|
| Factory Method | Creational | создание зависит от контекста | фабрика ради одного new |
| Abstract Factory | Creational | семейства связанных объектов | преждевременная универсальность |
| Builder | Creational | сложная сборка объекта | builder для DTO из трех полей |
| Prototype | Creational | клонирование шаблонов | shallow copy mutable state |
| Singleton | Creational | один процессный экземпляр | глобальное состояние |
| Adapter | Structural | чужой интерфейс надо привести к своему | домен зависит от SDK |
| Bridge | Structural | две оси изменения | слишком ранняя абстракция |
| Composite | Structural | дерево объектов | общий интерфейс раздувается |
| Decorator | Structural | добавить cross-cutting behavior | цепочка оберток непонятна |
| Facade | Structural | скрыть сложную подсистему | god service |
| Flyweight | Structural | много мелких объектов | спутать shared и local state |
| Proxy | Structural | контроль доступа/lazy/remote | скрыть сеть под локальный вызов |
| Chain | Behavioral | pipeline обработчиков | непонятный порядок |
| Command | Behavioral | action как объект/job/undo | слишком много классов |
| Interpreter | Behavioral | DSL/rules | свой язык без причины |
| Iterator | Behavioral | обход коллекций/stream | скрытые side effects |
| Mediator | Behavioral | координация объектов | mediator-бог |
| Memento | Behavioral | snapshot/undo | память и размер snapshot |
| Observer | Behavioral | подписки/events | утечки и каскады |
| State | Behavioral | workflow/lifecycle | overengineering для двух состояний |
| Strategy | Behavioral | сменный алгоритм | классы вместо параметра |
| Template Method | Behavioral | фиксированный алгоритм, разные шаги | жесткое наследование |
| Visitor | Behavioral | операции над AST/tree | тяжело добавлять новые node types |
8. Функциональные паттерны разработки
Функциональное программирование полезно не потому, что “ООП умерло”, а потому что оно дает мощные идеи для управления сложностью: чистые функции, неизменяемость, композиция, явные эффекты, типы результата, декларативные преобразования.
Pure function
Чистая функция:
- при одинаковых входах возвращает одинаковый выход;
- не меняет внешнее состояние;
- не делает скрытых IO;
- легко тестируется.
function calculateDiscount(order: Order, rules: DiscountRule[]): Money {
return rules.reduce((total, rule) => total.add(rule.apply(order)), Money.zero());
}
Это лучше, чем функция, которая сама читает БД, смотрит дату, пишет лог, мутирует заказ и отправляет событие.
Immutability
Вместо изменения объекта мы создаем новый объект с изменением.
const nextUser = {
...user,
profile: {
...user.profile,
displayName: 'Marat',
},
};
Это особенно важно во frontend state management, concurrent systems и event sourcing. Immutability снижает количество неожиданных изменений, но может стоить памяти и CPU, если делать наивные deep copy.
Function composition
Вместо одной огромной функции мы собираем pipeline из маленьких.
const normalizeLead = pipe(
trimFields,
normalizePhone,
validateEmail,
enrichSource,
);
Composition работает, когда функции имеют ясные входы/выходы. Если каждая функция тайно ходит в глобальный state, композиция превращается в театр.
Map/filter/reduce
Это не просто методы массивов. Это три базовые формы мышления:
map: преобразовать каждый элемент;filter: оставить часть элементов;reduce: свернуть коллекцию в значение.
Ошибка: использовать reduce там, где простой цикл читабельнее. Functional style не должен быть соревнованием по плотности кода.
Option / Maybe
Option явно моделирует отсутствие значения.
Вместо User | null | undefined и случайного Cannot read properties of undefined можно иметь:
type Option<T> =
| { kind: 'some'; value: T }
| { kind: 'none' };
Польза: вызывающий код вынужден обработать отсутствие.
Either / Result
Result явно моделирует успех или ошибку.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
Это удобно для domain validation, use cases, интеграций. Ошибка становится частью контракта, а не сюрпризом из exception.
Railway oriented programming
Pipeline, где каждый шаг может вернуть success или failure. Если шаг упал, следующие не выполняются.
Reducer pattern
Reducer получает текущее состояние и событие, возвращает новое состояние.
function orderReducer(order: OrderState, event: OrderEvent): OrderState {
switch (event.type) {
case 'PaymentReceived':
return { ...order, status: 'paid', paidAt: event.paidAt };
case 'AccessGranted':
return { ...order, access: 'granted' };
default:
return order;
}
}
Это база для Redux, event sourcing, state machines.
Algebraic Data Types
ADT позволяют точно описывать состояния.
Плохо:
type Payment = {
status: string;
paidAt?: Date;
failedReason?: string;
};
Лучше:
type Payment =
| { status: 'pending' }
| { status: 'paid'; paidAt: Date }
| { status: 'failed'; reason: string };
Теперь невозможно представить “paid без paidAt” или “pending с failedReason”.
Lens
Lens — способ читать и обновлять вложенные immutable structures. В повседневной разработке часто достаточно helper-функций или Immer, но идея полезна: обновление вложенного состояния должно быть локальным и предсказуемым.
Memoization
Memoization кеширует результат чистой функции.
Применять, когда:
- функция чистая;
- входы можно стабильно сравнить;
- вычисление дорогое;
- кеш не растет бесконечно.
Functional core, imperative shell
Один из самых практичных паттернов.
- Functional core: чистая доменная логика.
- Imperative shell: HTTP, БД, файлы, сеть, время, случайность.
Этот подход помогает писать тесты на домен без моков всего мира.
9. Структуры данных: что обязан понимать программист
Структуры данных — это способы организовать данные так, чтобы операции были дешевыми. Они важны не только для алгоритмических собеседований. Неправильная структура данных превращает простой продуктовый код в тормозящую систему.
Big O без магии
Big O описывает рост стоимости при росте входа.
| Сложность | Как растет | Пример |
|---|---|---|
| O(1) | не зависит от размера | доступ по индексу, hash lookup в среднем |
| O(log n) | медленно | binary search, balanced tree |
| O(n) | линейно | проход массива |
| O(n log n) | типичная хорошая сортировка | merge sort, heapsort |
| O(n²) | быстро становится больно | двойной цикл по всем парам |
| O(2^n) | взрыв | перебор подмножеств |
Big O не заменяет профилирование. O(1) hash map может быть медленнее маленького массива на 5 элементах. Но Big O помогает видеть катастрофы заранее.
Array / dynamic array
Массив хорош для:
- быстрого доступа по индексу;
- компактной памяти;
- cache locality;
- итерации.
Плох для частых вставок в начало/середину, если нужно двигать элементы.
Linked list
Linked list хорош для вставок/удалений при наличии указателя на узел, но плох для случайного доступа и cache locality. В современных языках linked list часто проигрывает массиву из-за памяти и CPU cache.
Использовать осознанно: LRU cache с hash map + doubly linked list — классический случай.
Stack
LIFO: last in, first out.
Примеры:
- call stack;
- undo;
- parsing скобок;
- DFS;
- history navigation.
Queue / deque
FIFO: first in, first out. Deque позволяет добавлять/удалять с обеих сторон.
Примеры:
- BFS;
- task scheduling;
- sliding window;
- producer/consumer.
Hash map
Hash map дает быстрый lookup по ключу в среднем O(1).
Важно понимать:
- hash function;
- collisions;
- load factor;
- resizing;
- equality semantics;
- порядок ключей не всегда гарантирован.
Set
Set хранит уникальные значения. Используется для membership checks, deduplication, graph visited nodes, feature flags.
Heap / priority queue
Heap быстро достает минимальный/максимальный элемент.
Примеры:
- scheduler;
- top K;
- Dijkstra;
- merge sorted streams;
- rate limiting по ближайшему времени.
Tree
Tree моделирует иерархии: DOM, filesystem, categories, AST, org chart.
Balanced trees дают O(log n) для поиска/вставки/удаления. B-tree и B+tree важны для баз данных и файловых систем.
Trie
Trie хранит строки по префиксам.
Примеры:
- autocomplete;
- prefix search;
- routing;
- dictionaries;
- IP routing variants.
Graph
Graph моделирует связи: пользователи, дороги, зависимости, workflow, permissions, knowledge graph.
Нужно знать:
- adjacency list;
- adjacency matrix;
- BFS;
- DFS;
- topological sort;
- shortest path;
- connected components;
- cycle detection.
Disjoint Set Union
DSU/Union-Find отвечает на вопрос: находятся ли элементы в одной группе. Полезен для connected components, Kruskal MST, группировок.
Bloom filter
Bloom filter говорит “точно нет” или “возможно да”. Он экономит память, но допускает false positives.
Примеры:
- предварительная проверка наличия ключа;
- защита от дорогих lookup;
- distributed cache;
- crawling.
LRU cache
LRU хранит недавно использованные элементы. Обычно строится на hash map + doubly linked list.
Операции:
- get: найти в map, переместить node в начало;
- put: добавить/обновить, вытеснить хвост при превышении capacity.
Segment tree / Fenwick tree
Эти структуры нужны для быстрых запросов по диапазонам: сумма, минимум, максимум, обновления.
В продуктовой разработке встречаются реже, но полезны в аналитике, realtime dashboards, играх, редакторах.
10. Таблица выбора структуры данных
| Нужно | Частый выбор | Почему |
|---|---|---|
| Быстрый доступ по индексу | Array | O(1), cache locality |
| Часто проверять наличие | Hash Set | O(1) average membership |
| Key-value lookup | Hash Map | O(1) average lookup |
| Отсортированный порядок | Balanced Tree / B-tree | O(log n), ordered iteration |
| Top K / priority | Heap | быстро достать min/max |
| Prefix search | Trie | поиск по префиксу |
| Очередь задач | Queue | FIFO semantics |
| Undo/history | Stack или persistent list | LIFO/history |
| Graph traversal | Adjacency list | экономно для sparse graph |
| Range queries | Segment/Fenwick tree | O(log n) queries/updates |
| Approx membership | Bloom filter | экономия памяти |
| Cache eviction | Hash map + linked list | O(1) get/put LRU |
11. Иммутабельные структуры данных
Иммутабельная структура данных не изменяется после создания. Любая операция “изменения” возвращает новую версию.
Наивная иммутабельность копирует все:
const next = [...items, newItem];
Для маленьких массивов это нормально. Для больших структур дорого. Поэтому существуют persistent data structures, которые переиспользуют старые части.
Structural sharing
Новая версия разделяет большую часть структуры со старой.
Мы меняем только путь к измененному элементу, остальное переиспользуем.
Persistent list
Односвязный список отлично подходит для immutable prepend:
- добавить в начало: O(1);
- получить голову: O(1);
- пройти весь список: O(n);
- доступ по индексу: O(n).
Старые версии остаются живыми, потому что хвост разделяется.
Persistent vector
Persistent vector обычно строится на bitmapped vector trie. Он дает почти O(1) доступ по индексу и эффективное добавление, переиспользуя ветки дерева.
Идея: индекс разбивается на куски битов, каждый кусок выбирает ветку. При обновлении копируется только путь от корня к листу.
HAMT
Hash Array Mapped Trie — основа многих immutable map/set. Ключ хешируется, куски хеша ведут по дереву. При обновлении копируется только путь.
Используется в Clojure, Immutable.js, Mori и похожих библиотеках.
Copy-on-write
Copy-on-write копирует данные только при попытке изменения, если они разделяются. Это полезно для оптимизаций, но требует аккуратной модели владения.
Где immutability особенно полезна
- frontend state;
- undo/redo;
- event sourcing;
- concurrent reads;
- snapshot isolation;
- audit trail;
- optimistic UI;
- time travel debugging.
Где immutability может мешать
- tight loops;
- большие binary buffers;
- realtime graphics;
- high-frequency trading;
- низкоуровневые структуры с жесткими performance budget;
- места, где allocation pressure критичен.
Правило: immutable by default, mutable in controlled hot paths.
12. Как соединить ООП, FP и system design
Не надо выбирать религию. В реальных системах хорошо работают гибриды.
- System design задает границы компонентов.
- ООП помогает моделировать роли, контракты, lifecycle и polymorphism.
- FP помогает делать доменную логику чистой, тестируемой и предсказуемой.
- Data structures помогают не проиграть performance на базовых операциях.
- Observability помогает понять, что теория встретилась с продом.
Пример хорошей формы:
В этом дизайне:
- контроллер тонкий;
- use case координирует;
- домен чистый;
- интеграции через порты;
- ошибки явные;
- события можно тестировать;
- storage details не протекают в бизнес-правила.
13. Архитектурные запахи
God service
Один сервис знает все и делает все. Обычно появляется из благих намерений: “давайте быстро добавим сюда”. Через год никто не понимает, где границы.
Лечение: выделять use cases, policies, adapters, read models.
Anemic domain без use cases
Есть DTO и repositories, но вся логика размазана по controllers. Это делает систему зависимой от транспорта и мешает переиспользованию.
Over-abstracted code
Интерфейсы на каждый класс, фабрики для фабрик, generic repository для всего. Код выглядит “enterprise”, но каждое изменение требует пройти лабиринт.
Лечение: удалять абстракции, которые не защищают от реального изменения.
Hidden coupling
Модули связаны через глобальное состояние, shared cache keys, события без схемы, implicit ordering.
Лечение: явные контракты, schema versioning, integration tests.
Accidental distributed monolith
Сервисы физически разделены, но релизятся вместе, падают вместе, требуют синхронной цепочки вызовов и общей БД.
Лечение: сначала модульный монолит с хорошими границами, потом выносить сервисы там, где есть независимый lifecycle.
14. Практические упражнения
Упражнение 1. LRU cache
Реализовать LRU cache с O(1) get и put.
Подсказка: hash map + doubly linked list.
Проверить:
- обновление существующего ключа;
- вытеснение least recently used;
- capacity = 1;
- повторный get меняет порядок.
Упражнение 2. URL shortener
Спроектировать сервис коротких ссылок.
Обсудить:
- генерация id;
- custom aliases;
- redirect latency;
- analytics;
- abuse protection;
- expiration;
- storage;
- cache;
- rate limits.
Упражнение 3. LMS progress
Спроектировать прогресс обучения.
Вопросы:
- что считается просмотром урока;
- как хранить progress events;
- как считать проценты;
- что делать с повторным просмотром;
- как выдать сертификат;
- как пересчитать прогресс после изменения курса.
Упражнение 4. Notification system
Спроектировать уведомления email/Telegram/push.
Использовать:
- Strategy для каналов;
- Command для jobs;
- Decorator для retry/metrics;
- Result для ошибок;
- Queue для асинхронности;
- idempotency key для повторов.
Упражнение 5. Immutable reducer
Сделать reducer для order lifecycle:
OrderCreated;PaymentStarted;PaymentReceived;PaymentFailed;AccessGranted;Refunded.
Проверить невозможные состояния.
15. Чеклист system design интервью и реальной задачи
- Повтори задачу своими словами.
- Определи пользователей и роли.
- Выпиши функциональные требования.
- Выпиши нефункциональные требования.
- Оцени нагрузку: reads, writes, storage, traffic.
- Определи source of truth.
- Нарисуй API.
- Нарисуй data model.
- Раздели read path и write path.
- Реши, где нужен cache.
- Реши, где нужна queue.
- Определи consistency model.
- Разбери failure modes.
- Добавь observability.
- Проверь security и privacy.
- Назови trade-offs.
- Скажи, что сделаешь сначала, а что отложишь.
- Опиши план эволюции.
16. Как выбирать паттерн
Задай пять вопросов:
- Что именно меняется?
- Как часто это меняется?
- Кто будет добавлять новые варианты?
- Какая цена ошибки?
- Что проще удалить через месяц?
Если изменение гипотетическое, не нужно строить сложную абстракцию. Если изменение уже случилось три раза и каждый раз ломает код, паттерн может быть лекарством.
17. Минимальный фундамент, который стоит натренировать руками
System design:
- URL shortener;
- feed;
- chat;
- notification system;
- file upload service;
- checkout;
- analytics counters;
- LMS progress;
- search autocomplete.
Паттерны:
- Strategy для pricing;
- Adapter для внешнего SDK;
- Decorator для кеша;
- Command для job queue;
- Observer для domain events;
- State для order lifecycle;
- Composite для дерева меню;
- Builder для query object.
Структуры данных:
- hash map;
- heap;
- trie;
- graph BFS/DFS;
- LRU cache;
- union-find;
- bloom filter;
- persistent list/vector на концептуальном уровне.
18. Главное резюме
Фундамент программиста — это не набор терминов. Это способность видеть форму проблемы.
- System design учит думать о системе целиком.
- SOLID учит управлять зависимостями и ответственностями.
- GoF дает язык повторяющихся решений.
- Functional patterns помогают делать логику чистой и предсказуемой.
- Data structures помогают выбирать правильную цену операций.
- Immutable structures помогают контролировать состояние и историю.
Хороший код не обязан быть максимально абстрактным. Хороший код должен быть честным: понятно, что он делает, где хранит состояние, какие ошибки возможны, какие изменения он выдержит и какую цену платит за свои решения.
Если после чтения хочется применить все паттерны сразу — не надо. Выбери один реальный кусок своей системы, найди боль, назови trade-off и улучши дизайн так, чтобы через месяц код стало проще менять, а не сложнее объяснять.