Implementing Domain Driven Design: Part II
Implementation: The Building Blocks
This is the essential part of this series. We will introduce and explain some explicit rules with examples. You can follow these rules and apply in your solution while implementing the Domain Driven Design.
The Example Domain
The examples will use some concepts those are used by GitHub, like Issue, Repository, Label and User, you are already familiar with.
The figure below shows some of the aggregates, aggregate roots, entities, value object and the relations between them:
Issue Aggregate consists of an Issue Aggregate Root that contains Comment and IssueLabel collections.
Other aggregates are shown as simple since we will focus on the Issue Aggregate:
Aggregates
As said before, an Aggregate is a cluster of objects (entities and value objects) bound together by an Aggregate Root object.
Aggregate / Aggregate Root Principles
Business Rules
Entities are responsible to implement the business rules related to the properties of their own. The Aggregate Root Entities are also responsible for their sub-collection entities.
An aggregate should maintain its self integrity and validity by implementing domain rules and constraints.
That means, unlike the DTOs, Entities have methods to implement some business logic. Actually, we should try to implement business rules in the entities wherever possible.
Single Unit
An aggregate is retrieved and saved as a single unit, with all the sub-collections and properties. For example, if you want to add a Comment to an Issue, you need to.
Get the Issue from database with including all the sub-collections (Comments and IssueLabels).
Use methods on the Issue class to add a new comment, like Issue.AddComment(...).
Save the Issue (with all sub-collections) to the database as a single database operation (update).
That may seem strange to the developers used to work with EF Core & Relational Databases before.
Getting the Issue with all details seems unnecessary and inefficient. Why don't we just execute an SQL Insert command to database without querying any data?
The answer is that we should implement the business rules and preserve the data consistency and integrity in the code.
If we have a business rule like "Users can not comment on the locked issues", how can we check the Issue's lock state without retrieving it from the database?
So, we can execute the business rules only if the related objects available in the application code.
Example: Add a comment to an issue
_issueRepository.GetAsync method retrieves the Issue with all details (sub-collections) as a single unit by default.
While this works out of the box for MongoDB, you need to configure your aggregate details for the EF Core. But, once you configure, repositories automatically handle it.
_issueRepository.GetAsync method gets an optional parameter, includeDetails, that you can pass false to disable this behavior when you need it.
Issue.AddComment gets a userId and comment text, implements the necessary business rules and adds the comment to the Comments collection of the Issue.
Finally, we use _issueRepository.UpdateAsync to save changes to the database.
Transaction Boundary
An aggregate is generally considered as a transaction boundary.
If a use case works with a single aggregate, reads and saves it as a single unit, all the changes made to the aggregate objects are saved together as an atomic operation and you don't need to an explicit database transaction.
However, in real life, you may need to change more than one aggregate instances in a single use case and you need to use database transactions to ensure atomic update and data consistency.
Serializability
An aggregate (with the root entity and sub-collections) should be serializable and transferrable on the wire as a single unit.
For example, MongoDB serializes the aggregate to JSON document while saving to the database and deserializes from JSON while reading from the database.
The following rules will already bring the serializability.
Aggregate / Aggregate Root Rules & Best Practices
The following rules ensures implementing the principles introduced above.
Reference Other Aggregates Only by ID
The first rule says an Aggregate should reference to other aggregates only by their Id. That means you can not add navigation properties to other aggregates.
This rule makes it possible to implement the serializability principle.
It also prevents different aggregates manipulate each other and leaking business logic of an aggregate to one another.
You see two aggregate roots, GitRepository and Issue in the example below:
GitRepository should not have a collection of the Issues since they are different aggregates.
Issue should not have a navigation property for the related GitRepository since it is a different aggregate.
Issue can have RepositoryId (as a Guid).
So, when you have an Issue and need to have GitRepository related to this issue, you need to explicitly query it from database by the RepositoryId.
Keep Aggregates Small
One good practice is to keep an aggregate simple and small.
This is because an aggregate will be loaded and saved as a single unit and reading/writing a big object has performance problems. See the example below:
Role aggregate has a collection of UserRole value objects to track the users assigned for this role.
Notice that UserRole is not another aggregate and it is not a problem for the rule Reference Other Aggregates Only By Id.
However, it is a problem in practical. A role may be assigned to thousands (even millions) of users in a real life scenario and it is a significant performance problem to load thousands of items whenever you query a Role from database (remember: Aggregates are loaded by their sub-collections as a single unit).
Primary Keys on the Aggregate Roots / Entities
An aggregate root typically has a single Id property for its identifier (Primark Key: PK). We prefer Guid as the PK of an aggregate root entity.
An entity (that's not the aggregate root) in an aggregate can use a composite primary key.
Organization has a Guid identifier (Id).
OrganizationUser is a sub-collection of an Organization and has a composite primary key consists of the OrganizationId and UserId.
Constructors of the Aggregate Roots / Entities
The constructor is located where the lifecycle of an entity begins. There are a some responsibilities of a well designed constructor:
Gets the required entity properties as parameters to create a valid entity. Should force to pass only for the required parameters and may get non-required properties as optional parameters.
Checks validity of the parameters.
- Initializes sub-collections.
Issue class properly forces to create a valid entity by getting minimum required properties in its constructor as parameters.
The constructor validates the inputs (Check.NotNullOrWhiteSpace(...) throws ArgumentException if the given value is empty).
It initializes the sub-collections, so you don't get a null reference exception when you try to use the Labels collection after creating the Issue.
The constructor also takes the id and passes to the base class. We don't generate Guids inside the constructor to be able to delegate this responsibility to another service.
Private empty constructor is necessary for ORMs. We made it private to prevent accidently using it in our own code.
Entity Property Accessors & Methods
The example above may seem strange to you! For example, we force to pass a non-null Title in the constructor.
However, the developer may then set the Title property to null without any control. This is because the example code above just focuses on the constructor.
If we declare all the properties with public setters (like the example Issue class above), we can't force validity and integrity of the entity in its lifecycle.
So:
Use private setter for a property when you need to perform any logic while setting that property.
Define public methods to manipulate such properties.
Example: Methods to change the properties in a controlled way
RepositoryId setter made private and there is no way to change it after creating an Issue because this is what we want in this domain: An issue can't be moved to another repository.
Text and AssignedUserId has public setters since there is no restriction on them. They can be null or any other value. We think it is unnecessary to define separate methods to set them. If we need later, we can add methods and make the setters private. Breaking changes are not problem in the domain layer since the domain layer is an internal project, it is not exposed to clients.
IsClosed and IssueCloseReason are pair properties. Defined Close and ReOpen methods to change them together. In this way, we prevent to close an issue without any reason.
Business Logic & Exceptions in the Entities
When you implement validation and business logic in the entities, you frequently need to manage the exceptional cases.
- Create domain specific exceptions.
- Throw these exceptions in the entity methods when necessary.
There are two business rules here:
- A locked issue can not be re-opened.
- You can not lock an open issue.
Issue class throws an IssueStateException in these cases to force the business rules:
There are two potential problems of throwing such exceptions;
In case of such an exception, should the end user see the exception (error) message? If so, how do you localize the exception message? You can not use the localization system, because you can't inject and use IStringLocalizer in the entities.
For a web application or HTTP API, what HTTP Status Code should return to the client?
ABP's Exception Handling system solves these and similar problems.
Example: Throwing a business exception with code
IssueStateException class inherits the BusinessException class. ABP returns 403 (forbidden) HTTP Status code by default (instead of 500 - Internal Server Error) for the exceptions derived from the BusinessException.
The code is used as a key in the localization resource file to find the localized message.
Now, we can change the ReOpen method as shown below:
And add an entry to the localization resource like below:
When you throw the exception, ABP automatically uses this localized message (based on the current language) to show to the end user.
The exception code (IssueTracking:CanNotOpenLockedIssue here) is also sent to the client, so it may handle the error case programmatically.