Field driven design based on ABP landing-02 Best practices and principles for aggregation and aggregation roots

preface

Last Field driven design based on ABP landing-01 Panorama This paper summarizes the DDD theory and corresponding solutions, project composition, project reference relationship, and the general principles of DDD based on ABP. Starting from this article, we will introduce the best practices and principles in the implementation of DDD based on ABP Framework in more depth.

Focusing on the two core technologies of DDD and ABP Framework, a series of articles on core component implementation and comprehensive case implementation will be released later. Please pay attention!
ABP Framework workshop (QQ group: 726299208)
ABP Framework learning and DDD implementation experience sharing; Sample source code, e-book sharing, welcome to join!

Domain objects are the core of DDD. We will analyze the best practices and rules of aggregation / aggregation root, warehousing, specification and domain services in turn. There are many contents, which will be split into multiple chapters and expanded separately.

This article focuses on best practices and principles for domain objects - aggregation and aggregation roots

First, we need a business scenario. Some concepts of GitHub will be used in the example, such as Issue, Repository, Label and User.

The following figure shows the aggregation, aggregation root, entity, value object and the relationship between them corresponding to the business scenario.

Issue aggregation is a collection of issue (aggregate root), Comment (entity), and issuelabel (value object). Because other aggregations are relatively simple, we focus on the issue aggregation.

polymerization

As mentioned earlier, an aggregation is a collection of a series of objects (entity and value objects), and all associated objects are bound together through the aggregation root. This section describes best practices and principles related to aggregation.

We use the term entity for both aggregate root and subset entities, unless the aggregate root or subset entity is explicitly written.

Aggregation and aggregation root principle

Include business principles

  • Entities are responsible for implementing business rules related to their own attributes.
  • The aggregation root is also responsible for managing the state of its subset entities.
  • Aggregation should maintain its integrity and effectiveness by implementing domain rules and specifications. This means that, unlike data transfer objects (DTO s), entities have ways to implement business logic. In fact, we should implement business rules in entities as much as possible.

Single unit principle

Aggregation and all its subsets are retrieved and saved as a single unit. For example, if you add a Comment to Issue, you need to do this:

  • Get the Issue from the database, which contains all subsets: Comments (the comment list of the Issue) and IssueLabels (the tag collection of the Issue).
  • In the Issue class, add a new Comment to the calling method, for example: Issue.AddCommnet(...)
  • As a single database update operation, Issue (including all subsets) is saved to the database.

This seems strange to developers who are used to using EF Core and relational data. It is unnecessary and inefficient to obtain all the data of Issue. Why don't we directly execute an SQL insert command into the database without querying any data?

The answer is that we should implement business rules in our code and maintain data consistency and integrity. If we have a business rule, such as: users cannot comment on locked issues, how can we check the locking status of issues without retrieving the data in the database? Therefore, we can execute the business rule only when the relevant objects in the application code are available, that is, when the aggregate and all its subset data are obtained.

On the other hand, MongoDB developers will find this rule very natural. In MongoDB, an aggregate object (including subsets) is stored in a collection in the database, while in relational database, it is distributed in several tables in the database. Therefore, when you get an aggregate, all subsets have been retrieved as part of the query without any additional configuration.

The ABP framework helps to implement this principle in your application.

Example: add Comment to Issue

public class IssueAppService : ApplicationService ,IIssueAppService
{
  private readonly IRepository<Issue,Guid> _issueRepository;
  public IssueAppService(IRepository<Issue,Guid> issueRepository)
  {
    _issueRepository = issueRepository;
  }
  [Authorize]
  public async Task CreateCommentAsync(CreateCommentDto input)
  {
    var issue = await _issueRepository.GetAsync(input.IssueId);
    issue.AddComment(CurrentUser.GetId(),input.Text);
    await _issueRepository.UpdateAsynce(issue);
  }
}

_ issueRepository.GetAsync(...) Method retrieves the Issue object as a single unit by default and contains all subsets. For MongoDB, this operation is out of the box, but the EF Core needs to be configured with aggregation and database mapping. After configuration, the EF Core warehouse implementation will process it automatically_ issueRepository.GetAsync(...) Method provides an optional parameter includeDetails. You can pass the value false to disable this behavior, do not include subset objects, and only enable it when necessary.

Issue.AddComment(...) Pass the parameters userId and text to represent the user ID and comment content, add them to the Comments collection of issue, and implement the necessary business logic verification.

Finally, use_ issueRepository.UpdateAsync(...) Save changes to the database.

EF Core provides the function of Change Tracking. In fact, you don't need to call it_ issueRepository.UpdateAsync(...) Method will be saved automatically. This function is provided by the ABP work unit system. As a separate work unit, the application service method will automatically call dbcontext after execution SaveChanges(). Of course, if you use the MongoDB database, you need to update the changed entities explicitly.
Therefore, if you want to write code independent of the database provider, you should always call the UpdateAsync() method for the entity to be changed.

Transaction boundary principle

An aggregation is usually considered a transaction boundary. If the use case uses a single aggregate, reads and saves as a single unit, all changes made to the aggregate object are saved as atomic operations without explicitly using database transactions.

Of course, we may need to deal with the scenario of changing multiple aggregate instances as a single use case. At this time, we need to use database transactions to ensure the atomicity and data consistency of update operations. Because of this, the ABP framework explicitly uses database transactions for a use case (that is, an application service method), which is a unit of work.

Serializability principle

Aggregations (including root entities and subsets) should be serializable and can be transmitted over the network as a single unit. For example, MongoDB serializes and aggregates Json documents, saves them to the database, and deserializes the Json data read from the database.

This is not necessary when you use relational databases and ORM. However, it is an important practice of domain driven design.

Aggregation and aggregation root best practices

The following best practices ensure the realization of the above principles.

Reference other aggregations only by ID

An aggregate should only refer to the aggregate through the ID of other aggregates, which means that you cannot add navigation properties to other aggregates.

  • This rule enables the serializability principle to be implemented.
  • It can prevent different aggregations from operating with each other and divulging the aggregated business logic to another aggregation.

Let's take an example of two aggregation roots: GitRepository and Issue:

public class GitRepository:AggregateRoot<Guid>
{
  public string Name {get;set;}
  public int StarCount{get;set;}
  public Collection<Issue> Issues {get;set;} //Error code example
}

public class Issue:AggregateRoot<Guid>
{
  public tring Text{get;set;}
  public GitRepository Repository{get;set;} //Error code example
  public Guid RepositoryId{get;set;} //Correct example
}
  • GitRepository should not contain the Issue collection, they are different aggregations.
  • Issue should not set navigation properties associated with GitRepository because they are different aggregations.
  • Issue uses the Repository ID to associate the Repository aggregation, which is correct.

When you have an Issue that needs to be associated with a GitRepository, you can query it directly from the database through the repository ID.

For EF Core and relational database

In MongoDB, it is not suitable to have such navigation attributes / collections. If you do so, a copy of the target collection object will be saved in the database collection of the source collection, because it is serialized as JSON when saving, which may lead to inconsistency of persistent data.

However, developers of EF Core and relational database may find this restrictive rule unnecessary because EF Core can handle it in the reading and writing of the database.

However, we believe that this is an important rule, which helps to reduce the complexity of the field and prevent potential problems. We strongly recommend that this rule be implemented. However, if you think it is practical to ignore this rule, please refer to the previous section Field driven design based on ABP landing-01 Panorama Discussion on the principle of database independence in.

Keep the aggregate root small enough

A good practice is to keep a simple and small aggregation. This is because a polymer will be loaded and saved as a unit. Reading / writing a large object will cause performance problems.

Take the following example:

public class UserRole:ValueObject
{
  public Guid UserId{get;set;}
  public Guid RoleId{get;set;}
}

public class Role:AggregateRoot<Guid>
{
  public string Name{get;set;}
  public Collection<UserRole> Users{get;set;} //Error example: the number of users corresponding to the role is increasing
}
public class User:AggregateRoot<Guid>
{
  public string Name{get;set;}
  public Collection<UserRole> Roles{get;set;}//Correct example: a user has a limited number of roles
}

The Role aggregation contains a collection of UserRole value objects that track users assigned to this Role. Note that UserRole is not another aggregation, and there is no conflict for rules to refer to other aggregations only through Id.

However, there is a problem in practice. In real life, a role may be assigned to thousands (or even millions) of users. Loading thousands of data items is a major performance problem whenever you query a role from the database. Remember: aggregations are loaded by their subsets as a single unit.

On the other hand, a user may have a role set, because in reality, the number of roles a user has is limited and will not be too many. When you use user aggregation, having a list of roles can be useful without affecting performance.

If you think about it carefully, when using a non relational database (such as MongoDB), there is another problem when both Role and User have a relational list: in this case, the same information will appear repeatedly in different sets, and it will be difficult to maintain data consistency whenever you are in User Add an item to roles, and you also need to add it to roles Users.

Therefore, the aggregation boundary and size are determined according to the following factors:

  • Consider the relevance of objects and whether they need to be used together.
  • Consider performance, query (load / save) performance, and memory consumption.
  • Consider data integrity, validity and consistency.

In fact:

  • Most aggregate roots do not have subsets.
  • A subset should not contain more than 100-150 entries at most. If you think the collection may have more items, do not define the collection as part of the aggregation. Instead, consider extracting entities in the collection as another aggregation root.

Primary key in aggregation root / entity

  • An aggregation root usually has an ID attribute as its identifier (primary key, Primark Key: PK). It is recommended to use Guid as the PK of the aggregation root entity.
  • Entities in an aggregation (not the aggregation root) can use a composite primary key.

Example: aggregate roots and entities

//Aggregate root: single primary key
public class Organization
{
  public Guid Id{get;set;}
  public string Name{get;set;}
  //...
}
//Entities: composite primary keys
public class OrganizationUser
{
  public Guid OrganizationId{get;set;} //Primary key
  public Guid UserId{get;set;}//Primary key
  public bool IsOwner{get;set;}
  //...
}
  • Organization contains Guid type primary key Id
  • OrganizationUser is a subset of the Organization and has a composite primary key: OrganizationId and UserId.

This does not mean that subset entities should always have composite primary keys, which can only be set when necessary; Usually a single ID attribute.

Composite primary key is actually a concept of relational database, because subset entities have their own tables and need a primary key. On the other hand, for example, in MongoDB, you don't need to define primary keys for subset entities at all, because they are stored as part of the aggregation root.

Aggregate root / entity constructor

The constructor is where the life cycle of an entity begins. A well-designed constructor undertakes the following responsibilities:

  • Get the required entity attribute parameters to create a valid entity. You should force only necessary parameters to be passed, and you can use unnecessary attributes as optional parameters.
  • Check the validity of the parameters.
  • Initialize the subset.

Example: Issue (aggregate root) constructor

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Volo.Abp;
using Volo.Abp.Domain.Entities;

namespace IssueTracking.Issues
{
  public class Issue:AggregateRoot<Guid>
  {
    public Guid RepositoryId{get;set;}
    public string Title{get;set;}
    public string Text{get;set;}
    public Guid? AssignedUserId{get;set;}
    public bool IsClosed{get;set;}
    pulic IssueCloseReason? CloseReason{get;set;} //enumeration
    public ICollection<IssueLabel> Labels {get;set;}

