You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

30 KiB

ddd-code-cookbook

5 приемов, которые помогают в борьбе за чистый код при разработке приложений по DDD&TDD

Ссылка на git

Alt text

email: maxim[at]codemonsters.team
https://t.me/maxology

«I am strong believer in a “begin with the concrete, and move to the abstract” pedagogical approach» © Scott Wlaschin

«The problem contains the solution»


Цель:

Повысить качество кода разработки приложений с бизнес-логикой

  • Снизить стоимость доработок
  • Снизить стоимость поддержки
  • Снизить стоимость погружения новичков
  • Сокращения количества ошибок

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

  • Повышения эффективности делегирования
  • Сокращения времени на встречи «погружения разработчиков» в методологию
  • Сокращения времени на проверку кода
  • Сокращения времени на осознание кода

Задача:

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

На автора оказали влияние работы инженеров:
Владимира Хорикова, Скотта Влашина, Роберта Мартина

«Рецепт» разработки бизнес-логики в функциональном стиле с примерами на Kotlin:

  • DDD :: общаемся с экспертами и проектируем бизнес-логику вместе

    • Единый язык в документации, коде, в общении с экспертами домена

    • Опиши в функциональном стиле бизнес-процесс с доменными классами
      Пример:
      Чтобы актуализировать данные по абоненту в системе, необходимо:

      | запросить данные для обновления абонента 
      | запросить текущие данные абонента в системе
      | сформировать запрос на обновление абонента
      | отправить запрос обновления данных абонента
      

      ^ хорошо помогает в рефакторинге

      Как часто бывает, не делай так:

       0. Снять с себя ответственность и кодить по постановке
       1. В таблице <обновление_данных> взять все строки со статусом need_to_update
       2. В таблице абонента взять данные по абоненту по идентификатору <обновление_данных>.subscriber_id, если таких данных нет, пометить ошибкой  
       3. Сверить строки как то так.
       4. Если данные отличаются см. пункт 5
       5. Отдельная страница в конфлюенс на два скрола со сложной логикой обновления данных, сиквенс диаграммой и т.д.
      
    • Забудь Table-Driven Design (Database Oriented мышление) - используй только Доменные объекты при обсуждении задачи. Не думай о низкоуровневой реализации.

    • Используй в коде Сильную Доменную модель - вся логика описана в доменных объектах, не в сервисах:

          class SubscriberDataUpdate(
                val subscriber: Subscriber, 
                val dataUpdate: SubscriberDataUpdate
          ) {
                fun isUpdateRequired(): Boolean = 
                   subscriber.mobileRegionId != dataUpdate.mobileRegionId 
          }
      

      Не используй анти-паттерн Слабая Доменная Модель > Anemic Domain Model:

          data class Subscriber(
                val subscriberId: String,
                val msisdn: String, 
                val mobileRegionId: String
          )
      
      

      Пример плохой реализации со слабой доменной моделью и бизнес-логикой в сервисе:

       @Service
       class SubscriberDataUpdateService(
         val subscriberGateWay: SubscriberGateway, 
         val dataUpdate: SubscriberDataUpdateGateway
       ) {
      
            fun dataUpdateProcess() {                                                         
                updateSubscriber(
                     dataUpdate.getDataForUpdate()
                )                                                                          
            }  
             //^ бизнес-логика и интеграции находятся в сервисах 
             // обычно их много и разработчик 
             // занят детективным расследованием при любой доработке
             // доменные модели при этом выступают в роли контейнера данных
             // код обрастает комментариями - а это плохой признак
      
            private fun updateSubscriber(
                           dataUpdate: SubscriberDataUpdate
            ) {
                val subscriber = 
                    subscriberGateWay.finedById(dataUpdate.subscriberId)
      
                if (isUpdateRequired(subscriber, subscriberDataUpdate))
                     //^ бизнес-логика
                      updateSubscriberWithData(subscriber, subscriberDataUpdate)                      
            }
      
            private fun isUpdateRequired(
                             subscriber: Subscriber, 
                             dataUpdate: SubscriberDataUpdate
            ) =
                  subscriber.modileRegionId != dataUpdate.mobileRegionId 
      
            private fun updateSubscriberWithData(
                             subscriber: Subscriber, 
                             dataUpdate: SubscriberDataUpdate
            ) {
      
                val updateSubscriberDto = 
                                   SubscriberDto( 
                                      subscriber.subscriberId, 
                                      dataUpdate.mobileRedionId
                )                     //^ бизнес-логика
                subscriberGateWay.updateSubscriber(
                         updateSubscriberDto
                )        //^ может вылететь исключение  
      
                // далее сложная логика по обновлению взаимосвязанных сущностей с Subscriber 
                // логика растеклась, границ у Доменов нет
                if (subscriber.msisdn != dataUpdate.msisdn) {
                   updateMnp(subscriber, subscriberDataUpdate)
                   updateLoyalty(subscriber, subscriberDataUpdate)     
                }
            }
       }
      
    • Изолируем доменную модель от интеграций с внешними системами
      Onion Architecture
      Уровень сервисов используем как простой поток
      Помогает в тестировании
      Alt text

      Постановка:

      | запросить данные для обновления абонента
      | запросить текущие данные абонента в системе
      | сформировать запрос на обновление абонента
      | отправить запрос обновления данных абонента
      

      Пример сервиса с Сильной Доменной Моделью:

         @Service
         class SubscriberDataUpdateService(
                    val _subscribersClient: SubscriberGateway
         ) {
      
           fun dataUpdateProcess(
                   dataUpdateRequest: SubscriberDataUpdateRequest
           ): Mono<Result<SubscriberDataUpdateResponse>> =
              getDataUpdate(dataUpdateRequest)                  
                .flatMap { getSubscriber(it) }                  
                .flatMap { prepareSubscriberUpdateRequest(it) } 
                .flatMap { updateSubscriber(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())
                                                 //^ бизнес-логика в Доменном классе
      
      
      
    • Реализуй в функциональном стиле всегда валидную Богатую Доменную Модель
      Для описания Доменных классов в функциональном стиле помогает
      описать бизнес-процесс в цепочке классов:

      • Постановка:
        | запросить данные для обновления абонента
        | запросить текущие данные абонента в системе
        | сформировать запрос на обновление абонента
        | отправить запрос обновления данных абонента
      
      • Последовательность классов:
        > Непроверенный Запрос на Обновление 
        > Проверенный Запрос На Обновление 
        > Запрос Абонента В Системе 
        > Запрос На Обновление Абонента 
        > Результат Обновления Абонента
      

      Пример плохого возможно Невалидного Доменного класса:

         class SubscriberDataUpdate(
                 val subscriber: Subscriber?, 
                 val dataUpdate: SubscriberDataUpdate
           ) {
                 fun isValid() = null != subscriber
      
                 fun isUpdateRequired() = 
                    subscriber.mobileRegionId != dataUpdate.mobileRegionId 
         }
      

      Всегда валидная Доменная модель:

         data class SubscriberDataUpdate private constructor(
              private val dataUpdate: DataUpdate,
              private val subscriber: Subscriber
          ) {
      
            val subscriberId = subscriber.subscriberId
            val dataUpdateId = dataUpdate.dataUpdateId
      
            fun prepareUpdateRequest()
            : Result<SubscriberUpdateRequest> 
            ...
            ...
      
            companion object {
                fun emerge(
                    dataUpdate: DataUpdate,
                    subscriberResult: Result<Subscriber>
                ): Result<SubscriberDataUpdate> =
                     //^ нет иной возможности в коде создать объект
                     // > Result<Data, Error> two-track type
                    subscriberResult.map {
                        SubscriberDataUpdate(
                            dataUpdate,
                            Subscriber(
                                    it.subscriberId, 
                                    it.msisdn, 
                                    it.mobileRegionId)
                            )
                    }
            }
      
        }
      
      

  • Не используем исключения при работе с ошибками в бизнес-процессе в качестве control flow

    Исключения как инструмент мешают в восприятии бизнес-процесса,
    как непрерывного потока.
    Railway Oriented Programming - error handling in functional languages

    Последовательность классов:

       > Непроверенный Запрос на Обновление 
       > Проверенный Запрос На Обновление 
       > Запрос Абонента В Системе 
       > Запрос На Обновление Абонента 
       > Результат Обновления Абонента
    

    Alt text

    • Не используем исключения в бизнес-процессе в качестве control flow (исключения - сигналы багов.)
    • Только честные функции.
      функция всегда возвращает ответ:
      two track type Result<Data, Error>
      если она может «сломаться» в процессе исполнения.
      Пример сервиса с Сильной Доменной Моделью и R.O.P. :
         @Service
         class SubscriberDataUpdateService(
            val _subscribersClient: SubscriberGateway
         ) {
      
            fun dataUpdateProcess(
                   dataUpdateRequest: SubscriberDataUpdateRequest
            ): Mono<Result<SubscriberDataUpdateResponse>> =
              getDataUpdate(dataUpdateRequest)
                .flatMap { getSubscriber(it) }
                .flatMap { prepareSubscriberUpdateRequest(it) }
                .flatMap { updateSubscriber(it) }
            ...
            ...
      
            private fun prepareSubscriberUpdateRequest(
                    subscriberDataUpdate: Result<SubscriberDataUpdate>
            ) : Result<SubscriberUpdateRequest> = 
              Mono.just(subscriberDataUpdate)
                .filter { it.isSuccess }
                             //^ canExecute/Execute pattern
                .flatMap { prepareRequest(it.getOrThrow()) }
                .switchIfEmpty(returnIncomingError(subscriberDataUpdate))
                               //^ R.O.P error handling
                               //передаем ошибку входного параметра по рельсам
                // Result<SubscriberDataUpdate> > Result<SubscriberUpdateRequest>
      
      
        ````
      
    • Не используем исключения в приложении в качестве control flow

  • Функциональный подход Pipeline Oriented Programming при проектировании кода с бизнес-логикой
    • Запусти Доменную Модель по тоннелю «бизнес-процесс» без исключений, на шлюзах поможет two track type Result<Data, Error> и canExecute/execute
      Описанный код бизнес-логики должен выглядеть структурно, подобно постановке:
      Постановка:

      | запросить данные для обновления абонента
      | запросить текущие данные абонента в системе
      | сформировать запрос на обновление абонента
      | отправить запрос обновления данных абонента
      

      Пример хорошего кода приложения в функциональном стиле:

          fun dataUpdateProcess(dataUpdateRequest: SubscriberDataUpdateRequest)
          : Mono<Result<SubscriberDataUpdateResponse>> =
            getDataUpdate(dataUpdateRequest)                   
              .flatMap { getSubscriber(it) }                   
              .flatMap { prepareSubscriberUpdateRequest(it) }  
              .flatMap { updateSubscriber(it) }                
      
    • не используй void в функциях

    • никаких побочных эффектов в функциях.


  • YAGNI + KISS как самые ценные принципы проектирования

    YAGNI — "You aren’t gonna need it"
    KISS — "Keep it simple, stupid" or "Keep it short and simple"

    • Улучшай структуру кода и уменьшай количество слоев
      Простая структура уменьшает когнитивную нагрузку, упрощает работу с кодом.

        ddd.toolkit
          controller
          domain
            common
            subscriberDataUpdate
          utils
      
      
    • проектируем только то, что нужно в моменте по бизнесу

    • никаких универсальных надстроек и шаблонов. Домен уникален сам по себе
      The simpler your solution is, the better you are as a software developer.

          ddd.toolkit
            controller
            domain
              common
              subscriberDataUpdate
                 DataUpdate
                 Subscriber
                 SubscriberDataUpdate
                 SubscriberDataUpdateRequest
                 SubscriberDataUpdateResponse
                 SubscriberDataUpdateService
                 SubscriberGateway
                 SubscriberRestClient
            utils
      
      

  • TDD :: классическая школа, прагматичный набор тестов, сфокусированный на бизнес-логике

    TDD — "Test Driven Development"
    TDD — это надежный способ проектирования программных компонентов.
    Тесты помогают писать код лучше, если поставить задачу:

    • Покрой юнит-тестами бизнес-логику, которая содержится в Доменной Модели
    • тест - это документация - должен быть максимально простым:
    /**
    4 аспекта хороших юнит-тестов:
     1) защита от багов
     2) устойчивость к рефакторингу
     3) быстрая обратная связь
     4) простота поддержки\
    **/
    @Test
    fun success() {
      //arrange
      val foundDataUpdateDto = DataUpdateDto(
          dataUpdateId = "101",
          subscriberId = "909",
          msisdn = "9999999999",
          mobileRegionId = "9" //< изменение региона
      )
      val foundSubscriberDto = SubscriberDto(
          subscriberId = "909",
          msisdn = "9999999999",
          mobileRegionId = "0" //< текущее состояние региона
      )
    
      val dataUpdate = DataUpdate.emerge(
          Result.success(foundDataUpdateDto)
      ).getOrThrow()
      val subscriberResult = Subscriber.emerge(
          Result.success(foundSubscriberDto)
      )   //^ моки не нужны
    
      //act
      val sut = SubscriberDataUpdate.emerge(dataUpdate, subscriberResult)
       // ^ SUT - sysyem under test
    
      //assert
      assertThat(sut.isSuccess).isTrue
      assertThat(sut.getOrThrow()
                    .prepareUpdateRequest().isSuccess
                 )
                 .isTrue
      val subscriberUpdateRequest = sut.getOrThrow()
                                       .prepareUpdateRequest()
                                       .getOrThrow();
      assertThat(subscriberUpdateRequest.subscriberId).isEqualTo("909")
      assertThat(subscriberUpdateRequest.msisdn).isEqualTo("9999999999")
      assertThat(subscriberUpdateRequest.mobileRegionId).isEqualTo("9")
    }
    

    Пример «трудного» теста:

    @Test
    void getExistingIdsBetweenInAscByReportDt ()
    {
        changeDataRepository.getExistingIdsBetween(1L, 5L)
            .concatMap(id -> changeDataRepository.findById(id))
        .collectList()
        .as(StepVerifier::create)
        .consumeNextWith(changeData -> {
            var reportDts = 
                changeData
                   .stream()
                     .map(ChangeData::getReportDateTime)
                     .collect(Collectors.toList());
                     log.info("list report_dt: {}", reportDts);
            assertThat(reportDts)
              .isSortedAccordingTo(Comparator.naturalOrder());
        })
        .verifyComplete();
    }
    
    
    • не использовать моки
      Не думай о деталях реализации тестируемой системы,
      думай о ее выходных данных.
    • тестировать выходные данные функции, если тестируем состояние - это компромисс.
    • минимизировать количество интеграционных тестов. Один тест покрывает максимум возможных интеграций – максимум кода.
      Проверь «Счастливый путь» и до 3-х крайних точек с ошибками по процессу
      Как правило интеграционного теста одной ошибки хватает.
      Все ошибки тестируем юнит-тестами.
      Пример интеграционного теста:
    class SubscriberDataUpdateControllerTest(
             @Autowired val webTestClient: WebTestClient
    ) {
    ...
    
    @Test
    fun updateSuccess() {
      webTestClient.put()
          .uri("/api/v1/subscriber-data-updates")
          .bodyValue(RestRequest(DataUpdateRequestDto(dataUpdateId = "101")))
          .exchange()
          .expectStatus().isOk
          .expectBody()
          .jsonPath("@.actualTimestamp").isNotEmpty
          .jsonPath("@.status").isEqualTo("success")
          .jsonPath("@.data.subscriberId").isEqualTo("999")
          .jsonPath("@.data.dataUpdateId").isEqualTo("101")
    }
    }
    
    

Резюме:

Рецепт:

  • Опиши в функциональном стиле бизнес-процесс с доменными классами
  • Реализуй в функциональном стиле всегда валидную Богатую Доменную Модель без примитивов
  • Покрой юнит-тестами бизнес-логику, которая содержится в Доменной Модели
  • Запусти Доменную Модель по тоннелю «бизнес-процесс» без исключений, на шлюзах поможет two track type Result<Data, Error> и canExecute/execute.

По рецепту возможно получить в качестве результата:

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

перед пушем запускай:

./gradlew test

Alt text

The best code is the one that has never been written © Vladimir Khorikov

This is The Way © The Mandalorian


Оставте, пожалуйста, отзыв о выступлении!

Alt text


Книги:

Alt text

Alt text

This Is Tha Way

Agile Manifesto

Полезные ссылки