Ruby Backend Engineering: как строят системы, которым доверяют

- -
- 100%
- +
Следующий уровень этого мышления — понять, как эти принципы применяются на уровне фреймворка. Rails предоставляет богатую экосистему инструментов, и большинство из них следуют той же логике: дать разработчику выразить намерение, а не механику. Но только при условии, что разработчик понимает, где заканчиваются возможности фреймворка и начинается ответственность за архитектурные решения.
ГЛАВА 2. RAILS ЗА ПРЕДЕЛАМИ CRUD
Когда я рассказываю коллегам из других технологических стеков, что работаю с Ruby on Rails, часто слышу одну и ту же реакцию: «а, это тот фреймворк для быстрого прототипирования». Иногда добавляют: «он же не подходит для серьёзных систем». Обе фразы объясняются просто: люди знакомы с Rails по его репутации начала 2000-х годов и по статьям, написанным людьми, которые использовали его ровно на том уровне, которым он известен — быстрое создание небольших приложений.
Реальность другая. За шесть лет работы на платформе для проведения электронных закупок я работал с Rails-приложением, которое обрабатывало тендеры и закупочные процедуры. Это система, где изменение статуса процедуры имеет юридические последствия, где одновременно могут работать тысячи участников торгов, и где ошибка в логике заявки может стоить компании контракта. Это не прототип.
Цель этой главы, показать, что Rails за пределами контроллеров и ActiveRecord — это богатая архитектурная экосистема. Не быстрый старт, а зрелая платформа для сложных бизнес-систем. Но только если вы понимаете, где проходят архитектурные границы и какие инструменты для их проведения у вас есть.
2.1. Почему Rails не заканчивается контроллерами и ActiveRecord
Стандартная структура Rails-приложения, которую видит разработчик в первые дни знакомства с фреймворком: папки app/controllers, app/models, app/views. Это MVC (Model-View-Controller) в явном виде. Фреймворк сделал всё, чтобы создать ресурс за несколько минут. И это отличное начало.
Проблема начинается тогда, когда разработчик продолжает использовать только эти три папки на протяжении всего роста приложения. Контроллеры начинают содержать бизнес-логику. Модели ActiveRecord обрастают методами, которые не имеют отношения к данным. Виды получают логику, которой там быть не должно. Система начинает «течь»: логика проникает не в те слои.
Контроллер в Rails отвечает за приём HTTP-запроса, извлечение параметров, вызов бизнес-логики и формирование ответа. Это три чётких задачи. Если в контроллере появляются многострочные условия, вызовы нескольких моделей, сложная подготовка данных для ответа — это сигнал о нарушении ответственности.
Аналогично с моделью. ActiveRecord-модель обеспечивает взаимодействие с базой данных: атрибуты, проверки данных, связи, области видимости. Это её зона ответственности. Когда модель начинает отправлять письма, интегрироваться с внешними API, выполнять сложные бизнес-правила — она перестаёт быть моделью данных и становится чем-то неопределённым.
Rails не запрещает это. Он даже немного поощряет через обратные вызовы (before_save, after_create и другие). Они кажутся удобными: в одном месте описано всё, что происходит при сохранении объекта. Но за этим удобством скрывается опасная связность: теперь вы не можете сохранить объект в тесте без того, чтобы не запустить цепочку побочных эффектов.
Что Rails предоставляет за пределами MVC? Начнём с того, что уже встроено в фреймворк, но часто игнорируется: jobs, mailers, channels, validators, formatters. Кроме этого, сообщество Rails выработало ряд паттернов, которые закрывают пробелы стандартной архитектуры.