    public Issue(
      Guid id,
      Guid repositoryId,
      string title,
      string text=null,
      Guid? assignedUserId = null
    ):base(id)
    {
      //Attribute assignment
      RepositoryId=repositoryId;
      //Effectiveness test
      Title=Check.NotNullOrWhiteSpace(title,nameof(title));

      Text=text;
      AssignedUserId=assignedUserId;
      //Subset initialization
      Labels=new Collection<IssueLabel>();
    }
    private Issue(){/*Deserialization or ORM required*/}
  }
}
  • The Issue class obtains the required value of the attribute through its constructor parameters, so as to create a correct and effective entity.
  • Verify the validity of the input parameters in the constructor, such as check NotNullOrWhiteSpace(...) Throw the exception ArgumentException when the passed value is null.
  • Initialize the subset. When using the Labels collection, no null reference exception will be obtained.
  • The constructor passes the parameter id to the base class. Instead of generating a Guid in the constructor, it can delegate it to another Guid generation service and pass it in as a parameter.
  • A parameterless constructor is necessary for ORM. We made it private to prevent inadvertent use of it in our code.

Entity property accessors and methods

The above example code may look strange. For example, in the constructor, we force a non null Title to be passed. However, we can set the Title property to null without any validity control. This is because the focus of the sample code is only on constructors for the time being.

If we use the public setter to declare all attributes, like the attribute example in the Issue class above, we cannot enforce its validity and integrity in the entity's life cycle. So:

  • When you need to perform any logic when setting a property, set the property to private.
  • Define public methods to manipulate these properties.

Example: modifying attributes by method

namespace IssueTracking.Issues
{
  public Guid RepositoryId {get; private set;} //No change
  public string Title { get; private set; } //Change, non null validation required
  public string Text{get;set;} //No verification required
  public Guid? AssignedUserId{get;set;} //No verification required
  public bool IsClosed { get; private set; } //It needs to be changed with CloseReason
  public IssueCloseReason? CloseReason { get;private set;} //It needs to be changed with IsClosed

  public class Issue:AggregateRoot<Guid>
  {
    //...
    public void SetTitle(string title)
    {
      Title=Check.NotNullOrWhiteSpace(title,nameof(title));
    }

    public void Close(IssueCloseReason reason)
    {
      IsClosed = true;
      CloseReason =reason;
    }

    public void ReOpen()
    {
      IsClosed=false;
      CloseReason=null;
    }
  }
}
  • The RepositoryId setter is set to private because Issue cannot move Issue to another Repository. This property does not need to be changed after it is created.
  • The Title setter is set to private. When it needs to be changed, you can use the SetTitle method, which is a controllable way.
  • Both Text and AssignedUserId have public setters because these two fields have no constraints and can be null or any value. We don't think it's necessary to define separate methods to set them up. If needed later, you can add change methods and set their setters to private. The domain layer is an internal project and will not be exposed to the client, so this change will not be a problem.
  • IsClosed and IssueCloseReason are attributes modified in pairs. Define the Close and ReOpen methods to modify them together. In this way, you can prevent closing a problem without any reason.

Exception handling in business logic and entities

When you verify and implement business logic in an entity, you often need to manage exceptions:

  • Create domain specific exceptions.
  • Throw these exceptions in entity methods if necessary.

Example:

public class Issue:AggregateRoot<Guid>
{
  //..
  public bool IsLocked {get;private set;}
  public bool IsClosed{get;private set;}
  public IssueCloseReason? CloseReason {get;private set;}

  public void Close(IssueCloseReason reason)
  {
    IsClose = true;
    CloseReason =reason;
  }
  public void ReOpen()
  {
    if(IsLocked)
    {
      throw new IssueStateException("Can't open a lock problem! Please unlock first!");
    }
    IsClosed=false;
    CloseReason=null;
  }
  public void Lock()
  {
    if(!IsClosed)
    {
      throw new IssueStateException("Cannot lock a closed problem! Please open it first!");
    }
  }
  public void Unlock()
  {
    IsLocked = false;
  }
}

There are two business rules:

  • Locked Issue cannot be reopened
  • Cannot lock a closed Issue

