During the New Year's holiday, I studied some domain-driven design knowledge. Here is a summary.
In domain-driven design, layered architecture is an important part, so let's start with layering and gradually understand some concepts in DDD.
Data Mapping Layer
The first step is the database. The database is part of the persistence layer, responsible for mapping entity objects to database tables and configuring database connections, database context configurations, ORM configurations, etc. The database and cache are both used by the domain layer, but they cannot be used directly; instead, they are encapsulated through repositories.
In the program, entity classes are used to correspond to database tables. As the persistence layer, the persistent objects are referred to as Persistent Objects, abbreviated as PO, indicating persistent objects. Since entities are called Entities, they are often suffixed with Entity
, while PO is used to describe Entity objects.
However, in C# coding standards, suffix naming is not recommended, so if you pay attention to ABP's naming conventions, you may not see suffixes.
For example, an entity is as follows:
public class TodoItem
{
public int Id { get; set; }
public string Text { get; set; }
}
In the data persistence layer, on one hand, we need to configure the mapping between entities and database tables, and on the other hand, we need to configure the ORM framework and clients like Redis for dependency injection into the domain layer.
Aggregates and Repositories
The repository is part of the domain layer, categorized by domain.
Many people's approach is to create a repository for each entity, one repository per table, which can lead to complexity, increased code volume, and redundancy.
For example, with User and Password tables, creating a repository for Password is meaningless as Password only has concrete meaning in conjunction with User.
Pseudocode for User and Password is as follows:
class User
{
int Id
string Name
string Email
}
class Password
{
int Id
int UserId
string Password
}
Password is associated with User through Password.UserId - User.Id
, and it's clear that User is the primary entity, which we can refer to as the base class or the primary entity. This "association" is an aggregate.
An aggregate represents a group of domain objects (including entities and value objects) that express a complete domain concept. Every aggregate has a root entity known as an aggregate root.
In ABP, we use FullAuditedAggregateRoot<>
or IFullAuditedObject<>
to identify an aggregate.
public class Author : FullAuditedAggregateRoot<Guid>
{
public string Name { get; private set; }
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
}
Returning to the User and Password example.
At this point, User.Id can serve as the aggregate root, allowing us to aggregate User and Password together.
The aggregate root is usually the primary key of the base class and has the following constraints:
- It is recommended to always use the Id property as the aggregate root primary key.
- It is not recommended to use composite keys in the aggregate root.
- It is recommended that all aggregate roots use a Guid type primary key. (This is recommended in ABP)
Regarding the third point, in distributed systems, it may be necessary to use snowflake IDs as primary keys. There are many scenarios where it is inconvenient to use Guid as a primary key, so readers should decide accordingly.
In practice, it is not necessary to know Password's ID; knowing User is sufficient. The primary key of Password is used for storage in the database, which is not important for the business, so we can simplify based on actual conditions by integrating the fields of User and Password:
class LoginAggregate
{
int Id
string Name
string Email
string Password
}
This way, when we log in, we don't need to check User then check Password; with this aggregate, we can check multiple fields in a single class.
However, this approach results in copying many fields each time. Certainly, you could resolve this through inheritance, but it may lead to coupling issues.
To represent User.Id (the aggregate root), we can express this aggregate through an abstract interface:
class LoginAggregate : AggregateRoot<int>
{
string Name
string Email
string Password
}
User is related to many tables beyond just Password; it is impractical to write all associations in one aggregate. In a login scenario, we could include User, Password, and LoginRecord three entities, whereas for user information maintenance, we only need User. Based on User, there can be multiple distinct aggregates; thus, how should we design aggregates? Refer to the aggregate boundaries in ABP:
It is recommended that aggregates be kept as small as possible. Most aggregates contain only primitive properties and do not have sub-collections. Consider these as design decisions:
- The performance and memory costs of loading and saving aggregates (remember, aggregates are typically loaded and saved as distinct units). Larger aggregates consume more CPU and memory.
- Consistency & validity boundaries.
EFCore has navigation properties that can help us simplify aggregate implementation by generating corresponding relationships through foreign keys.
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
public List<Post> Posts { get; set; }
}
public class Post
{
public int PostId { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public int BlogForeignKey { get; set; }
public Blog Blog { get; set; }
}
However, since many times it is not recommended to use foreign keys, these relationships cannot be automatically generated. That said, the .NET Freesql framework allows manual configuration of navigation properties without needing foreign keys, but this relationship is written into the entity.
What is the relationship and distinction between entities and aggregates in .NET?
Regrettably, both definitions can be used interchangeably, which can be confusing; this is the practical meaning for many.
This answer can reference: https://stackoverflow.com/questions/32353835/difference-between-an-entity-and-an-aggregate-in-domain-driven-design
Aggregates themselves are also entities.
For aggregates, since they are entities themselves, they possess fields and attributes:
public class Author : FullAuditedAggregateRoot<Guid>
{
public string Name { get; private set; }
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
Note that the Name field is set to private
to prevent arbitrary modifications. Therefore, if modifications are needed, corresponding methods should be defined in the aggregate to handle them.
internal Author(Guid id, [NotNull] string name, DateTime birthDate, [CanBeNull] string shortBio = null) :
base(id)
{
SetName(name);
BirthDate = birthDate;
ShortBio = shortBio;
}
internal Author ChangeName([NotNull] string name)
{
SetName(name);
return this;
}
private void SetName([NotNull] string name)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name), AuthorConsts.MaxNameLength);
}
This concludes the discussion about aggregates and entities.
Since this article only provides a simple overview, readers are encouraged to refer to others' design thoughts and theories: https://zhuanlan.zhihu.com/p/359672528
Repositories and Domain Layer
The domain layer should contain the following:
- Entities & Aggregate Roots
- Value Objects
- Repositories
- Domain Services
In ABP, typically there is one repository per aggregate.
"Act as an intermediary between the domain layer and the data mapping layer, using collection-like interfaces to manipulate domain objects." (Martin Fowler)
In simple terms, domain services should not directly manipulate frameworks like ORM or Redis; domain services also do not concern themselves with whether data is retrieved from the database or cache. Domain services are only concerned about how to operate on this data. This is where repositories come into play.
Here’s a simple example.
For example, a product that users can order, and in the backend, we need to check how many people have ordered this product.
Entities:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
// Other properties for the product
}
public class Order
{
public int ProductId { get; set; }
public int Id { get; set; }
public int UserId { get; set; }
public long CreationTime { get; set; }
public double Money { get; set; }
}
Aggregates:
public class OrderAggregate
{
public int ProductId { get; set; }
public int Id { get; set; }
public int UserId { get; set; }
public long CreationTime { get; set; }
public double Money { get; set; }
public User User { get; set; }
}
public class ProductAggregate
{
public int Id { get; set; }
public string Name { get; set; }
public List<OrderAggregate> Orders { get; set; }
}
A simple repository:
public class ProductRepository
{
private readonly AppCenterContext _context;
private readonly ILogger<ProductRepository> _logger;
public ProductRepository(AppCenterContext context, ILoggerFactory<ProductRepository> loggerFactory)
{
_context = context;
_logger = loggerFactory.CreateLogger<ProductRepository>();
}
// Get the count of related orders
public async Task<int> GetOrderCountAsync(int productId)
{
var count = await _context.Order.Select
.Where(x => x.ProductId == productId).CountAsync();
return count;
}
}
The repository encapsulates methods for specific scenarios and operations, and caching can also be included there. In the domain service, the repository class can be used if dependency injection is applied.
Using Repositories and Domain Services
Creating a repository:
// Repository
public class AccountRepository
{
public IEneumerable<Object> GetAsync()
{
...
}
public DataResult InsertAsync(Account account)
{
...
}
}
Creating a domain service:
public class AccountManager
{
private readonly AccountRepository _accountRepository;
public AccountManager(AccountRepository accountRepository)
{
_accountRepository = accountRepository;
}
public DataResult InsertAsync(Account account)
{
if(账号已经存在) // "Account already exists"
{
return ...
}
return await _accountRepository.InsertAsync(account);
}
}
Domain services use the Manager suffix.
In domain services, there is no need to encapsulate the repository; the interfaces for data retrieval and insertion in the repository are raw. When inserting data, the logic for verifying if usernames, emails, etc., already exists is not handled in the repository; it should be confirmed in the domain service before being inserted via the repository.
Application service:
public class AccountService
{
private readonly AccountRepository _accountRepository;
private readonly AccountManager _accountManager;
// Directly use the repository for interface retrieval
public IEneumerable<Object> GetAsync()
{
return await _accountRepository.GetAsync();
}
// ...
public DataResult InsertAsync(Account account)
{
return await _accountManager.InsertAsync(account);
}
}
文章评论