
Представим, перед командой разработки встает задача. Необходимо реализовать конвейер ипотечных заявок.
Что делает современная команда? Проектирует архитектуру. И это почти всегда микросервисы. И во многих случаях это оправдано: независимые релизы, масштабируемость, гибкость в развитии системы.
Появляются:
сервис заявок
сервис статусов
сервис workflow
сервис документов
сервис аудита
сервис оркестрации и, конечно же, сервис для сервиса оркестрации
Потом возникает gateway. Потом discovery. Потом tracing. Потом отдельная база под каждый сервис.
И вот мы уже тонем в интеграции микросервисов, распределённых транзакциях и инфраструктурных нюансах. А ведь бизнесу по-прежнему требуется просто инструмент, который быстро приносит ценность.
Можно ли решить проще?
На текущий момент есть целая группа решений — low-code платформ, призванных ускорить разработку целого ряда решений, сконцентрировавшись на реализации нужных бизнесу фич, при этом по минимуму обращая внимание на детали технической реализации.
Такие платформы позволяют достаточно быстро собирать процессы, работать с данными и описывать бизнес-логику, минимально погружаясь в технические детали.
При этом разработчики, в том числе и автор статьи, относятся к подобным инструментам с определённой осторожностью. Low-code решения нередко выглядят как "чёрный ящик": часть логики скрыта внутри платформы, и в критический момент это может осложнить диагностику и повлиять на предсказуемость системы.
Но этот тренд не получается игнорировать: low-code платформы быстро набирают популярность, а в такой ситуации самый верный подход - проверить всё на практике.
Было решено реализовать конкретный процесс с помощью low-code платформы Nuxeo, чтобы понять, где она действительно упрощает жизнь, а где добавляет новые ограничения. Для этого и посмотрим, что из себя представляет Nuxeo и как он может лечь в основу такого конвейера.
Что такое Nuxeo и как он вяжется с конвейером?
Простыми словами, Nuxeo — это платформа не просто для хранения файлов, а инструмент для работы с бизнес-сущностями в виде документов. При этом понятие "документ" в данном контексте — это не просто PDF-файл. Это отдельный объект с метаданными, структурой, жизненным циклом.

Nuxeo позволяет быстро и удобно реализовывать задачи, ориентированные на работу с документами, статусные модели и действия пользователей. Появляется возможность описать свой тип документа, добавить свои метаданные, переходы между состояниями. В результате структура данных и их поведение во многом определяются на уровне модели документа и конфигурации платформы, а не только кодом приложения.
Как раз ипотечная заявка и является таким документом, так она имеет собственные атрибуты (доход, запрашиваемая сумма, скоринг), вложения (документы клиента), состояния (draft, submitted, scoring и т.д.) и бизнес-логику переходов между ними. Всё это — нативные возможности платформы, которые не нужно собирать из отдельных сервисов.
Строим жизненный цикл ипотечной заявки
Чтобы не уходить в абстракцию, опишем конкретный жизненный цикл ипотечной заявки, который будет реализовываться. Выделим несколько основных состояний ипотечной заявки: Draft, Submitted, Scoring, Committee, Approved, Rejected. Также опишем условия перехода между состояниями.

Draft
Процесс начинается с создания заявки. Загружается файл с заявлением, но сама заявка еще не готова к работе. Присваивается статус draft.
Submitted
Далее требуется подгрузить необходимые документы. Предположим, необходимо проверить, что к заявлению прикреплены необходимые документы: паспорт, СНИЛС, 2НДФЛ. Для упрощения примера будем использовать проверку по имени файла. Когда пользователь прикрепил все, что необходимо, документ автоматически переходит в статус submitted.
Scoring
Далее будет необходимо заполнить поля заявки: доход клиента, запрашиваемую сумму, а также кредитный рейтинг. При указании значения ключевых полей, влияющих на скоринг, статус заявки переходит в scoring — начинается процесс просчета риска.
Committee
После заполнения всех необходимых для просчета риска полей автоматически просчитывается значение риска, обновляется соответствующее поле, заявка переходит в committee.
Approved/Rejected
После перехода документа в статус committee у пользователя должны появиться кнопки, которые позволят принять решение пользователю: принять (approved) или отклонить (rejected) заявку.
Как это устроено в Nuxeo?
В терминах Nuxeo жизненный цикл (lifecycle) — это не просто набор статусов, а декларативно заданная модель состояний (states) и переходов (transitions) между ними. Lifecycle настраивается на уровне конфигурации и определяет, в каком состоянии может находиться документ и каким образом он может перейти в следующее состояние.
Важно, что переход между состояниями — это не просто изменение поля, а использование встроенного механизма платформы, который фиксирует изменения в истории документа, позволяет ограничивать переходы и централизованно управлять бизнес-процессом.
Сам lifecycle настраивается в Nuxeo Studio (визуальный инструмент платформы, в котором описываются типы документов, их жизненный цикл, схемы данных и бизнес-правила без необходимости писать большую часть кода вручную): для типа документа определяется набор состояний, начальное состояние и допустимые переходы между ними. Ниже — реализация вышеописанного lifecycle ипотечной заявки, построенная через Studio:

