Привет, Хабр! Я Вячеслав Тихонов, разработчик в команде, которая занимается бэкендовским движком для кредитных продуктов. Мы делаем так, чтобы правильно начислялись проценты по кредитам, переходили деньги по счетам, работали досрочные погашения и так далее.

Многие базовые практики и принципы в программировании сформировались 20—30 лет назад. С тех пор многое изменилось — инструменты разработки и отладки стали на порядок лучше. Кажется, что теперь можно не заморачиваться со структурой кода, ведь IDE позволяет легко рефакторить любой спагетти-код, а покрытие тестами 90% вселяет надежду, что при этом ничего не сломается. 

Появилось много фреймворков и библиотек, которые решают рутинные задачи разработчика, позволяя сосредоточиться только на бизнес-функциональности. Жизненный цикл программных продуктов существенно сократился, time to market стал главным фактором при разработке, а все остальное записывается в техдолг, который потом никто не просматривает: через 3 года все равно перепишем заново. 

Появляется все больше статей о том, что старые подходы не работают. А сейчас мы на пороге AI-революции, когда новые инструменты позволят нам писать программы еще быстрее. 

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

Что такое финансовый движок

Наша команда отвечает за развитие финансового движка на проекте, который активно тестирует множество разных рыночных гипотез. У нас часто появляются и меняются бизнес-требования и функциональность систем. 

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

С ростом задач и данных монолитное решение становилось слишком тяжелым. Вполне логично, что мы пришли к использованию саги с оркестрацией.

Мы рассматривали разные оркестраторы: самописные, Kafka Streams, Spring Batch, Apache Airflow, Orkes Conductor, Apache Flink и другие. Но в итоге выбрали Camunda 8, так как платформа Camunda предоставляет обширную и проверенную годами функциональность, при этом в 8-й версии решены проблемы производительности 7-й. А еще потому, что Camunda уже активно использовалась другими командами проекта для реализации различных бизнес-процессов. 8-ка идеально подходила как решение, которое можно использовать как SaaS-решение для всего проекта. 

Но как только мы вышли в прод, обнаружилось, что с осени 2024 года все новые версии Zeebe выходят под новой лицензией, которая не позволяет использовать его бесплатно в продакшене. Пришлось переносить наши процессы с Camunda на Temporal, который хотя и не такой функциональный, но тоже удовлетворял требованиям наших задач. 

Нам удалось переехать буквально за пару недель. Очень помогли: 

  • Слои приложения.

  • Вспомогательная логика.

  • Обеспечение идемпотентности.

  • Ограниченное использование.

Слои приложения

Все классические подходы к дизайну приложений (слоистая архитектура, гексагональная архитектура, чистая архитектура и так .далее.) рекомендуют отделять бизнес- код от слоя взаимодействия с внешними системами. Я надеюсь, вы в Spring MVC не размещаете бизнес- логику в контроллерах?

Мы придерживались такого же подхода. Spring- Boot- стартер для Camunda позволяет превратить любой метод превратить в воркер процесса, но мы вынесли это в отдельные классы, по аналогии с контроллерами MVC.

@Component
@RequiredArgsConstructor
public class ImportantStepCamundaWorker {

  	private final ImportantStepService importantStepService;

  	@JobWorker(type = IMPORTANT_STEP)
  	publicMap<String,Object> importantStepCalculations(ActivatedJob job,
               	@Variable(PROCESS_INPUT_VALUE_VARIABLE) int inputValue) {
  	    var output = importantStepService.importantStepCalculations(inputValue);
  	    return Map.of(CALCULATED_VALUE_VARIABLE, output);
  	}
}

@Service
public class ImportantStepService {
  	public BigDecimal importantStepCalculations(int inputValue) {
  	    // some logic
  	}
}

В ImportantStepCamundaWorker — только маппинг параметров и ответа для взаимодействия с оркестратором, плюс вызов бизнесового сервиса, в котором уже лежит основная логика.

В результате при переезде на Temporal нюансы вызова воркера из оркестратора и передачи входных параметров не потребовали изменений в бизнесовых сервисах. Даже тесты поменялись минимально.  Для работы с Temporal мы лишь заменили воркер на активити.

@ActivityInterface
public interface ImportantStepActivity {
  	BigDecimal importantStepCalculations(String processId,
                                      	 String stepName,
                                       	 int inputValue);
}
 
@Component
@RequiredArgsConstructor
public class ImportantStepActivityImpl implements ImportantStepActivity {
 
  	private final ImportantStepService importantStepService;
 
