@yalandaev

Как подружить REST API и концепцию DDD?

Недавно я начал знакомиться с концепцией DDD, и возник вопрос - как правильно действовать при операциях CREATE и UPDATE, когда данные приходят через Web-API. Например, на некой форме на веб-странице мы заполняем поля, нажимаем "Сохранить". На адрес /api/customers летит POST-запрос с данными формы:

{
	"Id": "93967a3e-384f-459a-8b50-0a0f4cc66d66",
	"firstName": "Иван",
	"lastName": "Череззаборногузадерищенко",
	"zipCode": 245876,
	"city": "Самара",
	"street": "Николая Панова",
	"houseNumber": 64,
	"appartmentsNumber": 62,
}


На стороне Web-API он десериализуется в DTO-класс:
public class CustomerDTO
{
	public Guid Id { get; set; }
	public string FirstName { get; set; }
	public string LastName { get; set; }
	public int ZipCode { get; set; }
	public string City { get; set; }
	public string Street { get; set; }
	public int HouseNumber { get; set; }
	public int AppartmentsNumber { get; set; }
	public Guid CustomerStateId { get; set; }
}


Мы имеем следующую доменную модель (сделано очень грубо для демонстрации примера):
public class Customer: Entity, IAggreagtionRoot
{
        public Customer(Name name)
        {
            Name = name;
        }
	public Name Name { get; private set; }
	public Address Address { get; private set; }
	public CustomerState State { get; private set; }

	public void ChangeState(CustomerState newState)
	{
		//some logic...
	}

	public void ChangeAddress(Address newAddress)
	{
		//some logic...
		Address = newAddress;
	}

	public void ChangeName(Name newName)
	{
		//some logic...
		Name = newName;
	}
}

public class CustomerState: Entity
{
	public static readonly CustomerState Regular = new CustomerState(new Guid("7beb8006-1b70-4d47-bb95-1976a2c18e9a"), "Regular");
	public static readonly CustomerState VIP = new CustomerState(new Guid("c27e9e0c-a2dc-4093-80f5-e75b66997746"), "VIP");

	public CustomerState(Guid id, string name)
	{
		Id = id;
		Name = name;
	}
	public string Name { get; private set; }
}
public class Address: ValueType
{
	public Address(int zipCode, string city, string street, int houseNumber, int appartmentsNumber)
	{
		ZipCode = zipCode;
		City = city;
		Street = street;
		HouseNumber = houseNumber;
		AppartmentsNumber = appartmentsNumber;
	}

	public int ZipCode { get; private set; }  
	public string City { get; private set; }
	public string Street { get; private set; }
	public int HouseNumber { get; private set; }
	public int AppartmentsNumber { get; private set; }
}

public class Name: ValueType
{
	public Name(string firstName, string lastName)
	{
		FirstName = firstName;
		LastName = lastName;
	}

	public string FirstName { get; private set; }
	public string LastName { get; private set; }
}


Вопрос 1. Как правильно из DTO-объекта изменить объект Customer? На ум приходит следующее:
public class CustomerService
{
	private CustomerRepository _customerRepository;
	public CustomerService(CustomerRepository customerRepository)
	{
		_customerRepository = customerRepository;
	}

	public void UpdateCustomer(CustomerDTO customerDto)
	{
		Guid customerId = customerDto.Id;
		Customer customer = _customerRepository.GetById(customerId);

		//Как мне дейстовать здесь?
		customer.ChangeName(new Name(customerDto.FirstName, customerDto.LastName));
		customer.ChangeAddress(new Address(customerDto.ZipCode, customerDto.City, customerDto.Street, customerDto.HouseNumber, customerDto.AppartmentsNumber));

		if(customerDto.CustomerStateId == CustomerState.VIP.Id)
			customer.ChangeState(CustomerState.VIP);
		if (customerDto.CustomerStateId == CustomerState.Regular.Id)
			customer.ChangeState(CustomerState.Regular);
		//Что-то вроде этого?

		//Может быть согласовать дизайн UI с дизайном доменной модели, и не позволять 
		//изменять статус с помощью полей формы, и изменять его отдельными действиями (например, специальными кнопками, 
		//которые инициируют POST-запрос www.site.ru/api/customers/2432352/PromoteToVip ?

		_customerRepository.Update(customer);
	}
}

Вопрос 2. Как быть, если я хочу присылать только изменения, а не весь объект целиком? Например, изменился только номер дома, и в целях минимизации пересылаемых данных, я хочу слать такой запрос:
{
	"houseNumber": 164
}


Как в этом случае мне изменить Value-type Address, если его конструктор требует всех параметров?
Может быть как то вот так?:
Customer customer = _customerRepository.GetById(customerId);

var zipCode = customerDto.ZipCode == 0 ? customer.Address.ZipCode : customerDto.ZipCode;
var street = string.IsNullOrEmpty(customerDto.Street) ? customer.Address.Street : customerDto.Street;
var city = string.IsNullOrEmpty(customerDto.City) ? customer.Address.City : customerDto.City;
var appartmentsNumber = customerDto.AppartmentsNumber == 0 ? customer.Address.AppartmentsNumber : customerDto.AppartmentsNumber;
var houseNumber = customerDto.HouseNumber == 0 ? customer.Address.HouseNumber : customerDto.HouseNumber;

customer.ChangeAddress(new Address(zipCode, city, street, houseNumber, appartmentsNumber));


Вопрос 3. Что, если у объекта много свойств, скажем, 40. Скажем, 20 из них не влияют на согласованность модели (т.е. чисто информационные, и не требуется следить за их изменением). Делать для каждого методы установки значений (что ощущается мне грустным), или просто открыть их для редактирования (сделать сеттер публичным) внешнему коду?

P.S. Я думаю, что метод public void ChangeState(CustomerState newState) по канонам DDD следует убрать, и вместо него ввести методы что-то вроде PromoteToVip() и DowngradeToRegular(), но пусть в этом примере все будет как есть.
  • Вопрос задан
  • 1656 просмотров
Пригласить эксперта
Ответы на вопрос 2
@vova07
Приветствую!

Попробую как можно короче объяснить суть и упущенные моменты которые наблюдаются в вашем примере.

Ответ 1:
Прочитайте про так называемые доменные сервисы (Domain Services) и приложенческие сервисы (Application Services). В других статьях ответ на ваш вопрос люди называют Use Cases.
Юз кйэс (Use Case) - это отдельный сервис который не нарушает stateless и у которого одна конкретная задача.

Я напишу все примеры на PHP (это самый простой язык который вероятнее всего знаете и вы - извиняюсь за это но думаю лучше Go, Swift, Haskell для этого случая):

final class CreateCustomer
{
	/**
	 * @var IWriteRepository Интерфейс репозитория, где на самом деле хранистя конкретная имплементация записи сущности.
	 */
	private $repository;

	public function __construct(IWriteRepository $repositorym Validator $validator)
	{
		$this->repository = $repository;
		...
	}

	/**
	 * @param string $id ИД будущей записи.
	 * @param array $data ПОТ данные которые мы получаем в момент АПи запроса.
	 */
	public function handle(string $id, array $data) : void
	{
		/**
		 * Валидация выбрасывает исключение если данные не валидны или возвращает массив валидных данных.
		 */
		$dto = $this->validator->validate($data);

		$customer = new CustomerEntity(new UUID($id));
		/* Ентити это сущность которое содержить исключительно только бизнес логику и безнесс поведения. 
		 * В вашем примере у вас были разные сеттеры, в правильном подходе это лишенно смысла,
		 * и совсем неправильно. Думайте об методах аггрегата (Сушность с которой работает репозиторий это ни * что инное как аггрегат) как проекция бизесс логики. 
		 * для примера в реальном мире принято говорить `Новый клиент зарегистрировался в системе`, мы никогда * не перечисляем цепь проделанных событий. Кто-то говорит `Клиент заполнил свой аддрес, ФИО, потом
		 * телефон, и отправил данные` ?!
		 * 
		 * Если вы заметили правильно, то в конструкторе сущности передается только идентификатор.
		 * Такой подход похож на реальную ситуацию, что позволяет легко проектировать бизнес логику.
		 * регистрация клиента можно сравнить с регистрацией карточки пользователя в магазине.
		 * - Берём бумагу (или специальный блокнот)
		 * - Указываем номер клиента (обычно его как-то генерят например дата, время, или номер карточки 
		 * которую ему выдают)
		 * - начинаем спрашивать клиента кго персональные данные и заполняем все в ОДНОМ процессе.
		 * - Потом кидаем блокнот/бумагу оратно или в колецию где лежит другая похожая инфа.
		 */
		$customer->register($dto['name'], ...);

		/**
		 * Именно этот метод делает сохранение нового клиента.
		 * Сама реализация интереса живет в персистентном слое (`persistence layer`).
		 */
		$this->repository->store($customer);
	}
}


