Эргономичный подход на JPoint

November 18, 2022

Привет!

У меня ребёнок начал стабильно ходить в детский сад! Пока писал пост - снова заболел🤦‍♂️.

Как это касается вас? Во-первых, если у вас сейчас первый ребёнок до трёх лет - верьте, свет в конце туннеля есть, вместе с садом у вас начнётся новая жизнь:)

Например, у вас снова появится время смотреть видосики в интернете:) И я вот посмотрел доклад c JPoint "Эргономичный подход TDD&DDD — гайд по разработке бизнес-логики". Репозиторий доклада.

Это очередной материал, про который я могу сказать "посмотрите его, возьмите Диаграмму Эффектов для декомпозиции и получите ЭП". Поэтому в целом рекомендую к просмотру, но хочу предостеречь от части, которую я попробовал и откинул. А именно - Railway-oriented Programming на монадах.

При том, что сама идея мне нравится и я продолжаю ей придерживаться в императивном стиле (guard-clause с выбросом исключения, "пролетарскйий ROP"), в стиле на монадах она не подошла потому что:

  1. В моём коде процентов 95% обработки ошибок заключается в их маппинге на правильный статус HTTP-ответа. И Скотт Влашин, сам пишет, что не надо использовать ROP там, где нужны исключения.
  2. В Котлине она требует слишком много шума. Например, в Котлине буквально любой вызов может выбросить буквально любое исключение, соотвественно, каждую верхне-уровневую функцию надо оборачивать в Result.runCatching, например. И вообще разница в объёмах кода видна из примера ниже.

Вот перечень достоинств (+ мои комментарии) этого подхода из репозитория:

  1. просто прочитать и осознать.

    На мой взгляд пролетарский ROP проще читать и осознавать.

  2. является низкоуровневой документацией

    В полной мере относится и к ЭП

  3. использует ROP - отсутствуют исключения как возможный неявный результат выполнения бизнес-логики

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

  4. бизнес логика отделена от координирующего слоя

    В полной мере относится и к ЭП

  5. при таком подходе сам сервис выступает тупой трубой, который максимум использует паттерн canExecute/execute

    В полной мере относится и к ЭП

  6. тестировать его не обязатально - можно покрыть минимум точек верхнеуровневым интеграционным тестом контроллера

    В полной мере относится и к ЭП

  7. при тестировании можно сфокусироваться на тестировании сильной доменной модели - тестировании бизнес-логики

    В полной мере относится и к ЭП

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

Table 1. Сравнение ROP-а на монадах и пролетарского ROP-а

Ссылка на репозиторий

fun dataUpdateProcess(
        dataUpdateRequest: SubscriberDataUpdateRequest
): Mono<Result<SubscriberDataUpdateResponse>> =
   getDataUpdate(dataUpdateRequest)
     .flatMap { getSubscriber(it) }
     .flatMap { prepareSubscriberUpdateRequest(it) }
     .flatMap { updateSubscriber(it) }

 private fun getDataUpdate(
     dataUpdateRequest: SubscriberDataUpdateRequest
): Mono<Result<DataUpdate>> =
     _subscribersClient.findDataUpdate(dataUpdateRequest.dataUpdateId)


private fun getSubscriber(
    dataUpdate: Result<DataUpdate>
): Mono<Result<SubscriberDataUpdate>> =
    Mono.just(dataUpdate)
        .filter { it.isSuccess }
        .flatMap { getSubscriber(it.getOrThrow()) }
        .switchIfEmpty(incomingFailInDataUpdate(dataUpdate))

private fun getSubscriber(
    dataUpdate: DataUpdate
): Mono<Result<SubscriberDataUpdate>> =
    _subscribersClient.findSubscriber(dataUpdate.subscriberId)
        .map {
            SubscriberDataUpdate.emerge(dataUpdate, it)
        }

private fun prepareSubscriberUpdateRequest(
           subscriberDataUpdate: Result<SubscriberDataUpdate>
) : Mono<Result<SubscriberUpdateRequest>> =
  Mono.just(subscriberDataUpdate)
    .filter { it.isSuccess }
    .flatMap { prepareRequest(it.getOrThrow()) }
                //^ подготовка запроса на обновление абонента
    .switchIfEmpty(
        incomingFailInSubscriberDataUpdate(
            subscriberDataUpdate))

private fun prepareRequest(
         subscriberDataUpdate: SubscriberDataUpdate
): Mono<Result<SubscriberUpdateRequest>> =
    Mono.just(subscriberDataUpdate.prepareUpdateRequest())
              //^ бизнес-логика в Доменном
suspend fun dataUpdateProcess(
    dataUpdateRequest: SubscriberDataUpdateRequest
): SubscriberDataUpdateResponse {
   val dataUpdate = getDataUpdate(dataUpdateRequest)
   val subscriber = getSubscriber(dataUpdate)
   if (!subscriber.isUpdateRequired()) {
       throw RuntimeException("No Update Required") // (1)
   }
   val updateRequest = subscriber
       .prepareSubscriberUpdateRequest()
   val subscriberUpdateResult = updateSubscriber(it)
   return subscriberUpdateResult
}

private fun getDataUpdate(
    dataUpdateRequest: SubscriberDataUpdateRequest
): DataUpdate =
    _subscribersClient.findDataUpdate(dataUpdateRequest.dataUpdateId)

suspend fun getSubscriber(
    dataUpdate: DataUpdate
): SubscriberDataUpdateRequest {
    val dataUpdateDto =
        _subscribersClient
            .findDataUpdate(dataUpdateRequest.dataUpdateId)

    return SubscriberDataUpdate.emerge(dataUpdate, dataUpdateDto)
}

(1) взял из оригинального кода.

На мой взгляд, в эргономичной версии намного проще читается, что логика операции следующая:

  1. запросить данные для обновления абонента
  2. запросить текущие данные абонента в системе
  3. сформировать запрос на обновление абонента
  4. отправить запрос обновления данных абонента

И более того, в эргономичной версии ещё и сразу подсвечен альтернативный ход сценария "обновление не требуется".

А вот свежий пример эргономичного кода c "пролетарским ROP-ом" уже из Проекта Э:

class AnalysisService(
    private val diaryService: DiaryService,
    private val analysisReportCalculator: AnalysisReportCalculator,
) {

    fun getAnalysisReport(userId: Int, start: Instant, end: Instant): AnalysisReport {
        val reportEvents = diaryService.getReportEvents(userId, start, end)
        val analysisReport = analysisReportCalculator.calculateAnalysisReport(reportEvents, start, end)
        return analysisReport
    }

}

Реализация calculateAnalysisReport на 300 строк в виде чистой функции:

ea at jpoint db379

Конкретно этот код писал не я, но я его ревьювил и поставил на нём штамп "Соответсвует принципам ЭП". Именно так должен выглядеть идеальный эргономичный код - три линейных строчки в сервисе приложения, и куча чистого кода в доменном сервисе.