  	@Override
  	public BigDecimal importantStepCalculations(String processId, 
                                                String stepName, 
                                                int inputValue) {
  	    var output = importantStepService.importantStepCalculations(inputValue);
  	    return output;
  	}
}
 
@Service
public class ImportantStepService {
  	public BigDecimal importantStepCalculations(int inputValue) {
      	// some logic
  	}
}

Вспомогательная логика

Часто при вызове методов требуется какая-то вспомогательная логика, вроде добавления значений в MDC логов, единого подхода к обработке ошибок и так далее. Есть разные способы, как это сделать. Например, можно использовать AOP от Spring. 

Многие фреймворки или библиотеки предоставляют возможность встраивать свою логику в цепочку обработки. Например, в Spring MVC можноесть возможность встроить свою логику через фильтры и интерспепторы в процесс обработки запросов. 

К сожалению, Camunda не предоставляет штатных интерсепторов для своих воркеров и предлагает использовать инструменты gRPC- протокола, по которому осуществляется работа воркеров с кластером Zeebe. 

Мы выбрали простой и примитивный способ —  реализовали вспомогательную логику через обертки (врапперы) и заворачивали вызовы бизнесовых функций в них вручную.

@Component
public class TelemetryComponent {
 
	public <T> T executeWithTracing(String processID, String stepType, Supplier<T> action) {
  	    // configure tracing
  	    try {
        	return action.get();
  	    } catch (Exception e) {
        	// handle exceptions
  	    } finally {
        	// some final actions
  	    }
	}
}

@Component
@RequiredArgsConstructor
public class ImportantStepCamundaWorker {
 
  	private final ImportantStepService importantStepService;
  	private final TelemetryComponent telemetryComponent;
 
  	@JobWorker(type = IMPORTANT_STEP)
  	public Map<String, Object> importantStepCalculations(final ActivatedJob job, @Variable(PROCESS_INPUT_VALUE_VARIABLE) int inputValue) {
 
    	return telemetryComponent.executeWithTracing(job.getBpmnProcessId(), job.getType(), () -> {
        	var output = importantStepService.importantStepCalculations(job.getType(), inputValue);
        	return Map.of(CALCULATED_VALUE_VARIABLE, output);
    	});
  	}
}

TelemetryComponent —  обертка, которая добавляет поля в MDC для более удобной фильтрации логов, добавляет обновление метрик и так далее.

Использование оберток тоже помогло, так как на новом оркестраторе, хотя он и работал поверх gRPC,  процесс обработки воркером запросов от оркестратора оказался реализован иначе, в том числе с активным использованием статических фабричных методов.

У врапперов есть серьезный недостаток: – их надо не забывать добавлять везде, где это требуется. И поскольку в SDK нового оркестратора есть функциональность интерсепторов, скорее всего, мы все же пожертвуем универсальностью и перейдем на него.

Обеспечение идемпотентности

Я уже упоминал, что нам очень важна exactly-once семантика. Одним из требований было, чтобы оркестратор строго обеспечивал только однократное успешное выполнение каждого шага процесса. 

Кстати, Camunda 8 подложила нам свинью на проде. Из-за в разы большего объема данных в продовой базе, чем в тестовой, некоторые шаги выполнялись существенно дольше и превысили настроенные таймауты. Вместо ожидаемой нами ошибки в процессе, Zeebe просто отдал эти задачи в повторную обработку на другого воркера, при том, что у нас было выставлено количество ретраев для шага было выставлено в 0. 

Представьте наши эмоции, когда Zeebe нагрузил и так загруженную базу ненужными расчетами, да еще и часть расчетов вроде начисления ежедневных процентов выполнилась и применилась по некоторым договорам дважды. Это заставило нас в дополнение к механизмам оркестратора сделать проверку идемпотентности внутри воркеров с отменой всех сделанных изменений, если уже этот шаг уже был выполнен кем-то в параллельно. 

Как вариант, exactly-once обработку можно сделать через простую фиксацию в базе пройденных шагов с проверкой на уникальность при вставке. Отчасти это дублирование логики оркестратора, но зато при переезде мы были спокойны, что, даже если есть какая-то специфика реализации exactly-once у нового оркестратора, это не повредит наши данные.

@Service
@RequiredArgsConstructor
public class ImportantStepService {
 
  	private final TransactionTemplate transactionTemplate;
  	private final WorkflowStepService workflowStepService;
 
  	public BigDecimal importantStepCalculations(String stepName, int inputValue) {
  	if (workflowStepService.isStepProcessed(stepName)) {
        	throw new IllegalStateException("Step already processed");
  	}
 
    	return transactionTemplate.execute(() -> {
        	// some logic
        	workflowStepService.markStepAsProcessed(stepName);
    	});
  	}
}
 