Благодаря механизму lifecycle переходы между состояниями контролируются самой платформой Nuxeo, а не реализуются вручную через изменение полей или разрозненный код. Это довольно удобно, потому что документ может перейти только в допустимое состояние в соответствии с заданной моделью. В результате все изменения становятся предсказуемыми, централизованными и автоматически фиксируются в истории документа.
Создание документа (Draft). Создаем свою ипотечную заявку
Прежде чем строить сам конвейер, нужно определить: с чем именно мы работаем, а именно описать тип документа, ипотечную заявку, которая станет основой процесса и будет хранить всю необходимую информацию.
В Nuxeo «документ» — это не просто файл. Это структурированный объект данных, у которого может быть вложенный файл, но он является лишь одним из полей документа наряду с другими атрибутами.
Помимо самого заявления нам нужны и другие атрибуты:
Номер заявки
Запрашиваемая сумма
Уровень риска
Статус заявки
Срок ипотеки
Имя клиента
Доход клиента
Кредитный рейтинг клиента
В Nuxeo структура документа задается через схемы (schemas). Схемы — это часть модели данных, которые физически определяют набор полей документа и их типы. Они позволяют декомпозировать модель документа на логические части и переиспользовать их между типами документов. В нашем случае данные клиента и данные заявки разделены, потому что они могут изменяться независимо и потенциально использоваться в других процессах. Имеем схемы:
Данные клиента (mortgage_applicant)
Данные заявки (mortgage_application)
Тут важно понимать, что мы создаём собственный тип документа, который расширяет базовый функционал стандартного типа File. Это означает, что наш документ уже «из коробки» умеет работать с файлом (хранить бинарное содержимое, имя файла и т.д.), но при этом мы добавляем к нему поля, специфичные для ипотечной заявки.
Таким образом, создаваемый тип документа объединяет:
Стандартное файловое поведение (унаследованное от File)
Набор доменных атрибутов, описанных через схемы
Это позволяет рассматривать ипотечную заявку не как набор разрозненных файлов, а как единый объект, который проходит через бизнес-процесс и несёт в себе всё необходимое состояние.

После определения схем данных для ипотечной заявки они, как и lifecycle, создаются через Nuxeo Studio. Вместо ручной разработки модели данных мы:
создаём тип документа
определяем схемы (schemas)
связываем их с типом
наследуем поведение от базового типа File.




