Hexagonal Architecture / 3.2. Добавляем API списка и создания мастера

Сервисный слой

В предыдущей части мы рассмотрели доменный слой нашей функциональности. Настало время переходить к сервисному слою.

Организация кода и интерфейсов

В сети множество авторов предлагают совершенно разные схемы организации кода. Одни организуют код в виде классов сервисов, которые потом используются в контроллерах и других адаптерах. Другие предлагают все пользовательские сценарии бить на интерфейсы помещать их в пакет с названием *.port.in, а исходящие порты помещать в виде интерфейсов в пакет *.port.out, что явно указывает на входящие и исходящие порты.

Наша структура и организация кода будет выглядеть следующим образом:

ru.akazakov.beauty.application.expert ├── CreateExpertCommand.java ├── GetAllExpertsQuery.java ├── GetServiceItemsAvailableForExpertQuery.java ├── impl │ ├── CreateExpertCommandImpl.java │ ├── GetAllExpertsQueryImpl.java │ └── GetServiceItemsAvailableForExpertQueryImpl.java ├── port │ ├── GetAllExpertsPort.java │ ├── GetAllServiceItemsPort.java │ ├── GetServiceItemsPort.java │ └── SaveExpertPort.java └── request └── CreateExpertCommandRequest.java

В последнее время становится широко распостраненным подход, в котором функционал создания и изменения отделяется от фукнционала чтения, классами, интерфейсами или сервисами (да зравствует CQRS!). Будем использовать этот подход. Разместим в корне пакета интерфейсы, для основных бизнес-сценариев нашего приложения - CreateExpertCommandRequest, GetAllExpertsQuery, GetServiceItemsAvailableForExpertQuery. Здесь *Comand обозначает, что сценарий подразумевает модификацию данных, а *Query - что данные запрашиваются для представления пользователю.

Здесь GetAllExpertsQuery, GetServiceItemsAvailableForExpertQuery нужны лишь как вспомогательные сценарии, подробно на них останавливаться не будем, потому что в дальнейшем они вероятнее всего претерпят изменения. Данные интерфейсы были введены с целью проверки основного сценария создания Эксперта.

Пакет *.impl содержит реализацию интерфейсов команд и запросов. В пакете *.port содержатся иходящие интерфейсы (порты), необходимые нашим службам для работы. Пакет request содержит запросы (DTO) необходимые службам, чтобы выполнить свою работу. CreateExpertCommandRequest - содержит необходимую информацию, которую команда CreateExpertCommand будет использовать для создания Эксперта.

Бизнес сценарий (Сервис/Служба)

В Clean Architecture и Hexagonal Architecture да и в DDD в целом, основная обязанность сервиса - это выполнение важного бизнес-процесса, преобразование объекта предметной области путем изменения его состава.

Как провести грань между сервисом и сущностью, кто из них должен реализовавывать ту или иную операцию? Во-первых, все бизнес-правила, которые являются общими для всей системы должны быть выражены в операциях Сущности. Во-вторых, нужно задать себе вопрос, явлется ли данная операция основной для бизнес-сценария (как например, привязка оказываемых услуг к сущности Эксперта) или вспомогательной (отправка уведомления, валидация входящих данных, сохранение/загрузка), в первом случае это обязанность Сущности, во втором сервиса или службы. В-третьих, все операции, которые не относятся к естественным обязанностям объекта Сущности должны быть вынесены в сервис (службу).

Также в некоторых случаях - например операция сбора и чтения данных для предтавления пользователю (в нашем случае интерфейсы *Query) создание и использование дополнительной Сущности избыточно и не нужно и только затруднит понимание кода. В таких случаях допускается использование проекций данных и анемичных моделей - что то вроде View.

Опишем сценарий создания Эксперта:

  1. Осуществляем проверку входных данных, на заполненность всех необходимых полей, если какие-либо поля не заполнены, бросаем исключение.
  2. Создаем Эксперта, используя входные данные.
  3. Проверяем что Услуги, которые указаны при создании Эксперта сущестуют в нашей системе и, если они существуют, приписываем Эксперту оказываемые Услуги.
  4. Сохраняем нового Эксперта.

Теперь реализуем это в коде:

@Slf4j @RequiredArgsConstructor public class CreateExpertCommandImpl implements CreateExpertCommand { @Override @Transactional public Expert execute(CreateExpertCommandRequest request) { log.info("Create expert with request: {}", request); ConstraintViolations violations = validator.validate(request); if (!violations.isValid()) { log.error("Validation error: {}", violations); throw new IllegalArgumentException( violations.stream().map(ConstraintViolation::message).toList().toString()); } PersonalInfo personalInfo = buildPersonalInfo(request); ContactInfo contactInfo = buildContactInfo(request); TaxInfo taxInfo = buildTaxInfo(request); Expert expert = Expert.create(personalInfo, contactInfo, taxInfo); Set<ServiceItem> serviceItems = getServiceItems(request.getServiceItemsIds()); if (CollectionUtils.isNotEmpty(serviceItems)) { log.info("Assigning service items to expert: {}", serviceItems); expert.assignServiceItems(serviceItems); } saveExpertPort.saveExpert(expert); return expert; } }

На вход приходят данные для создания Эксперта, которые содержат всю необходимую информацию:

@Getter @Builder @Jacksonized @FieldNameConstants @AllArgsConstructor @NoArgsConstructor public class CreateExpertCommandRequest { private String firstName; private String middleName; private String lastName; private LocalDate birthDate; private String phone; private String email; private String individualTaxpayerNumber; private List<UUID> serviceItemsIds; }

Далее нам необходимо проверить, что входящий запрос содержит корректную информацию, для этих целей также используется валидатор YAVI. Только в этом случае, он задается прямо в сервисе, потому что является частью бизнес-логики:

public static Validator<CreateExpertCommandRequest> validator = ValidatorBuilder.<CreateExpertCommandRequest>of() .constraint(CreateExpertCommandRequest::getFirstName, CreateExpertCommandRequest.Fields.firstName, c -> c.notBlank()) .constraint(CreateExpertCommandRequest::getLastName, CreateExpertCommandRequest.Fields.lastName, c -> c.notBlank()) ._object(CreateExpertCommandRequest::getBirthDate, CreateExpertCommandRequest.Fields.birthDate, c -> c.notNull()) ._string(CreateExpertCommandRequest::getPhone, CreateExpertCommandRequest.Fields.phone, CharSequenceConstraint::notBlank) ._string(CreateExpertCommandRequest::getIndividualTaxpayerNumber, CreateExpertCommandRequest.Fields.individualTaxpayerNumber, CharSequenceConstraint::notBlank) .messageFormatter(new SimpleMessageFormatter()) .build();

Ну и сам процесс проверки входящего запроса:

... ConstraintViolations violations = validator.validate(request); if (!violations.isValid()) { log.error("Validation error: {}", violations); throw new IllegalArgumentException( violations.stream().map(ConstraintViolation::message).toList().toString()); } ...

Далее, так как информация валидна и достаточна для создания Эксперта. Мы конструируем нужную нам сущность, после создание производим назначение Услуг (если они были заданы) Эксперту:

... Expert expert = Expert.create(personalInfo, contactInfo, taxInfo); Set<ServiceItem> serviceItems = getServiceItems(request.getServiceItemsIds()); if (CollectionUtils.isNotEmpty(serviceItems)) { log.info("Assigning service items to expert: {}", serviceItems); expert.assignServiceItems(serviceItems); } ...

Последний шаг - это сохранение Эксперта, здесь стоит обратить внимание, на то что агрегат сохраняется полностью, со всеми компонентами, реализация сохранения производится в реализации порта SaveExpertPort (ее обсудим, когда будем добавлять БД к нашему сервису):

... log.info("Save expert: {}", expert); saveExpertPort.saveExpert(expert); ...

Итоговый результат:

@Slf4j @RequiredArgsConstructor public class CreateExpertCommandImpl implements CreateExpertCommand { private final GetServiceItemsPort getServiceItemsPort; private final SaveExpertPort saveExpertPort; public static Validator<CreateExpertCommandRequest> validator = ValidatorBuilder.<CreateExpertCommandRequest>of() .constraint(CreateExpertCommandRequest::getFirstName, CreateExpertCommandRequest.Fields.firstName, c -> c.notBlank()) .constraint(CreateExpertCommandRequest::getLastName, CreateExpertCommandRequest.Fields.lastName, c -> c.notBlank()) ._object(CreateExpertCommandRequest::getBirthDate, CreateExpertCommandRequest.Fields.birthDate, c -> c.notNull()) ._string(CreateExpertCommandRequest::getPhone, CreateExpertCommandRequest.Fields.phone, CharSequenceConstraint::notBlank) ._string(CreateExpertCommandRequest::getIndividualTaxpayerNumber, CreateExpertCommandRequest.Fields.individualTaxpayerNumber, CharSequenceConstraint::notBlank) .messageFormatter(new SimpleMessageFormatter()) .build(); @Override @Transactional public Expert execute(CreateExpertCommandRequest request) { log.info("Create expert with request: {}", request); ConstraintViolations violations = validator.validate(request); if (!violations.isValid()) { log.error("Validation error: {}", violations); throw new IllegalArgumentException( violations.stream().map(ConstraintViolation::message).toList().toString()); } PersonalInfo personalInfo = buildPersonalInfo(request); ContactInfo contactInfo = buildContactInfo(request); TaxInfo taxInfo = buildTaxInfo(request); Expert expert = Expert.create(personalInfo, contactInfo, taxInfo); Set<ServiceItem> serviceItems = getServiceItems(request.getServiceItemsIds()); if (CollectionUtils.isNotEmpty(serviceItems)) { log.info("Assigning service items to expert: {}", serviceItems); expert.assignServiceItems(serviceItems); } log.info("Save expert: {}", expert); saveExpertPort.saveExpert(expert); return expert; } private Set<ServiceItem> getServiceItems(List<UUID> itemsUUIDs) { List<ServiceItemId> serviceItemsIds = itemsUUIDs.stream().map(id -> ServiceItemId.of(id)) .toList(); return getServiceItemsPort.getServiceItems(serviceItemsIds); } private TaxInfo buildTaxInfo(CreateExpertCommandRequest request) { TaxInfo taxInfo = TaxInfo.builder().individualTaxpayerNumber(request.getIndividualTaxpayerNumber()).build(); return taxInfo; } private ContactInfo buildContactInfo(CreateExpertCommandRequest request) { ContactInfo contactInfo = ContactInfo.builder() .phone(request.getPhone()) .email(request.getEmail()) .build(); return contactInfo; } private PersonalInfo buildPersonalInfo(CreateExpertCommandRequest request) { return PersonalInfo.builder() .firstName(request.getFirstName()) .lastName(request.getLastName()) .middleName(request.getMiddleName()) .birthDate(request.getBirthDate()) .build(); } }

Переходим к тестам.

Тестирование Бизнес-логики

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

  1. Положительный сценарий, когда вся необходимая информация задана, но не заданы Услуги.
  2. Положительный сценарий, когда вся необходимая информация задана и заданы Услуги, которые будет предоставлять Эксперт.
  3. Отрицательный сценарий, когда в запрос не добавили необходимую информацию - номер телефона.

Полный листинг кода теста:

@ExtendWith(MockitoExtension.class) public class CreateExpertCommandImplTest { @Mock GetServiceItemsPort getServiceItemsPort; @Mock SaveExpertPort saveExpertPort; @Test void testExecuteSuccessAndServicesAreEmpty() { CreateExpertCommand createExpertCommand = new CreateExpertCommandImpl(getServiceItemsPort, saveExpertPort); List<UUID> serviceItemsIds = List.of(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")); List<ServiceItemId> ids = serviceItemsIds.stream().map(v -> ServiceItemId.of(v)).toList(); CreateExpertCommandRequest request = CreateExpertCommandRequest.builder() .firstName("Test") .lastName("Test") .birthDate(LocalDate.of(1999, 01, 01)) .phone("+78888888888") .individualTaxpayerNumber("123-123-123") .serviceItemsIds(serviceItemsIds) .build(); when(getServiceItemsPort.getServiceItems(ids)).thenReturn(Collections.emptySet()); doNothing().when(saveExpertPort).saveExpert(any()); createExpertCommand.execute(request); verify(saveExpertPort, times(1)).saveExpert(argThat(arg -> { var expert = (Expert) arg; return CollectionUtils.isEmpty(expert.getServices()) && Objects.nonNull(expert.getId()) && Objects.nonNull(expert.getId().getValue()); })); } @Test void testExecuteSuccessAndServicesAreNotEmpty() { CreateExpertCommand createExpertCommand = new CreateExpertCommandImpl(getServiceItemsPort, saveExpertPort); List<UUID> serviceItemsIds = List.of(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")); List<ServiceItemId> ids = serviceItemsIds.stream().map(v -> ServiceItemId.of(v)).toList(); CreateExpertCommandRequest request = CreateExpertCommandRequest.builder() .firstName("Test") .lastName("Test") .birthDate(LocalDate.of(1999, 01, 01)) .phone("+78888888888") .individualTaxpayerNumber("123-123-123") .serviceItemsIds(serviceItemsIds) .build(); when(getServiceItemsPort.getServiceItems(ids)).thenReturn(Set.of(new ServiceItem(ids.get(0), "testService"))); doNothing().when(saveExpertPort).saveExpert(any()); createExpertCommand.execute(request); verify(saveExpertPort, times(1)).saveExpert(argThat(arg -> { var expert = (Expert) arg; return CollectionUtils.isNotEmpty(expert.getServices()) && Objects.nonNull(expert.getId()) && Objects.nonNull(expert.getId().getValue()); })); } @Test void testExecuteFailedWithPhoneRequiredRequest() { CreateExpertCommand createExpertCommand = new CreateExpertCommandImpl(getServiceItemsPort, saveExpertPort); List<UUID> serviceItemsIds = List.of(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")); CreateExpertCommandRequest request = CreateExpertCommandRequest.builder() .firstName("Test") .lastName("Test") .birthDate(LocalDate.of(1999, 01, 01)) .individualTaxpayerNumber("123-123-123") .serviceItemsIds(serviceItemsIds) .build(); assertThrows(IllegalArgumentException.class, () -> createExpertCommand.execute(request)); } }

Настало время перейти к слою данных и веб слою.

Слой данных

Реализуя слой данных, мы можем воспользоваться основным и на мой взгляд главным преимуществом такой архитектуры - это независимость деталей реализации взаимодействия с внешним миром от бизнес-логики. Более того, как и предлагает Роберт Мартин (Ancle Bob), мы вначале разработки приложения не будем погружаться в детали реализации хранилища наших сущностей и других компонентов, а воспользуемся сохранением всех элементов в память приложения или файл на диске. А после того, как наше приложение созреет, мы уже выберем реализацию хранилища. Это может быть как реляционная база данных, так и колоночная база данных или же NoSQL объектное хранилище типа Mongo. На более поздних этапах разработки нам с более высокой вероятностью будет понятно то, каким требованиям должна удовлетворять структура хранилища, в этот момент мы и выберем необходимую технологию и структуру хранилища. Это позволит нам более эффективно использовать все преимущества той или иной технологии.

Подключаем библиотеки

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

Добавим соответствующую зависимость в build.gradle:

... implementation 'org.mapdb:mapdb:3.1.0' ...

Теперь реализуем в адаптере порты SaveExpertPort, GetAllExpertsPort. В данный момент не будем разбивать реализацию для каждого порта, а имплементируем ее в одном адаптере. Все сущности Экспертов со всеми значениями будут храниться в одном файле. Пока не будем хранить отдельно сущности свзязи экспертов с Услугами. Итоговая реализация будет выглядеть так:

@Slf4j @Repository public class ExpertAdapterInMemory implements SaveExpertPort, GetAllExpertsPort { private static final String DB_FILE_NAME = "experts.db"; private static final String MAP_NAME = "experts"; private final ExpertSerialzer expertSerialzer; public ExpertAdapterInMemory(ObjectMapper objectMapper) { this.expertSerialzer = new ExpertSerialzer(objectMapper); } @Override public void saveExpert(Expert expert) { log.info("Saving expert: {}", expert); try (DB db = DBMaker.fileDB(DB_FILE_NAME).make()) { HTreeMap<UUID, Expert> experts = db.hashMap(MAP_NAME).keySerializer(Serializer.UUID) .valueSerializer(expertSerialzer).createOrOpen(); experts.put(expert.getId().getValue(), expert); } } @Override public List<Expert> getAll() { log.info("Get all experts"); try (DB db = DBMaker.fileDB(DB_FILE_NAME).make()) { HTreeMap<UUID, Expert> experts = db.hashMap(MAP_NAME).keySerializer(Serializer.UUID) .valueSerializer(expertSerialzer).createOrOpen(); return new ArrayList<>(experts.values()); } } @RequiredArgsConstructor static class ExpertSerialzer extends GroupSerializerObjectArray<Expert> { private final ObjectMapper objectMapper; @Override public void serialize(DataOutput2 dataOutput2, Expert expert) throws IOException { dataOutput2.writeUTF(objectMapper.writeValueAsString(expert)); } @Override public Expert deserialize(DataInput2 dataInput2, int i) throws IOException { return objectMapper.readValue(dataInput2.readUTF(), Expert.class); } } }

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

Добавляем веб

Настало время для выставления во вне API эндпоинтов. Реализуем простой rest контроллер, который будет вызывать наши команды и отдавать результаты их работы. Код контроллера:

@RestController @RequestMapping("/api/v1/expert") @RequiredArgsConstructor public class ExpertController { private final CreateExpertCommand createExpertCommand; private final GetAllExpertsQuery getAllExpertsQuery; private final GetServiceItemsAvailableForExpertQuery getServiceItemsAvailableForExpertQuery; @PostMapping public Expert create(@RequestBody CreateExpertCommandRequest createExpertCommandRequest) { Expert result = createExpertCommand.execute(createExpertCommandRequest); return result; } @GetMapping("/list") public List<Expert> getAllExperts() { return getAllExpertsQuery.execute(); } @GetMapping("/service-items") public List<ServiceItem> getAvailableServiceItems() { return getServiceItemsAvailableForExpertQuery.execute(); } }

Не забываем конфигурацию

Теперь, чтобы все заработало, нам необходимо определить необходимые spring бины и сконфигурировать их. Конечно это можно сделать через аннотации (@Component, @Repository и другие), но в данном примере мы будем использовать явное определение бинов и их конфигурирование. Конфигурация, необходимая для создания Эксперта:

@Configuration public class ExpertConfig { @Bean public CreateExpertCommand createExpertCommand(GetServiceItemsPort getServiceItemsPort, SaveExpertPort saveExpertPort) { return new CreateExpertCommandImpl(getServiceItemsPort, saveExpertPort); } @Bean public GetAllExpertsQuery getAllExpertsQuery(GetAllExpertsPort getAllExpertsPort) { return new GetAllExpertsQueryImpl(getAllExpertsPort); } @Bean public GetServiceItemsAvailableForExpertQuery getServiceItemsAvailableForExpertQuery( GetAllServiceItemsPort getAllServiceItemsPort) { return new GetServiceItemsAvailableForExpertQueryImpl(getAllServiceItemsPort); } }

Проверка сервиса

Запустим и проверим наш сервис. Стартуем spring-boot приложение:

akazakov@akazakov:~/Projects/blog-posts/hex-architecture/workspace/schedule$ /usr/bin/env /usr/lib/jvm/java-21-openjdk-amd64/bin/java -agentlib:jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:45085 @/tmp/cp_r5lxr0as2in4h8am0m3vkp6m.argfile ru.akazakov.beauty.infractructure.ScheduleApplication . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v3.3.0) 2024-07-01T21:31:13.652+03:00 INFO 1394889 --- [ main] r.a.b.i.ScheduleApplication : Starting ScheduleApplication using Java 21.0.3 with PID 1394889 (/home/akazakov/Projects/blog-posts/hex-architecture/workspace/schedule/infrastructure/build/classes/java/main started by akazakov in /home/akazakov/Projects/blog-posts/hex-architecture/workspace/schedule) 2024-07-01T21:31:13.667+03:00 INFO 1394889 --- [ main] r.a.b.i.ScheduleApplication : No active profile set, falling back to 1 default profile: "default" 2024-07-01T21:31:14.634+03:00 INFO 1394889 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http) 2024-07-01T21:31:14.646+03:00 INFO 1394889 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2024-07-01T21:31:14.647+03:00 INFO 1394889 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.24] 2024-07-01T21:31:14.715+03:00 INFO 1394889 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 2024-07-01T21:31:14.717+03:00 INFO 1394889 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 970 ms 2024-07-01T21:31:14.882+03:00 INFO 1394889 --- [ main] r.a.b.d.s.GetServiceItemsAdapterInMemory : Initializing service items 2024-07-01T21:31:15.152+03:00 INFO 1394889 --- [ main] r.a.b.d.s.GetServiceItemsAdapterInMemory : Service items are contained in db: [ServiceItem(id=ServiceItemId(value=d947bd9b-2537-416f-a808-e25c05846ba8), name=haircut), ServiceItem(id=ServiceItemId(value=5e8ffd57-f644-4c0d-a202-d257e45f2299), name=manicure)] 2024-07-01T21:31:15.581+03:00 INFO 1394889 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/' 2024-07-01T21:31:15.593+03:00 INFO 1394889 --- [ main] r.a.b.i.ScheduleApplication : Started ScheduleApplication in 2.318 seconds (process running for 2.887)