@Service
@RequiredArgsConstructor
public class WorkflowStepService {
  	private final StepRepository stepRepository;
 
  	public void markStepAsProcessed(String stepName) {
        stepRepository.insert(stepName);
  	}



  	public boolean isStepProcessed(String stepName) {
    	return stepRepository.findByStep(stepName).isPresent();
  	}
}

WorkStepService —  самый простой пример проверки идемпотентности с помощью таблицы с unique-индексом в базе.

Ограниченное использование

«Когда у тебя в руках молоток, все задачи кажутся гвоздями» (Абрахам Маслоу)

Когда в проекте появляется фреймворк или решение с богатой функциональностью, очень часто тянет по максимуму использовать все его возможности. И в какой-то момент ловишь себя на том, что прикручиваешь костыли, используя недокументированные особенности из кишков этого решения. 

Camunda тоже предоставляет богатые возможности, позволяющие выносить решение каких-то задач из кода своего приложения на ее уровень. Например, трансформации данных, находящихся в контексте выполняемого процесса, — в Camunda естьпредоставляет большое количество функций для этого. И вот уже бизнес-логика начинает расползаться: – часть остается в воркерах, а часть оказывается на стороне оркестратора и задается в схеме процесса.

Было сложно бороться с соблазном вынести часть логики на уровень описания процесса и избежать создания дополнительного воркера, который, например, просто ждет, когда обработаются все ранее созданные задачи. Но именно это существенно упростило наш переезд, так как новый оркестратор не обладал таким обширным функционалом.

Выводы

Наше приложение — это не только код. Оно содержит множество различных библиотек, фреймворков, инфраструктурных и внешних компонент и систем. Но из всего этого я слышал опасения вендор-лока только в отношении облаков, что нужно стараться делать свои системы так, чтобы в случае чего было проще перенести их на другую инфраструктуру. 

В последнее время мы наблюдаем все больше разных историй, связанных с изменениями лицензий и вводом различных ограничений у самых разных продуктов — от крупных энтерпрайз-продуктов до небольших библиотек. А фразы типа «Зачем нам еще одна абстракция в виде ORM для работы с базой? Мы вряд ли будем менять базу» стали звучать немного иначе. 

Из всего этого я сделал два вывода:

  • Нельзя игнорировать общепринятые подходы к разработке приложений. Даже если они порой и кажутся излишними и устаревшими, они существенно помогают в подобных неожиданных ситуациях, когда вам необходимо мигрировать на другое решение.

  • Не нужно реализовывать основную логику приложения в жесткой связке на внешние зависимости. Лучше быть немного параноиком и подумать заранее, что будет с приложением, если какая-то из зависимостей вдруг станет платной или недоступной. Либо нужно быть готовым переписать свое приложение заново, если что-то поменяется вокруг.


    Выбранный подход помог перейти на использование другого решения в системе, которая обслуживает сотни тысяч клиентов, без серьезных доработок кода, длительного перетестирования всей системы (так как бизнес-логика не была затронута) и в короткие сроки.

Комментарии (5)


  1. Gabenskiy
    26.06.2025 09:35

    было бы здорово репозиторий прикрепить к этой статье


  1. PrinceKorwin
    26.06.2025 09:35

    Как вариант, exactly- once- обработку можно сделать через простую фиксацию в базе пройденных шагов с проверкой на уникальность при вставке.

    Отдавать гарантии корректности данных в сторонние решения - это всегда плохой вариант.


    1. vat78 Автор
      26.06.2025 09:35

      Ну тут вопрос упирает в уровень паранойи. База данных, по сути, тоже стороннее решение. У каждой системы есть свои особые кейсы, когда она может нарушать заявленные гарантии. Вчера, например, открыл для себя, что Kafka не дает 100% гарантии порядка записей в рамках ключа партиционирования.
      Как обычно, везде свои трейд-офы, главное об этом не забывать и заранее учитывать


      1. PrinceKorwin
        26.06.2025 09:35

        Базе данных вы уже доверили хранение своих бизнес-данных и доверяете ей в поддержке целостности данных (PK, FK). Поэтому нет ничего зазорного чтобы БД и дальше продолжала отвечать за целостность.

        что Kafka не дает 100% гарантии порядка записей в рамках ключа

        а также не дает гарантии exactly once и может терять сообщения :)


        1. vat78 Автор
          26.06.2025 09:35

          а есть ли хоть какая-то внешняя система со 100% гарантией exactly once? )