Первые принципы архитектуры
Проблема: Тысячи фреймворков, десятки «революционных» подходов, бесконечные споры про правильную архитектуру. Но есть вещи, которые не меняются. Эти вещи — первые принципы (first principles).
Когда я начинал программировать, мне казалось, что мир софта — это гонка за новыми технологиями. Каждые полгода выходил «убийца фреймворка», каждые два года — «убийца языка». Сегодня все писали на Java со Spring, завтра — на Go с микросервисами, послезавтра — на Rust с WebAssembly.
Но чем дольше я работаю, тем яснее вижу: технологии приходят и уходят, а фундаментальные принципы остаются. Принципы — это то, что не меняется, когда всё вокруг меняется.
В этой главе мы разберём восемь принципов, которые определяют любую архитектуру — от скрипта на PHP до распределённой системы на миллиард запросов. Не как «священные истины», а как инструменты мышления. Вы поймёте, почему они работают, на чём основаны, и — что важнее — когда их можно нарушать.
На протяжении главы мы будем обращаться к реальным кейсам компаний, которые прошли путь от стартапа до масштаба планеты. Их опыт — не абстрактные примеры, а уроки, оплаченные инцидентами, бессонными ночами и переписыванием архитектуры.
#Связанность (Coupling) — цена, которую вы платите за каждую зависимость
Представьте, что вы ремонтируете проводку в доме. В идеальном мире каждая комната имеет свой автомат в щитке: выключил спальню — чинишь розетку, а кухня работает. В плохом мире весь дом завязан на один рубильник: чините одну лампочку — сидите без света во всём доме.
Это и есть связанность.
Высокая связанность (tight coupling) — когда изменение в одном модуле требует изменений в других. Классический пример:
# Tight coupling — класс знает про конкретную БД
class UserService:
def __init__(self):
self.db = MySQLConnection("host", "user", "pass")
def get_user(self, user_id):
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")Хотите переехать с MySQL на PostgreSQL? Меняйте UserService. Хотите добавить Redis-кэш перед БД? Меняйте UserService. Хотите написать тест без реальной базы? Удачи — в конструкторе live-соединение.
Низкая связанность (loose coupling) — когда модули общаются через абстракции. Тот же пример, но иначе:
# Loose coupling — UserService зависит от абстракции, а не от конкретной БД
class UserService:
def __init__(self, repository: UserRepository):
self.repository = repository
def get_user(self, user_id):
return self.repository.find_by_id(user_id)Теперь UserRepository — это интерфейс. Реализация может быть MySQL, PostgreSQL, файл на диске или in-memory для тестов. UserService не знает и не хочет знать.
Проблема в том, что все знают про «low coupling good, high coupling bad». Но на практике любая архитектура — это про управление связанностью, а не про её полное отсутствие. Нулевая связанность невозможна: если два модуля вообще не связаны, они не образуют систему. Вопрос в том, какие связи вы разрешаете, а какие — нет.
#Что говорит практика
В 2019 году я работал над проектом, где была одна «божественная» (god) функция — processOrder(). Она занимала 800 строк. Делала всё: валидировала заказ, проверяла кредитный лимит, списывала деньги, обновляла склад, отправляла email, писала в CRM, обновляла промо-акции. Казалось бы — что плохого? Всё в одном месте.
Плохо стало, когда мы попытались добавить новый способ оплаты. processOrder() вызывалась из трёх мест. В каждом вызове — свои заморочки с валидацией. Мы меняли в одном — ломалось в другом. Через два месяца мы боялись трогать эту функцию.
Решение было болезненным: разбили на модули, каждый со своей ответственностью и интерфейсом. Сама processOrder() превратилась в оркестратор из шести строк:
def process_order(order):
validation_result = order_validator.validate(order)
payment = payment_service.charge(order, validation_result)
inventory.reserve(order, payment)
notification.send_confirmation(order)
crm.record_order(order)Каждый модуль теперь можно тестировать, менять и деплоить независимо. Цена? Пришлось спроектировать интерфейсы. Но это окупилось через месяц, когда мы добавили третий способ оплаты без единого бага.
Из опыта гигантов. Uber прошёл этот урок в масштабе компании. К 2017 году у них было более 2200 микросервисов. Но многие из них были настолько связаны, что деплой одного сервиса требовал каскадных изменений в десятке других. В индустрии это состояние получило название «распределённый монолит» (distributed monolith) — микросервисы, которые нельзя менять независимо. Uber потратил годы на консолидацию, осознав, что разбить код на сервисы недостаточно: настоящая слабая связанность требует дисциплины в контрактах, асинхронной коммуникации и чётких границ доменов.
Связанность бывает не только на уровне кода, но и на уровне команд. Подробнее об этом — в Главе 1 «Архитектура — это про людей», а об organisational coupling — в Главе 14 «Дорога в распределённый мир».
Cross-reference: О том, как микросервисы могут превратиться в распределённый монолит — см. Главу 15 «Микросервисы — правильный способ». О модульном монолите как альтернативе — Главу 12.
#Связность (Cohesion) — что внутри модуля и почему это важно
Связанность отвечает на вопрос «как модули общаются друг с другом». Связность отвечает на вопрос «а что, чёрт возьми, внутри одного модуля».
Высокая связность — когда всё внутри модуля служит одной цели. Модуль OrderRepository содержит только методы для работы с заказами: findById, save, delete, findByCustomerId. Всё логично.
Низкая связность — когда модуль — свалка. Класс Utils с функциями formatDate, calculateShippingCost и sendEmail. Что между ними общего? Ничего. Они просто «понадобились где-то».
Низкая связность убивает архитектуру незаметно. Вы не замечаете, пока проект маленький. Но когда в Utils 150 функций, а новый разработчик тратит полчаса, чтобы понять, есть ли уже функция для форматирования телефона — вы уже попали.
#Как понять, что модуль делает слишком много
Есть простой тест — тест названия. Попробуйте назвать модуль одним предложением.
ShippingCalculator— рассчитывает стоимость доставки. Ок.OrderProcessor— обрабатывает заказы. Всё ещё ок.PaymentService— проводит платежи. Ок.Helpers— содержит… полезные функции. Тревожный звоночек.Manager— управляет… чем-то. Красная лампочка.DataHandler— работает с данными. Пожар.
Если вы не можете назвать модуль, не используя слова «и», «также» или «прочее» — у него низкая связность.
#Что говорит практика
Я видел codebase, где был класс CommonUtils с 34 методами. Там лежало всё: от isValidEmail до generateInvoiceNumber и calculateDistanceBetweenCoordinates. В одном классе — валидация, генерация и гео-вычисления.
Когда проект вырос, оказалось, что generateInvoiceNumber вызывается из семи мест пяти разных сервисов. Изменение формата номера счёта потребовало проверки всех остальных 33 методов — вдруг что-то сломалось?
В итоге CommonUtils разбили на пять модулей: EmailValidator, InvoiceNumberGenerator, GeoUtils, DateUtils и StringUtils. Каждый — с чёткими границами и тестами. Каждый — про одно.
Из опыта гигантов. Посмотрите на Shopify: более 500 разработчиков работают в одном монолите на Ruby on Rails (подробнее — в Главе 11 «Монолит — не грязное слово»). Как им удаётся не скатиться в хаос? Ответ — жёсткая дисциплина модульности. Каждый модуль имеет чёткого владельца, ограниченную ответственность и имя, которое не стыдно произнести вслух. Никаких utils и misc. Когда кодовая база переваливает за миллион строк, связность — не вопрос эстетики, это вопрос выживания команды.
Cross-reference: О том, как организовать модули в монолите — см. Главу 12 «Модульный монолит». О bounded contexts как единице связности — Главу 4 «Domain-Driven Design».
#Принцип единственной ответственности (SRP) — не для функций, а для контекстов
SRP — самый неверно понятый принцип SOLID. Почти каждый разработчик скажет: «Один класс — одна ответственность». И будет неправ.
Оригинальная формулировка Роберта Мартина звучит иначе:
«A class should have only one reason to change» — «У класса должна быть только одна причина для изменения».
Вдумайтесь: причина для изменения — это про людей, а не про код. Изменения в софте происходят не потому что «код захотел измениться». Изменения происходят потому что люди приходят с требованиями.
Если ваш класс меняется, потому что «бухгалтерия поменяла формат отчёта» и «дизайнер обновил главный экран» и «маркетинг добавил новую акцию» — у этого класса три причины для изменения. Три разных человека (или роли) будут его трогать. Это значит — три источника конфликтов в мерж-реквестах.
SRP в практическом смысле — это кто отвечает за изменение этого кода. Если над модулем работают две разные команды по разным причинам — модуль нарушает SRP.
#Что говорит практика
Представьте типичный сервис в веб-приложении:
class UserService:
def register_user(self, email, password):
# валидация
# сохранение в БД
# отправка welcome email
# запись в аналитику
# обновление кэшаСколько здесь причин для изменения?
- Маркетинг хочет поменять welcome-письмо
- Аналитики хотят новые метрики
- Инфраструктура меняет провайдера кэша
- Юристы требуют новый формат валидации email
Все изменения проходят через UserService. Все — через одного разработчика или одну команду. В большом проекте это узкое горло.
Решение — разнести по разным обработчикам:
class UserRegistration:
def __init__(self, user_repo, mailer, analytics, cache):
self.user_repo = user_repo
self.mailer = mailer
self.analytics = analytics
self.cache = cache
def register(self, email, password):
user = self.user_repo.create(email, password)
self.mailer.send_welcome(user)
self.analytics.track_registration(user)
self.cache.invalidate(user)Теперь маркетинг может менять mailer, не трогая регистрацию. Аналитики — не трогая почту. Всё это — не создавая конфликтов в коде.
Из опыта гигантов. В 2002 году Джефф Безос издал знаменитый мандат: «Все команды обязаны предоставлять свои данные и функциональность через сервисные интерфейсы. Никакого прямого доступа к базам данных других команд». Формально это было про слабую связанность. Но по сути — про SRP на уровне организации. Каждая команда стала единственным владельцем своего домена. Нельзя просто «добавить поле» в чужую таблицу — нужно договариваться через API.
Этот мандат, известный как «API Mandate», породил AWS — первый сервис Amazon (S3) родился из необходимости обслуживать внутренние запросы через REST API. Урок: SRP на уровне команд — это не бюрократия, а необходимость для масштаба. Подробнее — в Главе 31 «Эволюция архитектуры».
Cross-reference: О том, как bounded contexts помогают реализовать SRP на практике — см. Главу 4 «Domain-Driven Design». Об Architectural Decision Records (ADR) — Приложение B.
#Dependency Inversion — кто кого определяет
Принцип инверсии зависимостей (Dependency Inversion Principle, DIP) звучит страшно, но суть проста:
«Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракций.»
В переводе на человеческий: не вы должны подстраиваться под базу данных, а база данных — под вас.
Реальный пример. Типичная ошибка начинающих — прямое использование БД в бизнес-логике:
def calculate_discount(user_id, order_total):
user_row = db.query("SELECT level FROM users WHERE id = ?", user_id)
# бизнес-логика расчёта скидки на основе уровня пользователяПроблема? Бизнес-логика привязана к структуре таблицы. Хотите переименовать поле level в tier? Меняйте расчёт скидки. Хотите хранить уровень в Redis, а не в SQL? Меняйте расчёт скидки.
Правильный подход:
class DiscountCalculator:
def __init__(self, user_repository: UserRepository):
self.user_repo = user_repository
def calculate(self, user_id, order_total):
user = self.user_repo.find_by_id(user_id)
return user.get_discount_for(order_total)DiscountCalculator зависит от абстракции UserRepository. Реализация может быть SQL, Redis, файл или мок для тестов — бизнес-логике всё равно.
#Что говорит практика
В 2020 году мы переезжали с MongoDB на PostgreSQL. Сервисы, где бизнес-логика была отделена от хранения через репозитории, переезжали за день — меняли только реализацию интерфейса. Сервисы, где бизнес-логика была вперемешку с Mongo-запросами, переписывали неделями и ловили баги ещё месяц.
Техника, которая спасает: сначала проектируйте интерфейсы, потом реализации. Начните с того, как ваш код будет выглядеть «в идеале» — какие методы вы хотите вызывать, какие данные получать. И только потом думайте, как это реализовать. База данных — это деталь реализации. Не позволяйте ей диктовать архитектуру.
Из опыта гигантов. Stripe сознательно придерживается философии «скучных технологий» (boring technology). Их основной стек — PostgreSQL, а не десяток модных баз данных. Но бизнес-логика Stripe абстрагирована от хранения: когда им понадобилось перейти на Vitess для горизонтального масштабирования PostgreSQL, поменялся слой данных, а не код процессинга платежей. DIP не про то, «какую БД вы выбрали». DIP про то, что решение о выборе БД не должно протекать в каждый класс вашего приложения.
Cross-reference: О выборе между SQL и NoSQL — см. Decision Matrix в Приложении A. О слое данных и Data Layer — Главу 7. О монолите и инверсии зависимостей — Главу 11.
#Закон Поста (Postel's Law / Robustness Principle)
Джон Постел, один из отцов интернета, сформулировал принцип, который вошёл в спецификацию TCP:
«Be conservative in what you send, be liberal in what you accept» «Будь консервативен в том, что отдаёшь, и либерален в том, что принимаешь»
На практике это означает две вещи:
- Когда вы отдаёте данные — будьте строги. Формат должен быть чётким, предсказуемым, документированным. Никаких «это поле опционально, но если не передать, может упасть».
- Когда вы принимаете данные — будьте терпимы. Клиенты бывают кривыми, партнёры — неаккуратными. Ваша система должна пережить лишнее поле в JSON, отсутствующий заголовок, нестандартный формат даты.
#Почему это важно
Закон Поста напрямую влияет на эволюцию системы. Строгость на выходе (ваш API) даёт клиентам стабильность — они знают, что получат. Либеральность на входе даёт вам возможность менять внутреннюю реализацию, не ломая клиентов.
Представьте, что ваш API возвращает:
{
"user": "ivan",
"created_at": "2024-01-15T10:30:00Z"
}Строгий контракт. Дата — всегда ISO 8601. Поле user — всегда строка. Клиенты могут положиться на это.
А теперь представьте, что ваш API принимает даты в любом формате: "15.01.2024", "2024/01/15", 1579000000 (unix timestamp). Ваша система всё разбирает и приводит к единому формату. Клиенты могут передавать как удобно — вы адаптируетесь.
#Когда закон Поста ломается
У принципа есть тёмная сторона. Чрезмерная либеральность на входе маскирует ошибки. Если вы принимаете что угодно, клиенты не узнают, что передают мусор, пока не станет слишком поздно.
Классический пример: поле email при регистрации. Если вы принимаете "ivan" как email (потому что «либеральность»), вы не сможете отправить пользователю письмо. Хуже того, пользователь узнает об этом только когда попытается восстановить пароль.
Баланс такой: будьте либеральны в том, что можно игнорировать, и строги в том, что критически важно. Лишнее поле в JSON — ок, просто проигнорируйте. Отсутствие обязательного поля — ошибка, отдавайте 400.
#Что говорит практика
В 2017 году мы интегрировались с платёжным шлюзом. Их API возвращал поле status как строку: "success", "failed", "pending". Через год они добавили "refunded". Наша система упала — парсинг статуса был строгим и знал только три значения. Либеральность на входе (принимать "refunded" как неизвестное, но не падать) спасла бы нас от инцидента.
С тех пор наша политика: всегда парсить с запасом. Неизвестное значение поля — не ошибка, а сигнал. Запишите его, залогируйте, но не падайте. Клиенты могут знать что-то, чего не знаете вы.
Из опыта гигантов. Cloudflare построил свою Edge Network на строгих контрактах наружу (клиент получает предсказуемые ответы с CDN) и либеральных контрактах внутрь (серверы Cloudflare принимают трафик с любого порта, в любом протоколе, терпят нестандартные заголовки). Эта асимметрия — ключ к их глобальной производительности. Они строги на выходе к пользователю и либеральны на входе от источника. (Подробнее об Edge Computing — в Главе 23.)
Cross-reference: Об API-архитектуре, контрактах и версионировании — см. Главу 6. О том, как проектировать строгие контракты с OpenAPI — раздел REST в той же главе.
#CQS / CQRS — фундаментальное разделение
Command-Query Separation (CQS) — принцип, сформулированный Бертраном Мейером:
«Каждый метод должен быть либо командой, выполняющей действие, либо запросом, возвращающим данные, но не тем и другим одновременно.»
Звучит как «очевидно, зачем про это писать?». Но на практике это нарушается на каждом шагу:
# Плохо: метод делает и то, и другое
def save_and_return_id(self, user_data):
# сохраняет пользователя (команда)
# и возвращает ID (запрос)
...
# Плохо: метод с побочными эффектами в запросе
def get_user(self, user_id):
user = self.db.query(...)
self.logger.info(f"User {user_id} accessed") # побочный эффект!
return userПочему это важно? Команды меняют состояние. Запросы — нет. Если смешивать их, вы не можете безопасно:
- Кэшировать результат запроса
- Повторять запрос при ошибке
- Параллелить чтение и запись
- Понимать, какие операции безопасны, а какие — нет
#CQRS — расширение для масштаба
Command Query Responsibility Segregation (CQRS) — это CQS, доведённый до архитектурного уровня: у вас разные модели для чтения и для записи. Не просто разные методы в одном классе, а разные классы, разные таблицы, иногда — разные базы данных.
# Команда — меняет состояние
class CreateOrderCommand:
def execute(self, order_data) -> OrderId: ...
# Запрос — читает данные (через отдельную read-модель)
class OrderQuery:
def find_by_customer(self, customer_id) -> List[OrderView]: ...В простых системах CQRS — избыточен. Одна и та же модель прекрасно работает и на запись, и на чтение. Но когда нагрузка растёт, вы замечаете, что:
- Оптимизации для записи (normalization, constraints, triggers) мешают чтению
- Оптимизации для чтения (денормализация, материализованные представления) мешают записи
CQRS даёт вам свободу оптимизировать их независимо.
#Что говорит практика
Представьте систему бронирования отелей. На запись: каждая операция требует транзакционной целостности — проверить, что номер свободен, забронировать, списать деньги. Тут важны согласованность и блокировки.
На чтение: пользователь ищет отели по 20 фильтрам. Тут нужны денормализованные данные, полнотекстовый поиск, агрегации. Запрос может занимать секунды, и это нормально.
В маленьком проекте обе модели живут в одной БД. В среднем — появляется read replica. В большом — отдельная поисковая система (Elasticsearch, Typesense), которая питается событиями из основной БД через CDC.
Из опыта гигантов. Discord прошёл путь от монолита на Python к раздельным read/write моделям для сообщений. Их read model (история сообщений при заходе в канал) живёт в ScyllaDB — это read-heavy нагрузка с денормализованными данными. Write model (отправка сообщения) проходит через Go-сервисы с бизнес-логикой. Две разные базы, две разные модели, два разных стека — и это позволило им масштабироваться от 1M до 100M+ пользователей (см. Главу 19 «Масштабирование»).
CQRS — не про «разделите на команды и запросы». CQRS — про то, что читать и писать можно по-разному, и это нормально.
Cross-reference: Полный разбор CQRS с кодом, чек-листом и анти-паттернами — см. Главу 7a «CQRS: Command Query Responsibility Segregation». Об Event Sourcing, с которым CQRS часто путают — см. раздел Event Sourcing в Главе 7.
#CAP теорема для продакшена — выбирайте два, знайте третье
CAP теорема (Эрик Брюэр, 2000) — одна из самых цитируемых и самых неверно понимаемых концепций в распределённых системах. Формулировка:
В распределённой системе можно обеспечить только два из трёх свойств:
- Consistency (C) — все ноды видят одни и те же данные в один момент времени
- Availability (A) — каждый запрос получает ответ (успех или ошибку)
- Partition tolerance (P) — система продолжает работать при разрыве связи между нодами
В интернете можно встретить тысячи статей в духе «Мы выбрали CP, потому что нам важна консистентность». Проблема в том, что в реальном мире выбора между C, A и P практически нет.
В распределённой системе P (partition tolerance) — не опция, а условие существования. Сети ломаются. Пакеты теряются. Ноды отваливаются. Разделение (partition) — это не «если», а «когда». Так что реальный выбор — между C и A во время раздела сети: или вы жертвуете консистентностью ради доступности (AP), или доступностью ради консистентности (CP).
#Но на практике всё сложнее
CAP теорема описывает идеализированную модель. В реальности:
- CAP — это бинарный выбор. Либо CP, либо AP. Но real-world системы не бинарны. PostgreSQL — CP в кластере? И да, и нет: синхронная репликация даёт CP, асинхронная — AP, пока раздел не обнаружен.
- CAP работает только во время раздела. В нормальном состоянии система может быть и C, и A. Весь вопрос — что происходит, когда связь рвётся.
- Есть PACELC — расширение CAP, которое формулирует дилемму точнее: «Если раздел (Partition) — выбирай между A и C. Иначе (Else) — выбирай между L (Latency) и C (Consistency).» Именно последнее — настоящий выбор, с которым вы сталкиваетесь каждый день.
#Что говорит практика
Возьмём типичный веб-сервис с PostgreSQL и read replicas. Пишем в мастер, читаем с реплики. В нормальном состоянии — всё хорошо. Но при задержке репликации пользователь может не увидеть только что созданный заказ. Это сознательный компромисс: мы жертвуем строгой консистентностью (strong consistency) в пользу производительности чтения.
Вот как выглядит реальная таблица выбора:
| Система | Во время раздела | В нормальном состоянии |
|---|---|---|
| PostgreSQL (синхронная реплика) | CP | Strong consistency |
| Cassandra | AP | Eventual consistency |
| DynamoDB | AP | Eventual / Strong (на выбор для ключа) |
| Spanner | CP | External consistency (сильнее чем strong) |
| MongoDB (replica set) | CP (по умолчанию) | Read-your-writes |
Из опыта гигантов. Google Spanner — возможно, самый впечатляющий пример обхода CAP-ограничений. Spanner обеспечивает external consistency (уровень согласованности выше, чем strong consistency) в глобально распределённой базе данных. Секрет — TrueTime API: синхронизация времени через GPS + атомные часы. Spanner платит другую цену: latency на запись может составлять десятки миллисекунд из-за синхронной репликации через континенты. CAP не нарушен — просто цена выбрана иная. Подробнее — в Главе 25 «Multi-Region архитектура».
Выбор не между C, A и P. Выбор между тем, что вы готовы потерять, когда сеть падает, и какой latency вы готовы терпеть, когда сеть работает.
Cross-reference: О распределённых данных и компромиссах консистентности — Главу 17. О шардировании и репликации — Главу 20. О multi-region архитектуре — Главу 25.
#Gap между теорией и практикой — когда нарушать правила
Мы разобрали семь принципов. Теперь самое важное: когда их нарушать.
Потому что идеальная архитектура — это архитектура, которую вы никогда не построите. Реальность всегда сложнее. Бюджеты ограничены. Сроки горят. Команды не идеальны. Бизнес требует фичу, а не «правильную архитектуру».
#Когда можно нарушить каждый принцип
Высокая связанность — оправдана в прототипе. Если вы проверяете гипотезу, которая может не взлететь — пишите монолит с прямой связью. Выиграете скорость. Проиграете — в гибкости, которая пока не нужна.
Низкая связность — оправдана для утилитарного кода. Форматирование дат, работа с HTTP-заголовками — это не про бизнес-логику. Иногда utils.py — это нормально.
SRP можно нарушить на старте. Одна функция регистрации, которая и валидирует, и пишет в БД, и шлёт email — это ок для первого релиза. Главное — знать, что вы его нарушаете, и иметь план рефакторинга, когда появится второй способ регистрации.
Dependency Inversion — не нужен для стабильных зависимостей. Если вы точно знаете, что БД — PostgreSQL, у вас нет планов мигрировать, и это не менялось 5 лет — прямой импорт драйвера может быть проще, чем абстракция с интерфейсом. Каждая абстракция — это сложность. Платите её только когда получаете выгоду.
Закон Поста — нарушайте в критических контрактах. Платёжные операции, медицинские данные, юридически значимые поля — здесь либеральность опасна. Ошибка на входе должна быть ошибкой, а не молчаливо проглоченным мусором.
CQS — нарушайте в CUD-операциях (с отдачей ID). «Создай и верни ID» — это нарушение CQS, но настолько удобное, что никто его не считает нарушением. Компромисс: команда возвращает минимальный идентификатор (не целое состояние).
CAP — это не руководство к действию, а способ мышления. Не выбирайте «мы CP» — это бессмысленно без контекста. Спрашивайте: «Что происходит при разрыве связи?», «Какую несогласованность мы допускаем?», «Как долго мы можем быть недоступны?».
#Главный критерий — осознанность
Единственное непростительное нарушение принципа — нарушение, о котором вы не знаете. Если вы пишете высокую связанность в прототипе и говорите команде: «Да, мы завяжемся на MySQL напрямую, это прототип, через месяц вынесем в репозиторий» — это ок. Если вы пишете высокую связанность, потому что «так быстрее», и забываете — через год это будет distributed monolith с бетонными стенами.
Из опыта гигантов. История Medium — хрестоматийный пример осознанного выбора. Когда Medium рос, их Python-монолит начал тормозить разработку. Они решили переходить на микросервисы на Go и Scala. Идеально спроектировали архитектуру с сильными границами, CQRS, event sourcing — все принципы соблюдены. Результат? Через два года они частично вернулись к монолиту. Потому что переплатили за абстракции, которые не окупились. Каждый новый сервис требовал настройки CI/CD, мониторинга, логирования, деплоя. Команда тратила время на инфраструктуру, а не на фичи. Сложность системы превысила выгоду от разделения. [Source: "Why we switched from microservices back to a monolith" — Medium Engineering Blog, 2020]
Урок: принципы — это не цель. Цель — доставлять ценность пользователям. Принципы — это инструменты, которые помогают это делать. Когда инструмент мешает — отложите его или замените.
Ту же ошибку повторила не одна компания. Uber после пика в 2200+ микросервисов начал обратный процесс — частичную консолидацию. Twitter в эпоху Fail Whale тоже пытался «исправить всё сразу», и каждая попытка big bang rewrite оборачивалась новыми проблемами. Подробнее — в Главе 31 «Эволюция архитектуры».
Cross-reference: Балансу «сделать правильно vs сделать быстро» посвящена Глава 5 «YAGNI, KISS и когда их нарушать».
#Когда это применимо
| Принцип | Для стартапа | Для средней компании | Для enterprise |
|---|---|---|---|
| Низкая связанность | Только в критических модулях | Обязательна на границах команд | Обязательна на всех уровнях |
| Высокая связность | Желательна | Обязательна | Критична |
| SRP | Понимать, но нарушать осознанно | Следовать на уровне модулей | Следовать на всех уровнях |
| DIP | Только для внешних сервисов | Для всех нестабильных зависимостей | Для всех зависимостей |
| Закон Поста | Да, но с логированием ошибок | Да, с мониторингом аномалий | Да, с формальными контрактами |
| CQRS | Нет, одна модель | На отдельных hot-paths | Как архитектурный стиль |
| CAP | Мониторить компромиссы | Знать свой выбор | Автоматизировать поведение |
#Не делай так
#1. Распределённый монолит (Distributed Monolith)
Микросервисы, которые нельзя деплоить независимо.
Симптомы: деплой одного сервиса требует деплоя десяти других; общая база данных между сервисами; синхронные цепочки вызовов A → B → C → D, где падение одного валит всё.
Лечение: признать ошибку, вернуться к модульному монолиту и передекомпозировать по доменам. См. Главу 12 «Модульный монолит».
#2. Большой ком грязи (Big Ball of Mud)
Кодовая база без структуры, где каждый класс делает всё.
Симптомы: циклические зависимости, нет модулей, добавление одной фичи требует изменения 20 файлов, чтение кода занимает больше времени, чем написание.
Лечение: Strangler Fig — выделяйте bounded context постепенно, не пытайтесь переписать всё сразу.
#3. Золотой молоток (Golden Hammer)
Решать все проблемы одним инструментом.
«У нас PostgreSQL, давайте всё в него сложим» (даже файлы). «Давайте сделаем микросервис» (для функционала на 100 строк). Kafka для одного consumer'a. Event Sourcing для TODO-приложения.
CQRS — особенно часто жертва золотого молотка. Не внедряйте CQRS с самого начала. Если у вас нет проблем с производительностью чтения и одна модель работает — не добавляйте вторую. CQRS — это решение для конкретной боли, а не выбор по умолчанию.
#4. Архитектура ради архитектуры (Architecture by Buzzword)
Архитектура выбирается не по потребностям, а по трендам.
«Давай сделаем на Event Sourcing, это же модно» — и следующий релиз через 6 месяцев. Event-Driven не потому что он нужен, а потому что все про него говорят. Kubernetes для статического сайта. Multi-region active-active для локального бизнеса.
#5. Переусложнение (Over-Engineering)
Строить космический корабль для перевозки одного человека.
Симптомы: 20 микросервисов для команды из 5 человек; шестиуровневая абстракция над БД для трёх CRUD-эндпоинтов; Event Sourcing для TODO-приложения (да, это повторяется).
«Архитектура на всякий случай» — это не «задел на будущее», а технический долг, который вы платите сегодня. Проект может умереть, так и не вырастив до своих абстракций.
#6. Big Bang Rewrite
Решение переписать всю систему с нуля.
«Переписывать код с нуля — это легаси-задача в квадрате» — Joel Spolsky. Netscape 6, попытки Twitter переписать свой монолит — классические примеры rewrite and die. Занимает в 2-3 раза больше времени, чем запланировано. Потеря знаний о баг-фиксах, edge cases и бизнес-логике.
Лечение: Strangler Fig Pattern — заменяйте частями, пишите новые фичи на новой архитектуре.
#7. Синхронные цепочки (Synchronous Chain of Calls)
Request → Service A → B → C → D → Response.
P99 latency = сумма всех P99. Если один сервис падает — падает вся цепочка. Лечение: асинхронная обработка, очереди, CQRS.
#8. Общая база данных (Shared Database Integration)
Множество сервисов пишут в одну БД.
Нет изоляции: schema change может сломать другое приложение. Нет владельца: никто не знает, какие данные чьи. Лечение: database-per-service (хотя бы schema-per-service в монолите).
#9. Преждевременный шардинг (Premature Sharding)
Шардирование базы данных до того, как оно реально нужно.
Большинству проектов шардинг никогда не понадобится. PostgreSQL с read replicas и connection pooling обслуживает 99% проектов. Если вы шардируете «на всякий случай» — готовьтесь к операционному аду. См. Главу 20 «Database Scaling».
#10. Не цитируйте CAP теорему как религиозный догмат
«Мы выбрали AP, потому что доступность важнее» — бессмысленная фраза без контекста.
CAP теорема — это аналитический инструмент, а не руководство к действию. Всегда спрашивайте: «Что происходит при разрыве связи?», «Насколько устаревшие данные допустимы?».
#11. Не путайте SRP с «один метод — одно действие»
Это разные вещи. SRP — про причины для изменения (люди). Single Responsibility на уровне функций — про читаемость (важно, но иначе). Если вы разбили класс на 12 маленьких, где каждый делает одно действие, но все меняются по одной причине — вы не применили SRP, вы раздробили код.
#12. Не внедряйте принципы через насилие
«Отныне все классы должны следовать SRP, или код не принимается в ревью». Принципы, внедрённые через администрирование, порождают сопротивление и игры с системой. Лучше: объясните почему, покажите на примере, дайте время привыкнуть.
#Чек-лист
- Я знаю, какие зависимости в моей системе критичны — какие модули сломаются, если изменить один
- Каждый модуль в моей системе можно назвать одним предложением без слов «и», «также», «прочее»
- Я могу объяснить, почему у каждого модуля есть причина для изменения и кто за эти изменения отвечает
- Бизнес-логика не зависит напрямую от конкретной БД, файловой системы или внешнего сервиса
- API наружу — строгие (консервативны на выходе), внутрь — либеральны (терпимы на входе)
- Команды (изменение состояния) и запросы (чтение) не смешаны в одних и тех же методах
- Я знаю, что происходит с системой при разрыве связи между компонентами
- Каждое нарушение принципов — осознанное и задокументированное (ADR или комментарий в коде)
- Наше использование CQRS (если он есть) решает конкретную проблему, а не является данью моде
- Мы не используем одну и ту же базу данных для разных bounded context без явного соглашения
- У нас нет сервисов, которые нельзя продеплоить независимо (отсутствие distributed monolith)
#Ключевые выводы
-
Первые принципы — это инструменты мышления, а не правила. Они помогают анализировать компромиссы, а не диктуют единственно верное решение.
-
Связанность (coupling) и связность (cohesion) — два главных рычага. Управляйте связанностью между модулями и связностью внутри модулей. Всё остальное — детали. О том, как это выглядит на уровне сервисов — в Главе 15.
-
Принципы можно (и нужно) нарушать, но осознанно. Технический долг — это кредит. Берите его, когда нужно, но знайте, когда и как отдавать. См. Главу 5 о балансе сложности.
-
Архитектура — про людей, не про код. SRP — это про причины для изменения, то есть про команды. DIP — про границы ответственности. CAP — про бизнес-требования к отказоустойчивости. Глава 1 раскрывает этот тезис полностью.
-
Лучшая архитектура — та, которую ваша команда может поддерживать. Принципы работают, когда команда их понимает и разделяет. Одна страница ADR с обоснованным компромиссом ценнее ста страниц «идеальной» архитектурной документации. Шаблон ADR — в Приложении B.
-
Реальные системы редко следуют всем принципам одновременно. Amazon, Netflix, Google, Uber — каждая из этих компаний нарушала каждый из описанных принципов в какой-то момент своей истории. Разница между хорошей и плохой архитектурой — не в количестве соблюдённых принципов, а в осознанности компромиссов. Истории их эволюции — в Главе 31.
Эта глава — основа для всех последующих. Понимание coupling, cohesion, SRP, DIP, Postel's Law, CQS/CQRS и CAP — это база, на которой строится любое архитектурное решение. Если какой-то из принципов остался неясным — вернитесь к нему, прежде чем двигаться дальше. Остальные главы будут ссылаться на эти понятия постоянно.
Forward reference: Первые принципы архитектуры не отменяются AI — они становятся важнее. Чем быстрее генерируется код, тем прочнее должен быть архитектурный фундамент. О том, как AI меняет роль архитектора, но не отменяет законов архитектуры — в Главе 32 «AI не отменяет архитектуру — она её требует».