The Issue class throws an exception IssueStateException in these business rules.

namespace IssueTracking.Issues
{
  public class IssueStateException : Exception
  {
    public IssueStateException(string message)
      :base(message)
      {

      }
  }
}

There are two potential problems with throwing such exceptions:

  1. Should the end user see an exception (error) message in this exception situation? If so, how to localize exception messages? Because IStringLocalizer cannot be injected and used in entities, the localization system cannot be used.
  2. For a Web application or HTTP API, what HTTP Status Code should be returned to the client?

ABP framework exception handling system deals with these problems.

Example: throw business exception

using Volo.Abp;
namespace IssuTracking.Issues
{
  public class IssueStateException : BuisinessException
  {
    public IssueStateExcetipn(string code)
      : base(code)
      {

      }
  }
}
  • The IssueStateException class inherits the BusinessException class. The ABP framework returns 403 HTTP status code by default when the request is disabled; An internal error occurred when a 500 HTTP status code was returned.
  • code is used as a key in the localization resource file to find localization messages.

Now we can modify the ReOpen method:

public void ReOpen()
{
  if(IsLocked)
  {
    throw new IssueStateException("IssueTracking:CanNotOpenLockedIssue");
  }
  IsClosed=false;
  CloseReason=null;
}

Suggestion: use constant instead of magic string "issuetracking: cannotopenlockedis sue".

Then add an entry in the localization resource as follows:

"IssueTracking:CanNotOpenLockedIssue":"Can't open a lock problem! Please unlock first!"
  • When an exception is thrown, ABP automatically displays this localized message (based on the current language) to the end user.
  • The exception Code ("issuetracking: cannotopenlockedis sue") is sent to the client, so it can handle error conditions programmatically.

The business logic in the entity needs external services

When the business logic only uses the attributes of the entity, it is very simple to implement the business rules in the entity method. What if the business logic needs to query the database or use any external services that should be obtained from the dependency injection system? Remember, entities cannot inject services.

There are two ways to achieve this:

  • Implement business logic on entity methods and take external dependencies as parameters of methods.
  • Create Domain Service

Domain services will be introduced later. Now let's see how to implement it in entity classes.

Example: business rule: a user cannot assign more than 3 unresolved problems at the same time

public class Issue:AggregateRoot<Guid>
{
  //..
  public Guid? AssignedUserId{get;private set;}
  //Problem allocation method
  public async Task AssignToAsync(AppUser user,IUserIssueService userIssueService)
  {
    var openIssueCount = await userIssueService.GetOpenIssueCountAsync(user.Id);
    if(openIssueCount >=3 )
    {
      throw new BusinessException("IssueTracking:CanNotOpenLockedIssue");
    }
    AssignedUserId=user.Id;
  }
  public void CleanAssignment()
  {
    AssignedUserId=null;
  }
}
  • The AssignedUserId property setter is set to private and can be modified through the AssignToAsync and CleanAssignment methods.
  • AssignToAsync gets an AppUser entity, which actually only uses user ID, the entity is passed to ensure that the parameter value is an existing user, not a random value.
  • IUserIssueService is an arbitrary service used to obtain the number of problems allocated to users. If the business rules are not satisfied, an exception is thrown. If all rules are met, set the AssignedUserId property value.

This method fully implements the application business logic. However, it has some problems:

  • Entities become complex because entity classes depend on external services.
  • The entity becomes difficult to use. When calling the method, you need to inject the dependent external service IUserIssueService as a parameter.

[END]

Learning help

Focusing on the two core technologies of DDD and ABP Framework, a series of articles on core component implementation and comprehensive case implementation will be released later. Please pay attention!

ABP Framework workshop (QQ group: 726299208)
Focus on ABP Framework learning and DDD implementation experience sharing; Sample source code, e-book sharing, welcome to join!

Added by evaoparah on Mon, 24 Jan 2022 05:38:55 +0200