Понимание того, что Rails — это платформа, а не только MVC-каркас, открывает путь к рассмотрению конкретных паттернов. Каждый из них решает конкретную проблему, которая возникает при росте приложения.
2.2. MVC в больших приложениях: где проходит реальная граница слоёв
В учебных примерах MVC выглядит просто: модель хранит данные, представление отображает, контроллер координирует. Но в реальном приложении вы быстро обнаруживаете, что многие задачи не укладываются в эту схему однозначно.
Возьмём типичную ситуацию: пользователь подаёт заявку на участие в торговой процедуре. Что происходит? Нужно проверить данные заявки (некоторые поля обязательны только в определённых типах процедур). Убедиться, что пользователь имеет право подавать заявку (роль, статус аккредитации). Сохранить заявку с правильным начальным статусом. Уведомить организатора торгов. Записать событие в аудит. Возможно, обновить счётчик в кэше.
Где это всё должно жить? Если засунуть в контроллер — получим контроллер на сто строк с семью вызовами разных моделей. Если в модель Application — получим модель, которая знает о ролях пользователей, уведомлениях, кэше и аудите. Ни то ни другое не масштабируется.
Реальная граница слоёв в большом Rails-приложении определяется вопросом: что вы тестируете и как легко это тестировать? Если тест контроллера требует создания сложного набора объектов и проверки побочных эффектов в базе — логика в неправильном месте. Если тест модели не может обойтись без подмены внешних сервисов — то же самое.
Отдельно скажу о представлениях и помощниках. В традиционном Rails с ERB-шаблонами они часто становятся ещё одним местом для бизнес-логики. Паттерн Presenter или Decorator решает эту проблему: создаётся объект-обёртка над моделью, который содержит методы форматирования и вычисляемые свойства для представления. Это позволяет держать шаблон простым, а модель — не знающей о том, как она отображается.
Когда речь идёт о реальной границе слоёв — это прежде всего тестируемость. Код хорошо разложен по слоям, если каждый слой можно протестировать изолированно, не поднимая весь стек. Это признак того, что зависимости между слоями разумны.
2.3. Service objects, form objects, query objects и другие способы разгрузить модель
Service object — пожалуй, самый распространённый паттерн в зрелых Rails-приложениях. Идея проста: если у вас есть бизнес-операция (зарегистрировать пользователя, обработать платёж, опубликовать объявление), она заслуживает собственного класса, который инкапсулирует всю логику этой операции.
Типичный service object в Rails выглядит так:
Обратите внимание на несколько вещей. Во-первых, один входной метод call. Это соглашение, которое делает service objects взаимозаменяемыми и предсказуемыми. Во-вторых, явные предусловия в виде bang-методов, которые выбрасывают исключение при нарушении. В-третьих, транзакция, которая гарантирует атомарность: либо всё произошло, либо ничего.
Этот паттерн я использовал для операций с торговыми процедурами. Создание процедуры, изменение её статуса, подведение итогов торгов — каждая из этих операций имела собственный service object. Это позволяло тестировать их изолированно и видеть в коде, какие именно операции с точки зрения бизнеса существуют в системе.

Form object решает другую задачу. В Rails по умолчанию проверка живёт в модели. Но что если одна и та же модель сохраняется через разные формы с разными наборами обязательных полей? Или если форма агрегирует данные из нескольких моделей? Здесь помогает form object, отдельный объект, который моделирует конкретную форму ввода со своим набором проверок.
Query object инкапсулирует сложный запрос к базе данных. Если у вас есть отчёт, который требует нескольких объединений, подзапросов и сложных условий — это хороший кандидат для выделения в query object. Запрос именован, его можно тестировать изолированно с реальной базой данных, и он не засоряет модель.
Важная оговорка: не каждому приложению нужны все эти паттерны. Преждевременная абстракция так же вредна, как отсутствие абстракции. Service objects стоит вводить тогда, когда контроллер стал делать более чем одну бизнес-операцию, или когда одна и та же операция нужна из нескольких мест. Это хорошее практическое правило.
Все перечисленные паттерны объединяет одно: они делают архитектуру явной. Глядя на папку app/services, новый разработчик сразу видит, какие бизнес-операции существуют в системе. Это само по себе форма документации.
2.4. Когда обратный вызов помогает, а когда превращает систему в ловушку
Обратные вызовы (callbacks) в Rails — пожалуй, самая спорная тема в сообществе. Фреймворк предоставляет богатый набор: before_validation, after_validation, before_save, after_save, before_create, after_create, after_commit, after_rollback — и это неполный список. Соблазн велик: можно описать всё, что должно произойти при сохранении объекта, в одном месте, в самом классе.
Рассмотрим конкретный пример. В государственной CRM-системе для работы с заявками, при создании нужно отправить уведомление организатору и записать событие в аудит. Самое простое решение:
Что здесь может пойти не так? Рассмотрим три сценария. Первый: в тестах вы создаёте Application.create! для проверки какой-то бизнес-логики, не связанной с уведомлениями. Но каждый такой вызов запускает отправку письма и запись в аудит. Тесты замедляются, вы вынуждены подменять побочные эффекты.
Второй сценарий: вам нужно импортировать исторические данные из старой системы. Вы создаёте тысячи заявок через Application.create!, и на каждую уходит письмо тысячам пользователей. Чтобы этого избежать, нужно временно отключать обратные вызовы — это не очевидно и легко забыть.
Третий сценарий: ваш обратный вызов зависит от внешнего сервиса. Если этот сервис недоступен, он падает с исключением — и транзакция откатывается. Заявка не сохраняется, хотя данные были корректны.

Означает ли это, что обратные вызовы нужно полностью избегать? Нет. Они оправданы для операций, неразрывно связанных с жизненным циклом объекта данных: before_validation для нормализации данных (нижний регистр, удаление лишних пробелов), after_commit для отложенных задач (именно after_commit, а не after_create — это важно), before_create для генерации идентификаторов и служебных полей.
Практическое правило: если обратный вызов можно запустить в тесте без дополнительных подмен и это не нарушит тест — он, вероятно, уместен. Если для теста нужно подменять обратный вызов — это признак того, что логика в неправильном месте.
Именно это правило подводит нас к следующей теме: Rails API как архитектурная точка, где разделение контроллеров и бизнес-логики становится не просто желательным, а необходимым.
2.5. Rails API как основа современной архитектуры
Одним из значимых этапов в работе стала модернизация платформы: перевод с монолитной архитектуры Rails back + Rails front (ERB-шаблоны) на схему Rails API + Nuxt. Это была полная перестройка слоя взаимодействия с пользователем при полностью сохранённой бизнес-логике на backend.
Что меняется, когда Rails переходит в режим API? Прежде всего, убирается слой представлений из Rails. Ответы формируются в формате JSON. Это упрощает backend-приложение и фокусирует его на том, что оно делает хорошо: обработка бизнес-логики, работа с данными, авторизация.
Но это не только техническое изменение. Rails API — это публичный контракт. Каждый эндпоинт — это соглашение между backend и frontend. Это соглашение должно быть предсказуемым: одинаковые статусы для одинаковых ситуаций, одинаковая структура ошибок. Должно быть версионированным: изменение контракта без версионирования ломает клиентов. И должно быть задокументированным: это не бонус, а часть работы.
Переход на Rails API + Nuxt потребовал нескольких месяцев работы. Основные вызовы: сессионная аутентификация (куки работают иначе при cross-origin запросах), согласование форматов ответов между командами, миграция существующих форм и интерактивных элементов. Но результат оправдал вложения: backend стал чище, frontend получил свободу в выборе UI-паттернов, зоны ответственности двух команд были разделены.
Rails API — это не просто технический выбор. Это решение о том, как организована система в долгосрочной перспективе. Прежде чем переходить на этот путь, стоит честно ответить: какие клиенты будут потреблять API? Насколько сложен UI? Есть ли смысл делить команды?
2.6. Как сохранять скорость разработки без архитектурного хаоса
Rails прославился именно скоростью: от идеи до первой версии приложения за часы, а не недели. Это реальное преимущество. Но по мере роста приложения скорость падает, если архитектура не поддерживается. Это проблема не только Rails, но именно здесь она проявляется особенно остро — из-за того, как легко писать «удобный» код, который создаёт проблемы позже.
Что именно замедляет разработку в большом Rails-приложении? Медленные тесты (приходится запускать весь набор, чтобы убедиться, что ничего не сломано). Неочевидные зависимости (изменение в одном месте ломает что-то совсем в другом). Сложное понимание контекста (чтобы понять, что делает один метод, нужно прочитать несколько файлов). Конфликты при параллельной работе над одними и теми же файлами.
Архитектурные решения, описанные в этой главе, напрямую влияют на эти факторы. Service objects снижают неочевидные зависимости. Form objects ускоряют тесты проверки данных. Query objects делают сложные запросы именованными и изолированно тестируемыми.
Есть и более тонкий фактор: соглашения. Rails силён соглашениями (convention over configuration). Когда команда придерживается единых соглашений — как именовать service objects, как строить ответы API, где жить бизнес-логике — разработчик, открывающий новый файл, уже примерно знает, что его ждёт. Это резко снижает когнитивную нагрузку.
Скорость разработки — это не только скорость написания новой функциональности. Это скорость безопасного изменения существующей. Именно вторая метрика важна для долгоживущего продукта. Архитектура, которая позволяет добавить новую фичу за два дня без страха сломать то, что уже работает — это хорошая архитектура.
Эта глава описала инструменты и принципы архитектуры Rails-приложения за пределами стандартного MVC. Следующий шаг — понять, как эти инструменты применяются в контексте сложной бизнес-логики, которую нужно моделировать внутри монолита.
Rails как фреймворк предоставляет инструменты для построения архитектуры — но не диктует, какой она должна быть. Это одновременно сила и ответственность. Команды, которые воспринимают Rails только как инструмент для быстрого создания приложений, со временем создают системы, которые стагнируют под весом связного неструктурированного кода.
Команды, которые видят в Rails платформу с богатой экосистемой паттернов — service objects, query objects, form objects, явное проектирование API — создают системы, которые остаются управляемыми при масштабировании. Это не сложнее. Это просто требует принятия явных архитектурных решений вместо того, чтобы позволить коду расти самому по себе.
Принципиальный вывод этой главы: архитектурные границы в Rails нужно проводить активно. Не потому что фреймворк плохо устроен, а потому что хороший фреймворк предоставляет свободу — а свобода требует дисциплины.
ГЛАВА 3. ДОМЕННАЯ МОДЕЛЬ В RAILS-МОНОЛИТЕ
Слово «монолит» в инженерной среде приобрело странный оттенок. Его произносят с извинительной интонацией, как будто признаются в чём-то постыдном. «У нас монолит, но мы планируем перейти на микросервисы». Это ожидание перехода, как будто монолит — промежуточная стадия, а не полноценная архитектурная форма.
Я хочу поспорить с этим представлением. Не потому что микросервисы плохи, они решают реальные проблемы в определённых контекстах. А потому что монолит при правильном проектировании может быть зрелой, поддерживаемой, высоконагруженной системой на протяжении многих лет.
Электронная торговая площадка, через которую проходят закупочные процедуры, работает на Rails-монолите. Это не legacy в смысле «устаревшего и страшного». Это production-система, которую мы развивали, модернизировали и масштабировали. Я занимался переводом части этой системы с монолитной структуры на API, но именно потому что понял: проблема была не в монолите как таковом, а в отсутствии чётких доменных границ внутри него.
Эта глава о том, как строить доменную модель внутри Rails-монолита так, чтобы он оставался управляемым при росте. Это разговор о статусах, переходах, транзакциях и о том, где заканчивается ActiveRecord-модель и начинается бизнес-логика.
3.1. Почему монолит может быть зрелой архитектурой, а не техническим долгом
Технический долг — это не синоним монолита. Технический долг — это решения, которые вы приняли сознательно или нет и которые создают работу в будущем. Монолит с хорошей структурой и тестовым покрытием не является техническим долгом. Монолит с запутанной логикой, без тестов и с циклическими зависимостями между модулями — да.
У монолита есть реальные преимущества, которые редко признают явно. Транзакционность: когда вся система работает в одном процессе с одной базой данных, вы можете обернуть любую сложную операцию в транзакцию и получить гарантию атомарности. В микросервисах для этого нужны распределённые транзакции или паттерн Saga — значительно сложнее.
Простота отладки: когда что-то ломается, вы смотрите в один стектрейс, один лог, один процесс. Нет необходимости сопоставлять трейсы между несколькими сервисами, чтобы найти, где именно произошла ошибка.
Развёртывание: один артефакт, один процесс деплоя. Для команд среднего размера это существенная разница в сложности инфраструктуры. Возможность рефакторинга: внутри монолита вы можете переименовывать классы, перемещать методы, менять интерфейсы — и всё это проверяется через тесты. В микросервисах изменение интерфейса одного сервиса требует координации с командами всех потребителей.

Это не означает, что монолит подходит всегда. Когда разные части системы требуют принципиально разных характеристик масштабирования, выделение в отдельный сервис имеет смысл. Когда команды разные и работают автономно, микросервисы позволяют деплоить независимо. Но это специфические случаи, а не общее правило.
Позиция, которую я отстаиваю: начинать с хорошо структурированного монолита и выделять сервисы только тогда, когда есть конкретная причина. Не потому что «так все делают», а потому что конкретная задача требует выделения.
С этим пониманием перейдём к тому, что делает монолит управляемым: доменная модель с чёткими правилами и защитой бизнес-логики.
3.2. Критические правила бизнеса: какие ошибки система обязана блокировать
Каждая бизнес-система имеет набор инвариантов — состояний и переходов, которые никогда не должны нарушаться. Для торговой площадки: ставка в аукционе не может быть выше предыдущей ставки, заявка не может быть подана после окончания срока приёма, победитель не может быть назначен, если нет ни одной заявки. Нарушение этих инвариантов — это не просто ошибка. Это юридическая проблема, финансовая ответственность, потеря доверия пользователей.
Вопрос, который я всегда задаю при проектировании: на каком уровне эти инварианты защищены? Если ответ «только в бизнес-логике», то любой прямой запрос к базе данных (из скрипта миграции, из задачи Sidekiq, из тестовых данных) может нарушить инвариант. Правильный ответ: защита на нескольких уровнях. Бизнес-логика в приложении проверяет инварианты и возвращает понятные ошибки пользователю. База данных поддерживает целостность через constraints, которые являются последним рубежом защиты.
Рассмотрим конкретный пример. Заявка на газификацию может быть в одном из нескольких статусов: черновик, подана, на рассмотрении, одобрена, отклонена. Некоторые переходы допустимы (черновик -> подана, на рассмотрении -> одобрена), некоторые нет (одобрена -> черновик). Это правило должно быть отражено в коде явно:
Этот код явно документирует, какие переходы разрешены. Любая попытка выполнить недопустимый переход вызовет исключение с понятным сообщением. Для самых важных инвариантов стоит добавить ограничение на уровне базы данных — PostgreSQL поддерживает CHECK constraints, которые проверяются при каждой записи.

Критические правила бизнеса должны быть в коде явными и названными. Не просто условием в методе, а именованной концепцией с понятным исключением при нарушении. Это облегчает отладку (вы сразу видите, какое правило нарушено) и документирует доменные ограничения в самом коде.
3.3. Статусы, переходы и жизненные циклы сущностей
Жизненный цикл сущности — один из самых богатых источников бизнес-логики в любой системе. Заявка проходит через статусы. Процедура закупки имеет этапы. Договор подписывается, исполняется, закрывается или расторгается. Каждый переход имеет условия, которые должны выполняться, и действия, которые должны произойти.
В Rails для управления жизненными циклами часто используют гем AASM (Acts As State Machine) или Statesman. AASM — более простой, встраивается прямо в ActiveRecord-модель. Statesman — более строгий, хранит историю переходов отдельно. Вот как выглядит управление жизненным циклом торговой процедуры с AASM:
Что даёт этот подход? Граф переходов явно описан в одном месте. Недопустимые переходы автоматически вызывают исключение. Guards проверяют предусловия перед переходом. After-хуки запускают действия после успешного перехода.
Работая над оптимизацией критических сервисов, связанных с проведением торгов, я столкнулся с реальной проблемой: after-хуки запускались синхронно внутри транзакции, включая медленные операции (уведомления, обновление кэша). Если уведомление занимало секунду, транзакция держала блокировку на секунду. При высоком параллелизме это быстро превращалось в проблему производительности. Решение: выносить медленные операции в фоновые задачи через perform_later, а не выполнять их синхронно в хуке.
3.4. Транзакции как инструмент защиты доменной целостности
Транзакция в базе данных — одна из мощнейших гарантий при работе с данными. Она гарантирует: либо все операции внутри выполнены успешно и зафиксированы, либо ни одна из них не применена. Это свойство называется атомарностью.
В Rails транзакции доступны через ApplicationRecord.transaction {}. Если любая из операций внутри блока вызывает исключение, все изменения откатываются. Договор не будет создан, если изменение статуса процедуры завершилось ошибкой. Запись в аудит не появится, если договор не был создан.
Но есть несколько тонкостей, важных для понимания. Первое: perform_later внутри транзакции ставит задачу в очередь, но задача будет запущена независимо от результата транзакции. Если транзакция откатится после того, как задача уже поставлена, задача выполнится для данных, которых теперь нет. Правильное решение — использовать after_commit хук.
Второе: Rails автоматически открывает транзакцию при вызове save! или create!. Если вы явно открываете ещё одну транзакцию внутри — используются вложенные транзакции. Поведение вложенных транзакций в PostgreSQL специфично: внутренняя транзакция создаёт savepoint, но откат внутренней транзакции не откатывает внешнюю автоматически.
Третья тонкость: не все операции участвуют в транзакции. Запись в лог-файл, HTTP-запрос к внешнему API, отправка письма напрямую — всё это происходит немедленно и не откатывается при откате транзакции. Это нужно учитывать при проектировании операций: действия с внешними побочными эффектами должны выполняться либо после подтверждения транзакции, либо быть идемпотентными.
Разобравшись с транзакционной защитой, подойдём к вопросу, который часто остаётся без явного ответа: где именно в архитектуре Rails-приложения должна жить бизнес-логика?
3.5. Где заканчивается ActiveRecord-модель и начинается бизнес-логика
Это один из самых практически важных вопросов в Rails-архитектуре. Граница между «данными» (ActiveRecord) и «бизнесом» размыта, и Rails не принуждает её соблюдать. Разработчики часто добавляют бизнес-методы прямо в модель, потому что это удобно: модель уже знает о своих атрибутах, об ассоциациях, о базе данных.



