In accordance with the principles of DDD, the storage of entities is managed by the repositories, they are also repositories that allow you to read and write aggregates . In this case, the order repository must be able to read and write down an order that includes order items .
Since the method of storing entities can change with high probability, we declare in the domain not the implementation of the repository, but only the interface:
public interface IOrderRepository { Order Create(); Order ReadById(int id); void Update(Order order); void DeleteById(ind id); }
The implementation of this interface will be located in the infrastructure layer of the application (Eric Evans term). Not in DDD, this layer is called the data access layer . In accordance with the principle of data hiding, the internal data of the entity Order cannot be accessed from the infrastructure layer.
Example: the Order entity has the property CreatedAt , that is, the creation date . According to the rules of the subject area, this property is read-only , that is, it has a getter method, but no setter method. When the order repository loads data from the database, it must set the values of all properties, including CreatedAt . But it cannot do this, because it cannot change the value of a read-only property.
A similar problem is described in GoF, and the Memento ( Keeper ) pattern is used to solve it. It allows you to save the state of the object and restore it later. In the classical scheme, the state is stored in a form inaccessible for analysis and understanding, but we need something else.
If we use a DBMS, we want the state to be in a form that is convenient to store in the DBMS. To do this, we introduce DTO objects for our entities. These objects are also located in the domain layer, since they are part of its interface for the infrastructure layer.
public class OrderDto { public int Id { get; set; } public DateTime CreatedAt { get; set; } public OrderItemDto[] Items { get; set; } } public class OrderItemDto { public int Id { get; set; } public int ProductId { get; set; } public decimal Count { get; set; } public decimal Amount { get; set; } } public class Order { private readonly OrderDto dto; private readonly List<OrderItem> items; public int Id { get { return dto.Id; } } public DateTime CreatedAt { get { return dto.CreatedAt; } } public IReadOnlyCollection<OrderItem> Items { . . . } internal Order(OrderDto dto) { this.dto = dto; items = new List<OrderItem>(); foreach (var orderItemDto in dto.Items) items.Add(new OrderItem(orderItemDto)); } public void AddItem(Product product, decimal count) { var itemDto = new OrderItemDto { Id = 0, OrderId = this.Id, ProductId = product.Id, Count = count, Amount = product.Price * count }; dto.Items.Add(itemDto); var item = new OrderItem(itemDto); items.Add(item); } } public class OrderItem { internal OrderItem(OrderItemDto dto) { . . . } . . . }
In this form, the repository loads data from the database, converts it into a DTO object, and then creates a domain object from the DTO object. Or he gets the essence of the domain, converts it into a DTO object, and saves it.
public class AdoOrderRepository : IOrderRepository { private readonly SqlConnection connection; public AdoOrderRepository(SqlConnection connection) { this.connection = connection; } public Order ReadById(int id) { using (var command = connection.CreateCommand()) { // загружаем данные заказа включая агрегированные позиции // в объект OrderDto OrderDto orderDto = . . .; // волшебным образом преобразуем OrderDto в Order Order order = . . .; return order; } } public void Update(Order order) { using (var command = connection.CreateCommand()) { // волшебным образом преобразуем Order в OrderDto OrderDto orderDto = . . .; // обновляем данные из OrderDto . . . } } }
I fix once again: the domain layer describes the entities of the domain ( Order , OrderItem ), it describes the interfaces of the repositories ( IOrderRepository ), which need to be implemented in the infrastructure layer ( AdoOrderRepository ), and finally, it describes the DTO objects ( OrderDto , OrderItemDto ) that contain all the fields that the entity ( Order ) wants to store for a long time. Entities ( Order ) contain logic and hide implementation, DTO objects contain only data and no logic.
Repository implementations can work with DTO objects, in particular, they can send a read request to the database and write the results to a DTO object, since the DTO object is very simple. But how do they convert a DTO object into an entity, and vice versa? In the sample code that I gave above, I wrote that this happens in a magical way .
It's time to figure out exactly how. The domain objects themselves, such as Order , could save and restore their state. But they have a main function — they are Domain Orders. Adding a second function would violate the sole responsibility principle. We need a separate class that must have access to the state of the Order entity. But one class should not know the details of the implementation of another class.
Except when this class is nested.
public class Order { private readonly OrderDto dto; private Order(OrderDto dto) { this.dto = dto; } . . . public static class DtoMapper { public static void Map(Order order, OrderDto orderDto) { . . . } public static void Map(OrderDto orderDto, Order order) { . . . } } }
The inner class is described as public, so the implementation of the repositories at the infrastructure level can access it.
With this approach, it does not matter how exactly the repositories are implemented: through a large ORM like the Entity Framework, through a simple microORM Dapper, or through ADO.
OrderItem must be taken with aggregated objects, such as the OrderItem / OrderItemDto . Inside we have the OrderItemDto collection, outside the OrderItem , and they should match. This task is not very difficult.
Finally, I would like to illustrate the difference between domain objects and DTO, because sometimes it seems that there is so much in common between them that DTO could be abandoned. Sometimes there really is a lot in common, but sometimes not.
If we have an entity user who has a password, then, in the subject area, it looks like this:
public class User { public int Id { get; } public DateTime CreatedAt { get; } public string Login { get; } bool ValidatePassword(string password); void ChangePassword(string oldPassword, string newPassword); }
The DTO object for it looks radically different.
public class UserDto { public int Id { get; set; } public DateTime CreatedAt { get; set; } public string Login { get; set; } public byte[] PasswordHash { get; set; } }
As you can see, the DTO object allows access to raw data, in this case, the password hash. The domain object hides the hash, not only for writing but also for reading, in order to avoid possible security problems. It provides the ValidatePassword and ChangePassword methods, which prompt us with scripts for using the User object.
That is why DTO-objects and entities of the domain, in spite of some duplication of fields, belong to different levels and have different purposes.
There are some tips on how to design DTO objects. Ideally, they should be made such that they can be immediately used in the Entity Framework, NHibernate, or Dapper. This means that you can use foreign keys , navigation properties , and attributes like [Key] , [TableName] from the System.Components.Annotations namespace. This is not necessary, but it will simplify the implementation of repositories. On the other hand, it is better not to focus on ORM-specific attributes and solutions, such as [Index] . This way you leave DTO objects unbound to specific ORMs and can quickly go from EF / SQL to MongoDB or Redis.
Finally, after the infinite theory, I will give a specific answer to the question of how exactly to save the changes of the aggregation root without using the ORM. I will add method AdoOrderRepository.Update :
public void Update(Order order) { var orderDto = new OrderDto(); Order.DtoMapper.Map(order, orderDto); using (var transaction = connection.BeginTransaction()) { var orderDto = new OrderDto(); Order.DtoMapper.Map(order, orderDto); var command = connection.CreateCommand(); command.CommandText = "UPDATE [Orders] SET CreatedAt = @CreatedAt WHERE Id = @Id"; // Поле CreatedAt только для чтения, так что я просто // иллюстрирую идею. command.Parameters.Add("@CreatedAt", orderDto.CreatedAt); command.Parameters.Add("@Id", orderDto.Id); command.ExecuteNonQuery(); command.CommandText = "UPDATE [OrderItems] SET Count = @Count, Amount = @Amount WHERE Id = @Id"; foreach (var item in dto.Items.Where(x => x.Id != 0)) { command.Parameters.Clear(); command.Parameters.Add("@Count", item.Count); command.Parameters.Add("@Amount", item.Amount); command.Parameters.Add("@Id", item.Id); command.ExecuteNonQuery(); } command.CommandText = "INSERT [OrderItems] (OrderId, ProductId, Count, Amount) VALUES (@OrderId, @ProductId, @Count, @Amount)"; foreach (var item in dto.Items.Where(x => x.Id == 0)) { command.Parameters.Clear(); command.Parameters.Add("@OrderId", item.OrderId); command.Parameters.Add("@ProductId", item.ProductId); command.Parameters.Add("@Count", item.Count); command.Parameters.Add("@Amount", item.Amount); item.Id = (int)command.ExecuteScalar(); } transaction.Commit(); } Order.DtoMapper.Map(orderDto, order); }
In accordance with DDD, we must save not only the root of the aggregate, but also all aggregated entities, which we are doing in this method. Create a transaction to ensure consistency of changes. The code is bulky, but simple. To get rid of bulkiness, just you can use Dapper.
не используя ORM- why didn’t you please? What is microOrm - such a technology has not been heard, and how does it differ radically from ORM, that you really don't want to use ORM? - Goncharov Alexander