30 KiB
ddd-code-cookbook
5 приемов, которые помогают в борьбе за чистый код при разработке приложений по DDD&TDD
Ссылка на git
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
Уровень сервисов используем как простой поток
Помогает в тестировании
Постановка:
| запросить данные для обновления абонента | запросить текущие данные абонента в системе | сформировать запрос на обновление абонента | отправить запрос обновления данных абонента
Пример сервиса с Сильной Доменной Моделью:
@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Последовательность классов:
> Непроверенный Запрос на Обновление > Проверенный Запрос На Обновление > Запрос Абонента В Системе > Запрос На Обновление Абонента > Результат Обновления Абонента
- Не используем исключения в бизнес-процессе в качестве 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
The best code is the one that has never been written © Vladimir Khorikov
This is The Way © The Mandalorian
Оставте, пожалуйста, отзыв о выступлении!
Книги:
Полезные ссылки
- There is no I in Software Craftsmanship
- Domain Modeling Made Functional книга
- Принципы юнит-тестирования книга
- Video :: Scott Wlaschin - Railway Oriented Programming — error handling in functional languages
- Видео :: Владимир Хориков — Domain-driven design: Cамое важное
- Vladimir Khorikov, Refactoring from Anemic Domain Model Towards a Rich One
- Vladimir Khorikov, Applying Functional Principles : pluralsight
- OCP vs YAGNI
- Validation and DDD
- canExecute/Execute
- 7 Software Development Principles That Should Be Embraced Daily
- primitive obsession
- http://dddcommunity.org/
- http://eventstorming.com/
- Visualising software architecture
- https://www.martinfowler.com/bliki/MicroservicePremium.html
- http://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageRouter.html
- https://www.slideshare.net/BerndRuecker/long-running-processes-in-ddd
- https://martinfowler.com/eaaCatalog/dataTransferObject.html
- https://www.infoq.com/articles/consumer-driven-contracts
- Conway's Law
- http://jeffreypalermo.com/blog/the-onion-architecture-part-1/
- http://alistair.cockburn.us/Hexagonal+architecture
- https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
- https://fsharpforfunandprofit.com/posts/type-inference/
- https://en.wikipedia.org/wiki/Data-oriented_design
- http://www.melconway.com/Home/Committees_Paper.html
- https://www.enterpriseintegrationpatterns.com/ramblings/18_starbucks.html
- http://vasters.com/archive/Sagas.html
- tdd best practices
- What Is Software Design? by Jack W. Reeves
- http://alistair.cockburn.us/Hexagonal+architecture
- monad
- Railway-Oriented-Programming-Example
- CQRS
- DTO vs Value Object vs POCO
- Value Objects
- pipeline oriented
- type-inference