Материал Фундамент программиста: system design, SOLID, GoF, функциональные паттерны и структуры данных
0%

Фундамент программиста: system design, SOLID, GoF, функциональные паттерны и структуры данных

Фундамент программиста: 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:

  1. Прочитать разделы про system design mindset, SOLID, базовые структуры данных.
  2. Разобрать GoF на уровне “какую проблему решает”.
  3. Сделать упражнения: LRU cache, очередь задач, URL shortener.

Маршрут senior/lead:

  1. Читать через призму trade-off: latency vs consistency, coupling vs duplication, abstraction vs locality.
  2. Смотреть на паттерны как на язык обсуждения, а не как на каталог классов.
  3. Проработать system design чеклисты и ADR-шаблоны.

Маршрут frontend/fullstack:

  1. Особое внимание уделить состоянию, immutability, reducers, memoization, event-driven UI.
  2. Понять, что frontend тоже система: данные приходят асинхронно, состояние расходится, сеть падает, пользователь кликает быстрее API.

Маршрут backend/platform:

  1. Больше внимания storage, очередям, consistency, idempotency, retry, observability.
  2. На каждый паттерн задавать вопрос: “что будет при конкуренции, отказе, повторной доставке, миграции схемы?”

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;
  • схемы событий и совместимости версий.

Поиск почти всегда 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 интервью и реальной задачи

  1. Повтори задачу своими словами.
  2. Определи пользователей и роли.
  3. Выпиши функциональные требования.
  4. Выпиши нефункциональные требования.
  5. Оцени нагрузку: reads, writes, storage, traffic.
  6. Определи source of truth.
  7. Нарисуй API.
  8. Нарисуй data model.
  9. Раздели read path и write path.
  10. Реши, где нужен cache.
  11. Реши, где нужна queue.
  12. Определи consistency model.
  13. Разбери failure modes.
  14. Добавь observability.
  15. Проверь security и privacy.
  16. Назови trade-offs.
  17. Скажи, что сделаешь сначала, а что отложишь.
  18. Опиши план эволюции.

16. Как выбирать паттерн

Задай пять вопросов:

  1. Что именно меняется?
  2. Как часто это меняется?
  3. Кто будет добавлять новые варианты?
  4. Какая цена ошибки?
  5. Что проще удалить через месяц?

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

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 и улучши дизайн так, чтобы через месяц код стало проще менять, а не сложнее объяснять.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов