Миграция от CRUD к доменной модели без переписывания приложения
Миграция от CRUD к доменной модели проходит лучше, когда вы сначала рефакторите «горячие» места, оставляете простые экраны простыми и переносите правила только туда, где они действительно важны.

Почему CRUD начинает мешать
CRUD кажется удобным, пока экран только сохраняет запись и показывает её обратно. Форма редактирует клиента, таблица выводит счета, страница админа меняет статус. Такая структура легко строится и некоторое время остаётся понятной.
Проблемы начинаются, когда приложение перестаёт быть набором форм и начинает нести реальные бизнес-правила. Теперь возврат заказа зависит от статуса платежа, статуса доставки, роли пользователя, временных ограничений и того, трогал ли его уже сотрудник поддержки. Данные всё ещё лежат в парах таблиц, но поведение уже не укладывается в простые create, read, update и delete.
Обычно тогда имеет смысл двигаться к доменной модели. CRUD не проблема сам по себе. Проблема в том, что приложение теперь принимает решения, а не только обновляет поля.
Небольшое правило редко остаётся небольшим. Кто‑то добавляет проверку в API. Потом та же проверка появляется в веб‑форме, в админке, в фоновой задаче и в скрипте импорта. Через месяц правило меняется, и одно изменение бизнеса затрагивает пять–шесть файлов. Поскольку структура приложения следует за экранами и таблицами, одно и то же решение копируется во всех путях, которые могут изменить запись.
Эти копии со временем расходятся. На одном экране возврат блокируется через 30 дней. На другом по‑прежнему разрешён за 45. Пакетный процесс пропускает проверку, потому что никто не вспомнил, что она там тоже есть. Каждый кусок выглядит почти правильно сам по себе, поэтому баг становится труднее заметить.
Изменения состояния создают большую часть путаницы. Запись уже не просто «open» или «closed». Она проходит через этапы, и на каждом этапе меняется то, что можно делать дальше. Когда эти переходы живут в разбросанных if‑выражениях, приложение быстро становится непредсказуемым.
Боль обычно видно быстро. Одно и то же правило появляется в нескольких контроллерах, формах и заданиях. Простой запрос на изменение превращается в поиск по всему коду. Разные экраны позволяют разные действия для одной и той же записи. Разработчики начинают спорить, где должен происходить переход состояния.
В этот момент простой CRUD начинает стоить дороже, чем экономить. Сложность не в базе данных. Сложность — в том, чтобы понять, где на самом деле живёт правило, и убедиться, что все пути ему подчиняются.
Что стоит оставить простым CRUD
Не каждому экрану нужна доменная модель. В большинстве миграций самые быстрые выигрыши — оставить скучные части в покое.
Если страница в основном сохраняет простые поля, показывает их и применяет мало бизнес‑логики, CRUD обычно достаточно. Переписывание добавляет код, тесты и движущиеся части, но даёт мало пользы.
Яркий пример — справочные данные: теги, офисы, отделы, налоговые ставки или метки статуса. Люди создают их, переименовывают, иногда архивируют и забывают. Редко там скрыто что‑то глубже.
То же самое касается страниц, ориентированных на чтение, и простых поисковых экранов. Если страница помогает сотрудникам найти записи, отфильтровать по дате или экспортировать таблицу, вам не нужны сложные доменные объекты. Чистый запрос и простая форма чаще всего лучше.
Внутренние страницы с редкими изменениями тоже заслуживают меньше внимания, чем команды думают. Если админская страница изменяется два раза в год и не вызывает обращений в поддержку, оставьте её в покое. Инженеры часто хотят консистентности по всему приложению, но это может превратиться в бессмысленную работу.
Экран обычно можно оставить простым, если пользователи редко его трогают, правила укладываются в несколько явных валидаций, никто не спорит о процессе, а ошибки легко исправить правкой записи. Если страница в основном читает, фильтрует или обновляет сохранённые данные, простой CRUD, скорее всего, достаточен.
Возьмём админскую страницу для офисов с полями: название, адрес, часовой пояс. Сотрудники добавляют офис время от времени, и единственные проверки — «название обязательно» и «часовой пояс должен быть валидным». Такой экран не нуждается в агрегатах, доменных событиях или специальном workflow.
Сохраните усилия для грязных мест, где копятся деньги, риски, согласования или изменения состояний. Оставляйте простые таблицы простыми. Это не лень — это способ закончить рефакторинг.
Сначала найдите горячие места
Миграция идёт лучше, когда вы перестаёте гоняться за каждым экраном и фокусируетесь на тех местах, где ошибки стоят времени, денег или доверия. Большинству приложений не нужен полный переписывание. Список клиентов или страница профиля могут оставаться простыми долгое время.
Проблемы обычно прячутся в потоках, где приложение делает больше, чем сохраняет поля. Ищите экраны, связанные с утверждениями, сменой статуса, деньгами, дедлайнами или любым шагом, где одно действие запускает ещё три. Это места, где логика течёт в контроллеры, обработчики форм, SQL‑запросы и случайные helper‑методы.
Ваша собственная история — лучший старт. Проверьте части приложения, которые уже доставляли боль: баг‑репорты, которые возвращаются после «фикс», тикеты поддержки с пояснениями странных краевых случаев, заметки к релизам с срочными патчами и откаты, связанные с одним workflow, а не со всем приложением.
Это следы показывают, где бизнес‑правила уже переросли простой CRUD.
Обращайте внимание на страх внутри команды. Перед релизом какие экраны вызывают у людей нервозность? Если кто‑то говорит: «Пожалуйста, не трогайте поток утверждений» или «Одна маленькая правка в биллинге ломает что‑то», — это, вероятно, горячая точка. Команды редко переживают из‑за скучных экранов. Они переживают из‑за экранов с скрытыми правилами.
Страница статуса доставки — хороший пример. На виду всё просто: pending, packed, shipped, delivered. Но поддержка может заново открыть заказ, финансы могут заблокировать другие, а поздние поставки запускают возвраты после крайнего срока. Даже если UI всё ещё выглядит как простая форма, это уже не простое обновление.
Начинайте с самой маленькой болезненной области, а не с самой большой кучи мусора. Если один шаг утверждения ломается два раза в месяц, исправьте сначала его. Вынесите правила для этого шага в одно место, оставьте остальной экран как есть и не трогайте нерелевантный CRUD. Маленькие победы упрощают следующий шаг и дают команде доказательство, что рефакторинг стоит усилий.
Переносите по одному правилу
Большие переписывания терпят неудачу, потому что движутся сразу слишком много логики. Безопаснее идти мелкими шагами: выберите один кейс, который мешает (например, cancel order или approve invoice), и вынесите только это правило из CRUD‑обработчиков.
Прежде чем трогать код, запишите правило простым языком. Держите его кратким, чтобы продукт-менеджер, сотрудник поддержки или основатель могли прочитать и согласовать. Например: «An order can be canceled only before shipment. If payment already cleared, create a refund request instead of deleting the order.»
Такое короткое правило делает две вещи. Оно говорит, что система должна делать, и заранее выявляет краевые случаи. Команды часто пропускают этот шаг, а затем прячут половину правила в контроллерах, половину в проверках форм и остаток в триггерах базы. Через неделю никто не знает, какое поведение корректно.
Поставьте правило в одно место. Это может быть небольшой сервис, action или доменный объект. Имя важнее границ — одно место должно решать исход. Экраны, API‑эндпойнты и фоновые задачи должны вызывать этот код вместо того, чтобы хранить локальные копии.
Новый UI не обязателен. Оставьте текущий экран, кнопку и маршрут. Измените экран так, чтобы он вызывал новое правило. Пользователи сохраняют привычный поток, а у вас появляется чистый центр бизнес‑логики.
Простой цикл работает хорошо:
- Выберите одно правило с явной бизнес‑ценностью.
- Запишите его простым языком.
- Перенесите решение в одно место.
- Сохраните старый экран и маршрут.
- Выпустите изменение и смотрите логи, тикеты поддержки и странные случаи.
Потом остановитесь. Если изменение держится в продакшне, переходите к следующему правилу. Если ломается — откатывать придётся только одно небольшое изменение, а не весь рефакторинг. Так обычно и проходят такие миграции: правило за правилом, с тихими релизами и меньше сюрпризов.
Сформируйте небольшую доменную модель
Небольшая доменная модель работает лучше, когда она отражает бизнес, а не базу данных. Если приложение говорит про заказы, возвраты, счета и подписки — используйте эти термины. Имена вроде order_row или customer_record возвращают мышление к таблицам и обычно сохраняют бизнес‑правила разбросанными по контроллерам, обработчикам и SQL.
Начните с бизнес‑сущностей
Каждый объект должен делать одну работу хорошо. Order может знать, оплачен ли он, отменён ли он или готов к возврату. RefundRequest может хранить причину, сумму и статус одобрения. Этого достаточно. Вам не нужна огромная сеть объектов, которые все вызывают друг друга.
Хороший тест прост: если вы можете объяснить объект в одном предложении, он, вероятно, достаточно мал. Если объект считает скидки, проверяет права, отправляет письма и обновляет склад — разделите его. Один объект должен владеть одним типом решения.
Держите изменения состояния рядом с правилом, которое их контролирует. Если только оплаченному заказу можно сделать возврат, код, который переводит заказ в состояние refunded, должен жить рядом с этим правилом. Не проверяйте правило в одном файле, а меняйте состояние в другом. Такой разрыв порождает баги: кто‑то обновит одну сторону и забудет другую.
Держите модель маленькой
Не каждый экран должен подключаться к модели. Страницы поиска, административные фильтры, экспорты и простые детальные виды часто лучше работают как обычные запросы к базе. Они в основном читают и показывают данные. В таких случаях прямые запросы проще читать и дешевле поддерживать.
В этом и смысл инкрементальной доменной работы. Помещайте модель туда, где решения сложны и где правила часто меняются. Оставляйте простые CRUD‑экраны простыми.
Быстрый тест помогает. Если часть приложения навязывает бизнес‑правила, повторяет одно и то же правило в нескольких местах или защищает переходы состояния от неправильных действий, там, вероятно, нужен доменный объект. Если код только читает, сортирует и фильтрует строки — держите его близко к базе.
Небольшая сфокусированная модель даёт чистые правила, не превращая всё приложение в архитектурный проект.
Простой пример с возвратами заказов
Форма возврата кажется безобидной. Вы загружаете заказ, правите несколько полей, сохраняете — и всё выглядит как обычный CRUD.
Это впечатление ломается, когда деньги уже двинулись. Агент поддержки может запросить частичный возврат, но платёжный провайдер уже выплатил продавцу. Финансовый менеджер может одобрить большую сумму, чем поддержка. Та же форма теперь зависит от статуса, суммы и того, кто совершает изменение.
Если оставить это как plain CRUD, проверки часто оказываются разбросанными по контроллерам, обработчикам форм и триггерам базы. Кто‑то правит один экран, забывает другой путь, и возвраты начинают проходить по неверным правилам.
Поместите правила в один метод возврата
Лучший шаг — маленький и локальный. Оставьте страницу списка заказов простым CRUD, если она только показывает данные, фильтрует и открывает детали. Этот экран не нуждается в полной доменной модели.
Поток возврата нуждается. Дайте заказу или сервису по возвратам один метод, который решает, можно ли сделать возврат, до любых сохранений в хранилище.
Этот метод может проверять несколько фактов:
- Выплачена ли уже продавцу сумма?
- Полный это возврат или частичный?
- Кто выполняет действие: поддержка, финансы или админ?
- Был ли уже ранее произведён возврат по этому заказу?
Когда эти правила живут в одном месте, форма становится тоньше. Она собирает ввод и вызывает метод возврата. Метод либо принимает запрос, либо отклоняет с понятной причиной.
Представьте заказ на $120. Поддержка пытается вернуть $100 после выплаты. Метод возврата блокирует это и сообщает, что только финансы могут одобрить пост‑выплатный возврат выше допустимого лимита. Финансы открывают тот же поток, одобряют, и система фиксирует правильную причину и сумму.
Это и есть цель рефакторинга. Вы не перестраиваете всю область заказов. Вы оставляете скучные экраны как есть и добавляете структуру только там, где бизнес‑правила постоянно создают проблемы.
Ошибки, которые тормозят рефакторинг
Большинство команд теряют скорость, когда пытаются сделать всё «чистым» за один проход. Лучше двигаться по‑мелочи. Выберите один болезненный поток, докажите, что новая форма работает, и остановитесь. Если логика возвратов ломается — исправьте её сначала. Оставьте страницу профиля клиента в покое, если она всё ещё работает как простое create/edit/delete.
Ещё одна распространённая ошибка — позволить каждому объекту самостоятельно общаться с базой. Это выглядит аккуратно неделю, а потом вы тратите часы на трассировку скрытых запросов и странных побочных эффектов. Держите persistency в понятных местах. Доменные объекты должны хранить правила и изменения состояния, а не неожиданные чтения и записи.
Тесты важны на рискованных путях, а не везде сразу. Если двигаются деньги, меняются права или склад может уйти в минус, заблокируйте эти пути тестами перед переносом правил. Даже несколько сфокусированных тестов уберегут вас от цикла «исправили один баг — выпустили два новых».
Глубокие иерархии классов создают другой тип торможения. Малому приложению не нужны RefundBase, RefundPolicyBase, AbstractRefundHandler и пять подклассов, чтобы решить, разрешён ли возврат. Начните более плоско, чем кажется элегантным. Одна простая служба, одна сущность и пара явных методов часто лучше сложного дизайна.
Ещё ловушка — превращать каждое редактирование поля в бизнес‑событие. Если агент поддержки правит опечатку в адресе, вам вряд ли нужен AddressCorrected, CustomerEdited, AuditMetadataUpdated и очередь обработчиков. Сохраняйте такую механнику для действий, меняющих бизнес‑смысл.
Быстрая проверка полезна: может ли команда объяснить новый поток на доске за две минуты? Можно ли протестировать рискованный кейс, не поднимая всё приложение? Видно ли, где чтения, а где записи? Вы рефакторили одну болезненную область или объём разросся сам по себе?
Если ответ «нет», рефакторинг, вероятно, стал слишком абстрактным, широким или преждевременным. Верните фокус к горячей точке и сделайте эту часть скучной сначала.
Быстрые проверки после каждого изменения
Малые рефакторинги идут неправильно, когда остаётся одно новое правило, а три старых копии виснут в коде. После каждого изменения остановитесь на пару минут и проверьте основы. Не нужен полный аудит — нужна уверенность, что одно правило стало чище, безопаснее и проще объяснимо.
Начните с самого правила. Если возврат разрешён только для оплаченных заказов, это правило теперь должно жить в одном месте. Контроллер не должен проверять его, затем задача в фоне — снова, а скрипт — в третий раз. Выберите один дом для правила и пусть все пути его вызывают.
Затем протестируйте старый экран, который люди уже используют. Это важнее, чем многие думают. Доменный код может выглядеть аккуратно и одновременно ломать форму, кнопку или сообщение со статусом, на которые опираются сотрудники. Откройте экран, пройдите по обычному пути и убедитесь, что он ведёт себя так же, кроме ожидаемого изменения.
Короткий рефакторинг:
- Поискать в контроллерах, задачах и обработчиках старые копии проверки.
- Удалить дублирующиеся условия, когда общее правило работает.
- Сделать сообщения об ошибках консистентными между веб‑запросами и фоновыми задачами.
- Обновить один тест на правило и один тест на экран.
Данные поддержки покажут, помогло ли изменение в реальной жизни. Сравните число и тип проблем до и после рефакторинга. Не нужен большой дашборд — простой подсчёт подойдёт. Если люди перестали спрашивать, почему один путь принимает изменение, а другой отвергает, вы решили настоящую проблему.
Один последний полезный тест: попросите кого‑нибудь в команде объяснить новый поток в одном предложении. Если ответ: «Все запросы на возврат проходят через единую политику возврата перед сохранением», — это хороший знак. Если объяснение требует пять исключений и два побочных примечания, код всё ещё несёт слишком много старого CRUD‑поведения.
Именно это делает миграцию устойчивой. Каждый шаг должен делать правило проще найти, легче доверять и сложнее дублировать снова.
Следующие шаги для поэтапной миграции
После одного удачного рефакторинга большинство команд хочет почистить всё приложение. Это нормальное желание, но оно обычно делает работу медленнее и рискованнее. Такая миграция работает лучше, когда вы остаётесь избирательными.
Ранжируйте только следующие три горячих места. Задайте два простых вопроса: что сломается или будет стоить денег, если это останется грязным, и насколько сложно это изменить без вмешательства в половину приложения? Это даст практический порядок вместо списка желаемого.
Простой скоринг достаточен. Поместите чаще возникающие баги и тяжёлые для поддержки потоки наверх. Поднимите правила, связанные с деньгами, правами или рисками соответствия. Опустите широкие, запутанные области, если они требуют слишком много координации. Держите вне сферы низко‑рисковые экраны, даже если код выглядит уродливо.
Потом выберите самое маленькое правило с очевидной бизнес‑ценностью. Хорошая цель — одно правило, которое команда уже ощущает: лимиты одобрения возврата, изменения статуса заказа, проверки скидок или сроки выставления счёта. Если одно изменение может сократить повторяющиеся ошибки или сэкономить несколько часов поддержки в неделю, это лучше, чем большая чистка с расплывчатой пользой.
Оставляйте простые CRUD‑экраны в покое, пока они действительно не мешают. Если экран лишь создаёт, редактирует и перечисляет простые записи, полная доменная модель может добавить кода больше, чем пользы. Скучные экраны — это нормально. Команды тратят много времени на шлифовку форм, которые не несут бизнес‑правил.
Назначьте точку обзора после первого рефакторинга, прежде чем копировать паттерн на другие области. Проверьте четыре вещи: стало ли правило легче тестировать, снизилось ли количество багов, поняла ли команда новую форму и не замедлилась ли доставка слишком сильно? Если хоть один ответ «нет», скорректируйте подход перед расширением.
Иногда спокойный внешний взгляд помогает. Oleg Sotnikov at oleg.is работает с стартапами и малыми командами как fractional CTO и советник, и такой поэтапный cleanup — именно то, где может пригодиться сторонняя перспектива. Цель не в грандиозном переписывании, а в выборе нескольких правил, которые реально нуждаются в моделировании, и в сохранении остального приложения в покое.
Часто задаваемые вопросы
When does a CRUD screen stop being simple CRUD?
Stop calling it simple CRUD when one action depends on status, role, time limits, approvals, or money. That usually means the app makes decisions, not just saves fields.
You will also feel it in day to day work. One rule shows up in several files, small changes turn into code searches, and different screens allow different actions for the same record.
Do I need to rewrite the whole app to move toward a domain model?
No. Keep the stable, boring parts as they are and refactor only the painful flow first.
That approach lowers risk and lets you prove the new shape works before you touch anything else.
Which parts of the app should stay plain CRUD?
Leave screens alone when they mostly store plain fields, show data, filter records, or export tables. Reference data like tags, office locations, departments, and tax rates usually fits plain CRUD just fine.
If users rarely touch a screen and staff can fix mistakes by editing the record, you probably do not need a domain model there.
How do I choose the first area to refactor?
Start where bugs keep coming back or where the team gets nervous before a release. Approval flows, billing, refunds, shipment states, and deadline rules often cause the most pain.
Support tickets and rollback history give you a strong signal. If one workflow keeps causing trouble, refactor that boundary first.
What should I do before I move a business rule out of CRUD code?
Write the rule in plain language before you touch the code. A short sentence like An order can be canceled only before shipment forces the team to agree on the behavior.
That step also exposes edge cases early, before they hide in controllers, forms, and jobs.
Where should the business rule live after the refactor?
Put the decision in one place and make every path call it. That place might be a small service, action, or domain object; the name matters less than the boundary.
Do not leave one copy in the controller, another in the form, and a third in a background job. One shared rule stops drift.
Do I need a new UI or new routes for this migration?
Usually no. Keep the same screen, button, and route, then change the code behind them.
Users keep their normal workflow while you move the rule to a cleaner center. That makes the change easier to ship and easier to roll back if needed.
How small should the domain model be?
Keep the model small enough that you can explain each object in one sentence. If an object handles refunds, permissions, emails, and stock all at once, split it.
Use business names like Order, RefundRequest, or Invoice. Those names keep your code close to the real process instead of the table layout.
What mistakes make this kind of refactor drag on?
Teams lose speed when they try to clean the whole app in one pass. They also create trouble when every object talks to the database or when they build deep class trees for a simple rule.
Focus on risky paths first, keep reads and writes easy to find, and avoid turning every tiny field edit into a business event.
How can I tell if each refactor step actually worked?
After each change, make sure one shared rule replaced the old copies. Then click through the existing screen and confirm it still works the way staff expect.
Watch logs, support issues, and a couple of focused tests. If the team can explain the new flow in one clear sentence, you probably moved the rule to the right place.