Теперь воспользуемся rest client и попробуем создать Эксперта. Сначала получим список доступных Услуг:

### GET http://localhost:8080/api/v1/expert/service-items Content-Type: application/json

В ответ получим, введенные нами услуги:

HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Mon, 01 Jul 2024 18:34:28 GMT Connection: close [ { "id": { "value": "d947bd9b-2537-416f-a808-e25c05846ba8" }, "name": "haircut" }, { "id": { "value": "5e8ffd57-f644-4c0d-a202-d257e45f2299" }, "name": "manicure" } ]

Потом создадим эксперта с определенной Услугой:

### POST http://localhost:8080/api/v1/expert Content-Type: application/json { "firstName": "John", "lastName": "Malkovich", "birthDate": "1991-06-13", "phone": "+78888888888", "individualTaxpayerNumber": "123-123-123", "serviceItemsIds": [ "d947bd9b-2537-416f-a808-e25c05846ba8", "5e8ffd57-f644-4c0d-a202-d257e45f2299" ] }

Результат работы метода:

HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Mon, 01 Jul 2024 18:36:10 GMT Connection: close { "id": { "value": "49945703-da95-4d6a-9cb5-096bce9f02a6" }, "personalInfo": { "firstName": "John", "middleName": null, "lastName": "Malkovich", "birthDate": "1991-06-13" }, "contactInfo": { "phone": "+78888888888", "email": null }, "taxInfo": { "individualTaxpayerNumber": "123-123-123" }, "services": [ { "id": { "value": "d947bd9b-2537-416f-a808-e25c05846ba8" }, "name": "haircut" }, { "id": { "value": "5e8ffd57-f644-4c0d-a202-d257e45f2299" }, "name": "manicure" } ] }

И получим список всех экспертов, убедившись, что эксперт добавлен:

### GET http://localhost:8080/api/v1/expert/list Content-Type: application/json ### Response TTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Mon, 01 Jul 2024 18:37:00 GMT Connection: close [ { "id": { "value": "49945703-da95-4d6a-9cb5-096bce9f02a6" }, "personalInfo": { "firstName": "John", "middleName": null, "lastName": "Malkovich", "birthDate": "1991-06-13" }, "contactInfo": { "phone": "+78888888888", "email": null }, "taxInfo": { "individualTaxpayerNumber": "123-123-123" }, "services": [ { "id": { "value": "d947bd9b-2537-416f-a808-e25c05846ba8" }, "name": "haircut" }, { "id": { "value": "5e8ffd57-f644-4c0d-a202-d257e45f2299" }, "name": "manicure" } ] }, { "id": { "value": "30ef62ad-637b-4fd5-89cf-91238a815016" }, "personalInfo": { "firstName": "John", "middleName": null, "lastName": "Doe", "birthDate": "1991-06-13" }, "contactInfo": { "phone": "+78888888888", "email": null }, "taxInfo": { "individualTaxpayerNumber": "123-123-123" }, "services": null }, { "id": { "value": "34fdba6a-7cd2-4287-93a3-5ed40937b36b" }, "personalInfo": { "firstName": "John", "middleName": null, "lastName": "Malkovich", "birthDate": "1991-06-13" }, "contactInfo": { "phone": "+78888888888", "email": null }, "taxInfo": { "individualTaxpayerNumber": "123-123-123" }, "services": null } ]

Заключение

Надеюсь в этой части мне удалось показать основные преимущества гексагональной архитектуры - тестируемость бизнес-правил, что позволит делать более качественные приложения, и независимость от деталей реализации, что на первых порах разработки системы позволит не задумываться о проектировании некоторых аспектов системы, которые могут быть разработаны уже постфактум, когда нам это понадобиться и мы уже будем уверены в выборе той или иной технологии. В следующей части, попробуем добавить к нашему приложению API First подход и также раскроем один из недостатков гексагональной архитектуры - конвертация запросов и ответов между слоями. Код текущей части доступен в github https://github.com/kazakovav/hex-architecture/tree/3_Add_first_functionality/workspace/schedule

Спасибо за внимание!

Подпишись на мой канал в telegram

Начать дискуссию