Отмечу, что несмотря на то, что для наглядности я создаю схемы, тип документа через Studio, есть возможность создавать схемы через xml конфигуацию в JAR bundle. Например, вот такой код создает Nuxeo, когда мы определяем новую схему:
<!-- data/schemas/mortgage_applicant.xsd: --> <?xml version="1.0" encoding="UTF-8"?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:nxs="http://www.nuxeo.org/ecm/project/schemas/Test0124/mortgage_applicant" xmlns:nxsv="http://www.nuxeo.org/ecm/schemas/core/validation/" xmlns:ref="http://www.nuxeo.org/ecm/schemas/core/external-references/" targetNamespace="http://www.nuxeo.org/ecm/project/schemas/Test0124/mortgage_applicant"> <!-- helper XSD definitions for list types --> ... <xs:element name="creditScore" type="xs:integer"/> <xs:element name="income" type="xs:double"/> <xs:element name="name" type="xs:string"/> </xs:schema> <!-- data/schemas/mortgage_application.xsd --> <?xml version="1.0" encoding="UTF-8"?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:nxs="http://www.nuxeo.org/ecm/project/schemas/Test0124/mortgage_application" xmlns:nxsv="http://www.nuxeo.org/ecm/schemas/core/validation/" xmlns:ref="http://www.nuxeo.org/ecm/schemas/core/external-references/" targetNamespace="http://www.nuxeo.org/ecm/project/schemas/Test0124/mortgage_application"> <!-- helper XSD definitions for list types --> ... <xs:element name="applicationNumber" type="xs:string"/> <xs:element name="requestedAmount" type="xs:double"/> <xs:element name="riskLevel" type="xs:string"/> <xs:element name="status" type="xs:string"/> <xs:element name="termMonths" type="xs:integer"/> </xs:schema> <extension target="org.nuxeo.ecm.core.schema.TypeService" point="schema"> <!-- Регистрируются схемы --> <schema name="mortgageapplication" prefix="mortgageapplication" override="true" src="data/schemas/mortgageapplication.xsd"/> <schema name="mortgage_applicant" prefix="appl" override="true" src="data/schemas/mortgage_applicant.xsd"/> <schema name="mortgage_application" prefix="mort" override="true" src="data/schemas/mortgage_application.xsd"/> </extension> <extension target="org.nuxeo.ecm.core.schema.TypeService" point="doctype"> <!-- Регистрируется тип данных, указываются используемые схемы --> <doctype name="MortgageApplication" extends="File"> <schema name="mortgageapplication"/> <schema name="mortgage_applicant"/> <schema name="mortgage_application"/> </doctype> <!-- ... --> </extension>
Теперь, после сохранения, можем перейти в Web UI, попытаться создать новый тип документа. Видим наш новый тип — MortgageApplication.

Чтобы пользователь мог работать с данными документа через интерфейс, необходимо настроить их отображение. В Nuxeo это делается с помощью layout-ов, которые определяют, какие поля показываются в форме, в каком порядке и как именно они представлены (input, dropdown и т.д.).
Если говорить проще, layout — это описание формы, которую видит пользователь.
В базовом сценарии работа с layout-ами происходит через Nuxeo Studio: нужные поля из схемы можно просто перетащить в форму, после чего платформа автоматически сгенерирует UI.
Пример того, как выглядит форма для просмотра документа ипотечной заявки:

Настроено базовое отображение документа в режиме просмотра (view layout). Форма простая: подписи полей оставлены максимально близкими к их внутренним именам — Credit Score, Income, Application Number и т.д. Это сделано по причине того, что на этапе разработки важно видеть прямую связь между полями модели данных и тем, как они отображаются в интерфейсе. Это упрощает отладку и позволяет быстрее ориентироваться в структуре документа.
При необходимости, в Studio можно создавать формы напрямую через код. К примеру, при создании формы, представленной выше, автоматически был создан следующий код:

Однако такой «технический» layout не всегда удобен для конечного пользователя. Поэтому имеет смысл выделить ключевые поля заявки и представить их в более понятном виде.
Для этого по аналогии с основным представлением документа настраивается отдельный layout для блока metadata, в котором:
используются более понятные подписи полей
отображаются только важные поля

Вот так форма будет выглядеть, когда пользователь будет просматривать документ:

И форма metadata:

После того, как мы сказали Nuxeo о том, как ему отображать формы, связанные с представлением нашего нового типа данных, можем создать заявку.

Поскольку делаем конвейер и для нас важен жизненный цикл, обращаем внимание, что созданному документу автоматически был присвоен статус Draft, так как при создании типа документа мы указали lifecycle, у которого данное состояния указано как Initial State.

Подгрузка необходимых документов (Submit)
Итак, мы создали документ нового типа — MortgageApplication. Выше мы упоминали: ключевое понятие кредитного конвейера — жизненный цикл ипотечной заявки. После создания базового документа типа MortgageApplication необходимо подгрузить обязательные документы. При подгрузке документов с именем passport, snils, 2ndfl заявка перейдет в статус submitted (принята в работу).
Переход заявки в состояние Submitted не будет происходить вручную. Он автоматизируется: система отслеживает изменения документа и при выполнении условий (наличие обязательных документов) инициирует смену состояния через automation chain.
Для реализации такого поведения используется backend-расширение Nuxeo — OSGI bundle. По сути, это обычный JAR с Java-кодом, который подключается к платформе и позволяет разработчику реализовать собственную логику: обрабатывать события, валидировать данные и вызывать операции. В нем при помощи библиотеки nuxeo-automation-core реализуем собственный слушатель событий и все необходимые смежные компоненты.
После создания заявки в состоянии Draft пользователь загружает обязательные документы. Система автоматически отслеживает изменения документа и проверяет, выполнены ли условия для перехода. Если все обязательные документы загружены, статус заявки меняется на Submitted без участия пользователя.
Ниже — пример базового обработчика событий, который используется для отслеживания изменений документа ипотечной заявки. Предусмотрена фильтрация событий по типу документа и событию изменения.
package com.madela.dev.listener; import org.nuxeo.ecm.automation.AutomationService; import org.nuxeo.ecm.automation.OperationContext; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.event.Event; import org.nuxeo.ecm.core.event.EventListener; import org.nuxeo.ecm.core.event.impl.DocumentEventContext; import org.nuxeo.runtime.api.Framework; /** * Базовый абстрактный класс для всех обработчиков событий ипотеки. * Инкапсулирует общую логику фильтрации событий и запуска цепочек автоматизации. */ public abstract class AbstractMortgageListener implements EventListener { public static final String MORTGAGE_APPLICATION_DOC_TYPE = "MortgageApplication"; public static final String DOCUMENT_MODIFIED_EVENT_TYPE = "documentModified"; /** * Основной метод обработки события Nuxeo. * Здесь происходит фильтрация событий. * * @param event пришедшее событие */ @Override public void handleEvent(Event event) { // Если событие не связано с изменением документа - пропускаем if (!DOCUMENT_MODIFIED_EVENT_TYPE.equals(event.getName())) { return; } // Убеждаемся, что контекст события связан именно с документами, // чтобы избежать ошибок приведения типов далее if (!(event.getContext() instanceof DocumentEventContext)) { return; } DocumentEventContext ctx = (DocumentEventContext) event.getContext(); // Извлекаем сам объект документа, с которым произошло событие. DocumentModel doc = ctx.getSourceDocument(); // Если тип этого документа НЕ ипотечная заявка» - пропускаем if (!MORTGAGE_APPLICATION_DOC_TYPE.equals(doc.getType())) { return; } // Вызываем логику processEvent(doc); } /** * Абстрактный метод для реализации логики перевода документа по состояниям * * @param doc документ, с которым происходит действие */ protected abstract void processEvent(DocumentModel doc); /** * Метод для вызова цепочки событий по идентификатору цепочки * * @param doc передаваемый документ * @param chainId идентификатор цепочки событий */ protected void runChain(DocumentModel doc, String chainId) { AutomationService automationService = Framework.getService(AutomationService.class); try (OperationContext opCtx = new OperationContext(doc.getCoreSession())) { opCtx.setInput(doc); automationService.run(opCtx, chainId); } catch (Exception e) { throw new RuntimeException("Ошибка при вызове цепочки: " + chainId, e); } } }
Далее реализуем валидатор, который будет проверять, прикреплены ли все необходимые документы:
package com.madela.dev.validator.impl; import com.madela.dev.validator.AttachmentValidator; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.DocumentModel; import java.util.Arrays; import java.util.List; import java.util.Map; /** * Класс для валидации документа. * Проверяем, содержит ли документ все необходимые attachments */ public class RequiredDocumentsValidator implements AttachmentValidator { private static final List<String> REQUIRED_FILES = Arrays.asList("passport", "snils", "2ndfl"); @Override public boolean isValid(DocumentModel doc) { List<Map<String, Object>> files = (List<Map<String, Object>>) doc.getPropertyValue("files:files"); if (files == null || files.isEmpty()) return false; // Возвращаем true, если для каждого названия файла в списке REQUIRED_FILES // соответствует как минимум один attachment return REQUIRED_FILES.stream().allMatch(req -> files.stream() .map(file -> file != null ? (Blob) file.get("file") : null) .anyMatch(blob -> blob != null && blob.getFilename() != null && blob.getFilename().toLowerCase().contains(req)) ); } }
Реализация слушателя, который отвечает за переход из состояния Draft в Submitted. Содержит в себе валидатор и проверяет подходит ли входящий документ под условие смены статуса.
package com.madela.dev.listener; import com.madela.dev.model.MortgageStatus; import com.madela.dev.validator.impl.RequiredDocumentsValidator; import org.nuxeo.ecm.core.api.DocumentModel; public class MortgageDraftToSubmittedListener extends AbstractMortgageListener { public static final String CHANGE_STATE_FROM_DRAFT_TO_SUBMITTED_CHAIN = "Mortgage.ChangeStateFromDraftToSubmittedChain"; private final RequiredDocumentsValidator validator = new RequiredDocumentsValidator(); @Override protected void processEvent(DocumentModel doc) { // Если текущее состояние документа - Draft И все необходимы документы загружены - меняем состояние // документа с Draft на Submitted путем вызова цепочки Mortgage.ChangeStateFromDraftToSubmittedChain if (MortgageStatus.DRAFT.getName().equals(doc.getCurrentLifeCycleState()) && validator.isValid(doc)) { runChain(doc, CHANGE_STATE_FROM_DRAFT_TO_SUBMITTED_CHAIN); } } } package com.madela.dev.model; public enum MortgageStatus { DRAFT("draft", "none", "Создан"), SUBMITTED("submitted", "to_submitted", "Принят в работу"), SCORING("scoring", "to_scoring", "Скоринг"), COMMITTEE("committee", "to_committee", "Принятие решения"), APPROVED("approved", "to_approved", "Заявка одобрена"), REJECTED("rejected", "to_rejected", "Заявка отклонена"); private final String name; private final String transitionStep; private final String description; MortgageStatus(String name, String transitionStep, String description) { this.name = name; this.transitionStep = transitionStep; this.description = description; } public String getName() { return name; } public String getTransitionStep() { return transitionStep; } public String getDescription() { return description; } }
При выполнении условия перевода ипотечной заявки в новый статус вызывается Automation Chain (цепочка операций) с идентификатором Mortgage.ChangeStateFromDraftToSubmittedChain. Automation Chain используется как декларативный способ описания последовательности действий. Это позволяет вынести бизнес-логику из Java-кода и управлять поведением системы через конфигурацию. Ее можно создать через XML определение следующим образом:
<extension point="chains" target="org.nuxeo.ecm.core.operation.OperationServiceComponent"> <chain id="Mortgage.ChangeStateFromDraftToSubmittedChain"> <operation id="Mortgage.TransitionToSubmittedOperation"/> </chain> </extension>
Также необходимо создать саму операцию, на которую ссылается цепочка:
package com.madela.dev.operation.system; import com.madela.dev.model.MortgageField; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.core.annotations.OperationMethod; import org.nuxeo.ecm.core.api.CoreSession; import org.nuxeo.ecm.core.api.DocumentModel; public abstract class AbstractTransitionOperation { protected abstract CoreSession getCoreSession(); protected abstract MortgageStatus getTargetStatus(); @OperationMethod public DocumentModel run(DocumentModel doc) { doc.followTransition(getTargetStatus().getTransitionStep()); doc.setPropertyValue(MortgageField.STATUS.xpath(), getTargetStatus().getDescription()); return getCoreSession().saveDocument(doc); } } @Operation(id="Mortgage.TransitionToSubmittedOperation", category="Mortgage") public class TransitionToSubmittedOperation extends AbstractTransitionOperation { @Context protected CoreSession session; @Override protected CoreSession getCoreSession() { return this.session; } @Override protected MortgageStatus getTargetStatus() { return MortgageStatus.SUBMITTED; } }
После сборки нашего bundle-а и его импортирования в Nuxeo при загрузке необходимых документов увидим, что документ автоматически поменял статус на submitted.


Таким образом, когда к документу типа MortgageAppliсation будет прикладываться необходимый набор документов, статус документа будет переводиться в Submitted (Принят в работу).
Что в итоге получилось:

Создан свой Java класс MortgageDraftToSubmittedListener, имплементирующий EventListener Nuxeo, который ловит события типа documentModified.
При получении информации о событии, извлекается информация из документа. Если документ подходит под условие — вызывается цепочка операций Mortgage.ChangeStateFromDraftToSubmittedChain.
Цепочка в данном случае вызывает всего одну операцию —TransitionToSubmittedOperation, которая переводит ипотечную заявку в state submitted.
Автоматический подсчет уровня риска (Scoring)
После перехода заявки в статус Submitted пользователь начинает заполнять данные, необходимые для расчёта кредитного риска: доход, запрашиваемая сумма и кредитный рейтинг. Как только система фиксирует, что необходимые данные начали появляться в документе, заявка автоматически переводится в статус Scoring.
package com.madela.dev.listener; import com.madela.dev.model.MortgageField; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.core.api.DocumentModel; public class MortgageSubmittedToScoringListener extends AbstractMortgageListener { public static final String CHANGE_STATE_FROM_SUBMITTED_TO_SCORING_CHAIN = "Mortgage.ChangeStateFromSubmittedToScoringChain"; @Override protected void processEvent(DocumentModel doc) { if (!MortgageStatus.SUBMITTED.getName().equals(doc.getCurrentLifeCycleState())) { return; } // Если ни одно из полей не заполнено - пропускаем событие if (isScoringDataNotPresent(doc)) { return; } // Если заполнено хотя бы одно поле - вызываем цепочку операций для // смены статуса заявки на Scoring runChain(doc, CHANGE_STATE_FROM_SUBMITTED_TO_SCORING_CHAIN); } /** * Метод проверяющий, заполнено ли хотя бы одно из полей: * кредитный рейтинг, запрашиваемая сумма, доход */ private boolean isScoringDataNotPresent(DocumentModel doc) { return doc.getPropertyValue(MortgageField.CREDIT_SCORE.xpath()) == null && doc.getPropertyValue(MortgageField.REQUESTED_AMOUNT.xpath()) == null && doc.getPropertyValue(MortgageField.INCOME.xpath()) == null; } }
Цепочка: Mortgage.ChangeStateFromSubmittedToScoringChain
<?xml version="1.0"?> <component name="com.madela.dev.operation.mortgagechengestatechains"> ... <extension point="chains" target="org.nuxeo.ecm.core.operation.OperationServiceComponent"> <chain id="Mortgage.ChangeStateFromSubmittedToScoringChain"> <operation id="Mortgage.TransitionToScoringOperation"/> </chain> </extension> ... </component>
И аналогичная операция для смены статуса на Scoring:
import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.core.annotations.Context; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.core.api.CoreSession; @Operation(id="Mortgage.TransitionToScoringOperation", category="Mortgage") public class TransitionToScoringOperation extends AbstractTransitionOperation { @Context protected CoreSession session; @Override protected CoreSession getCoreSession() { return this.session; } @Override protected MortgageStatus getTargetStatus() { return MortgageStatus.SCORING; } }
Теперь в случае когда заявка находится в статусе Submitted пользователь начинает заполнять поля, необходимые для просчета риска, документ переводится в статус Scoring.



Согласование заявки (Committee)
После того как все необходимые для скоринга данные заполнены и статус заявки изменился на Scoring, система выполняет автоматический расчёт уровня риска. На этом этапе используются заранее определённые правила, после чего результат записывается в соответствующее поле документа.
Когда расчёт завершён и уровень риска определён, заявка переводится в следующий этап жизненного цикла Committee. Это означает, что заявка готова к принятию финального решения — одобрить или отклонить.
Как и в предыдущих случаях, переход в это состояние происходит автоматически через цепочку операций и не требует ручного изменения статуса пользователем.
Для реализации такого поведения используется аналогичная цепочка, но на этот раз в ней уже 2 операции:
Mortgage.CalculateRiskOperation: получает на вход документ из контекста automation chain, извлекает значения ключевых полей (таких как доход клиента, запрашиваемая сумма и кредитный рейтинг), передаёт их в сервис расчёта риска и записывает полученный результат обратно в документ.
Mortgage.TransitionToCommitteeOperation: выполняет изменение жизненного цикла документа, переводя его из текущего состояния в состояние Committee после того, как уровень риска уже рассчитан и сохранён.
<?xml version="1.0"?> <component name="com.madela.dev.operation.mortgagechengestatechains"> ... <extension point="chains" target="org.nuxeo.ecm.core.operation.OperationServiceComponent"> <chain id="Mortgage.ChangeStateFromScoringToCommitteeChain"> // Первая операция: приходит документ, вычисляется уровень риска <operation id="Mortgage.CalculateRiskOperation"/> // Вторая операция: после вычислений статус переводится в Committee <operation id="Mortgage.TransitionToCommitteeOperation"/> </chain> </extension> ... </component>
Первая операция в цепочке отвечает за расчёт уровня кредитного риска. В рамках операции извлекаются необходимые данные из документа ипотечной заявки (кредитный рейтинг клиента, запрашиваемую сумму и уровень дохода) и используются в качестве входных параметров для вычисления итоговой оценки риска. Результат расчёта записывается обратно в документ:
package com.madela.dev.operation.business; import com.madela.dev.model.MortgageField; import com.madela.dev.service.MortgageRiskService; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.automation.core.annotations.OperationMethod; import org.nuxeo.ecm.core.api.DocumentModel; @Operation(id = "Mortgage.CalculateRiskOperation", category = "Mortgage") public class CalculateRiskOperation { private final MortgageRiskService riskService = new MortgageRiskService(); @OperationMethod public DocumentModel run(DocumentModel doc) { Long score = (Long) doc.getPropertyValue(MortgageField.CREDIT_SCORE.xpath()); Double amount = (Double) doc.getPropertyValue(MortgageField.REQUESTED_AMOUNT.xpath()); Double income = (Double) doc.getPropertyValue(MortgageField.INCOME.xpath()); String riskLevel = riskService.calculateRisk(score, amount, income); doc.setPropertyValue(MortgageField.RISK_LEVEL.xpath(), riskLevel); doc.getCoreSession().save(); return doc; } }
На основе переданных параметров (кредитный рейтинг, соотношение дохода к сумме кредита и другие показатели) сервис применяет заранее определённые правила и возвращает категорию риска:
package com.madela.dev.service; import com.madela.dev.model.MortgageRiskLevel; public class MortgageRiskService { public String calculateRisk(Long creditScore, Double amount, Double income) { if (creditScore == null || amount == null || income == null || amount == 0) { return MortgageRiskLevel.UNKNOWN.getValue(); } if (creditScore >= 750 && (income / amount) > 0.3) return MortgageRiskLevel.LOW.getValue(); if (creditScore >= 600) return MortgageRiskLevel.MEDIUM.getValue(); return MortgageRiskLevel.HIGH.getValue(); } }
После того как уровень риска успешно рассчитан и сохранён в заявке, эта операция переводит документ из состояния Scoring в состояние Committee:
package com.madela.dev.operation.system; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.core.annotations.Context; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.core.api.CoreSession; @Operation(id="Mortgage.TransitionToCommitteeOperation", category="Mortgage") public class TransitionToCommitteeOperation extends AbstractTransitionOperation { @Context protected CoreSession session; @Override protected CoreSession getCoreSession() { return this.session; } @Override protected MortgageStatus getTargetStatus() { return MortgageStatus.COMMITTEE; } }
Слушатель реагирует на изменение документа и проверяет, что заявка находится в статусе Scoring и все необходимые поля заполнены. Только в этом случае запускается цепочка операций:
package com.madela.dev.listener; import com.madela.dev.model.MortgageField; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.AutomationService; import org.nuxeo.ecm.automation.OperationContext; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.event.Event; import org.nuxeo.ecm.core.event.EventListener; import org.nuxeo.ecm.core.event.impl.DocumentEventContext; import org.nuxeo.runtime.api.Framework; public class MortgageScoringToCommitteeListener extends AbstractMortgageListener { public static final String CHANGE_STATE_FROM_SCORING_TO_COMMITTEE_CHAIN = "Mortgage.ChangeStateFromScoringToCommitteeChain"; @Override protected void processEvent(DocumentModel doc) { if (!MortgageStatus.SCORING.getName().equals(doc.getCurrentLifeCycleState())) { return; } // Если хотя бы одно из необходимых полей не заполнено на момент // изменения документа - пропускаем событие if (hasRequiredFields(doc)) { return; } runChain(doc, CHANGE_STATE_FROM_SCORING_TO_COMMITTEE_CHAIN); } private boolean hasRequiredFields(DocumentModel doc) { return doc.getPropertyValue(MortgageField.CREDIT_SCORE.xpath()) == null || doc.getPropertyValue(MortgageField.REQUESTED_AMOUNT.xpath()) == null || doc.getPropertyValue(MortgageField.INCOME.xpath()) == null; } }
Таким образом, при заполнений необходимых полей (запрашиваемая сумма, ежемесячный доход, кредитный скоринг), автоматически просчитается риск:


Принятие решения (Approved/Rejected)
После перехода заявки в состояние Committee процесс автоматической обработки завершается и управление передаётся пользователю. На этом этапе необходимо принимается финальное решение по заявке — одобрить или отклонить.
В отличие от предыдущих стадий, где переходы выполнялись автоматически, здесь инициатором изменения состояния выступает пользователь. Для этого в интерфейсе документа добавляются соответствующие действия (кнопки):
Одобрить ипотечную заявку
Отклонить ипотечную заявку
Представление кнопок можно настроить через Nuxeo Studio. Можно задать иконку для кнопки, операцию, которая будет выполнена по нажатию, правила видимости кнопки и многие другие параметры, но в рамках ознакомления были добавлены кнопки, которые доступны на UI тогда, когда документ MortgageApplication находится в статусе Committee. Кнопки "Одобрить ипотечную заявку" / "Отклонить ипотечную заявку" вызывают операции ApproveOperation / RejectOperation соответственно.


После того как нажата кнопка Approve осуществляется перевод документа из состояния Committee в Approved через вызов операции ApproveOperation:
package com.madela.dev.operation.system; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.core.annotations.Context; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.core.api.CoreSession; @Operation(id = "ApproveOperation", category = "Committee") public class TransitionToApproveOperation extends AbstractTransitionOperation { @Context protected CoreSession session; @Override protected CoreSession getCoreSession() { return this.session; } @Override protected MortgageStatus getTargetStatus() { return MortgageStatus.APPROVED; } }
Аналогично, после того, как нажата кнопка Reject осуществляется перевод документа из состояния Committee в Rejected через вызов операции RejectOperation:
package com.madela.dev.operation.system; import com.madela.dev.model.MortgageStatus; import org.nuxeo.ecm.automation.core.annotations.Context; import org.nuxeo.ecm.automation.core.annotations.Operation; import org.nuxeo.ecm.core.api.CoreSession; @Operation(id = "RejectOperation", category = "Committee") public class TransitionToRejectOperation extends AbstractTransitionOperation { @Context protected CoreSession session; @Override protected CoreSession getCoreSession() { return this.session; } @Override protected MortgageStatus getTargetStatus() { return MortgageStatus.REJECTED; } }
Теперь в UI у пользователя появляется 2 кнопки и он может выбрать то, в какой статус перевести заявку:



Что в итоге?
Собран конвейер ипотечных заявок, где данные и логика находятся внутри одного документа. Теперь заявка — это не просто набор полей, а самостоятельная сущность, в которой хранится вся информация о клиенте, кредите и рисках.
Нам не пришлось прописывать каждый шаг вручную. Система сама двигает заявку по этапам, реагируя на изменения в документе. Как только прикрепили все обязательные документы, срабатывает триггер, запускается проверка, обновляется статус. Если сравнивать с разработкой микросервисов с нуля, то платформа взяла на себя всю работу по части разработки инфраструктурной части. Не нужно изобретать велосипед для хранения данных или оркестрации — мы просто настраиваем готовую логику.
Заключение
В ходе изучения Nuxeo получилось реализовать тестовый жизненный цикл ипотечной заявки: от заявления и прикрепления обязательных документов до автоматического скоринга и принятия финального решения по ипотечной заявке.
В реализации задачи значительным плюсом стало то, что не потребовалось проектировать отдельные сервисы для хранения документов, управления статусами или оркестрации процессов — все эти задачи решаются на уровне платформы.
При этом возможности для расширения круга операций остаются достаточно широкими. Операции в Nuxeo могут выполнять практически любую бизнес-логику:
вызывать внешние сервисы
обогащать данные
выполнять валидацию
инициировать асинхронные процессы
интегрироваться с другими системами
В данном кейсе Nuxeo выступает как полноценная платформа для реализации бизнес-процессов, где данные, состояние и логика объединены в единую модель.
Но в ходе реализации стали заметны и ограничения подхода. Работа с платформой требует понимания её внутренних механизмов — lifecycle, событийной модели и цепочек операций. Часть логики скрыта внутри системы, что усложняет диагностику и может снижать прозрачность поведения.
Также такой подход делает систему более зависимой от конкретной платформы, из-за чего возникает привязка к ней и необходимость развивать навыки именно работы с Nuxeo.
Правильного ответа на вопрос "микросервисы или low-code" не существует, но для целого ряда задач, особенно связанных с документооборотом и статусными моделями, low-code платформы, в том числе Nuxeo, позволяют намного быстрее получить результат, не жертвуя при этом гибкостью и возможностью масштабирования системы под растущие запросы бизнеса.
С исходным кодом статьи можно ознакомиться по ссылке: https://github.com/madela-team/Java/tree/master/nuxeo-mortgage-pipeline
Расскажите в комментариях, какие low-code решения используете вы, чтобы не обрастать микросервисами?