Пример выше демонстрирует обычный юз кйэс который является (Application Service) и который живет в слое приложения. Хочу заметить что он делает ровно одну операцию (создает нового клиента) и ничего не возвращает.

Примечание: Если обратите внимание то юз кйэс в своем методе "handle" принимает только скалярные данные, это важный момент который позволяет писать реюзабельные юз кйэсы. Самый простой пример это веб АПИ и консольная команда которая вводится в терминале.
Валидация данных делается в том же классе (это не отменяет доменную валидацию но показывает один из хороших мест это можно делать в слое приложения.)
Другое примечание сводится к тому что очень важно соблюдать простое правило: (SRP) - один юз кэйс одна операция.

Ответ на вопрос который вероятнее всего будет: Как быть если нам надо создать клиента и вернуть ответ, то есть в обычной REST ситуации ?

final class PostController extend Controller
{
	public function __construct(IWriteRepository, CreateCustomer $createCustomUseCase, RetrieveCustomer $retrieveCustomerUseCase)
	{
		...
	}

	public function __invoke(array $data)
	{
		$id = $this->repository->pickNextId();
		
		$this->createCustomUseCase->handle($id, $data);

		return $this->retrieveCustomerUseCase->handle($id);
	}
}


Примечание: В реальном мире есть два типа репозиторием, один который записывает данные и делает все манипуляции с данными - WriteRepository и второй Тольятти для чтения который используется во всех других юз кйэсах где нужно только выбирать данные - ReadRepository

Ответ 2:
REST API - не подразумевает то что вы описываете.
В REST идеологии на запрос клиента надо вернуть тот же ответ. То есть сокращенно это подразумевает получение целого ресурса (ресурс - так называемая схема запроса/ответа) и возврат его же. (Ответ может содержать дополнительные данные но они вспомогательные, такие как хедеры, пагинация, и так далее.)

Ответ 3:
Все что не является бизнес логикой надо старится вынести из сущности.
Сущность это состояние безнос процесса.
Если некие атрибуты не имеют смысла для бизнеса, то их надо избегать.

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

Если там входит логи, временные тэги, дополнительные данные для других последующих операций, то сущность спроектирована неправильно.

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

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

Итог:
разные сущности могут содержать в себе (инкапсулировать) разные Value Objects разделение классов по строгим типам очень важно. Если есть цикл жизни и идентификатор то это сущность, если нету цикла то это ВО (VO = Value Object).

Ваш CustomerState не имеет цикла жизни что сразу ограничивает вас в использовании сущности. Это сирого ВО. А если в логике появляются разные "if", "else" (лично мое мнение которое разделяют другие разработчики) то это признак плохой архитектуры, и повод задуматься над разделением Во на два или на подтипы. "VipState", "RegularState" но это больше правда для ситуаций с количеством от Ю 2 значений, если их два, то имеет место сделать белковый флаг который будет указывать на то или другое значение.

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

П.С.С: Все выше сказанное это личные наблюдения, и персональная интерпретация теории. Вы МОЖЕТЕ учитывать мои ИДЕИ но НЕ НАДО СЛЕДОВАТЬ моим интерпретациям.
ДДД это принцип мышления, еще никто в мире не смог доказать и описать 100% правильный подход, который бы должен быть быть эталоном, даже автор мышления.
Пробуйте, экспериментируйте и делитесь опытом!

Удачи!
Ответ написан
Комментировать
метод ChangeAddress ничем не отличается от того же кода в свойстве.

Насколько я понял DDD - это круто для очень больших и сложных проектов.

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

В проекте по DDD - для каждой бизнес операции заводится чуть ли ни свой проект со своими DTO, таблицами с БД и своими сервисами. Можно гораздо проще разрабатывать что то одно не вникая и не боясь задеть другие бизнес операции.
Сложность тут в том что бы все это собрать воедино.

Так что метод или свойство в одной Entity это не совсем то о чем нужно долго задумываться.

Pluralsight - Entity Framework in the Enterprise. Отличный пример.

Вопрос 2.
Лучше отправлять все, данных не так много. Сам факт запроса значительно более высокая нагрузка.
А вот проблемы с тем что бы понять отсутствие значения от пустого поля будут.
Ответ написан
Комментировать
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы