Domain-Driven Design (DDD) is a set of techniques and methods that help developers tackle business complexity. It is particularly useful for systems full of business processes and rules that aren’t easy to understand for people unfamiliar with the business area.
Using DDD is not recommended for trivial business cases, such as when the complexity lies only in the technology itself, or when the application is just CRUD-based and can be designed as simple UI mockups (shallow systems).
Domain-Driven Design techniques are split into two categories: strategic and tactical.
Strategic techniques help you explore, analyse, and understand the problem space. They provide insights into how the company operates, how communication flows, and help build a shared understanding between the development team and the business. Strategic techniques emphasise building a ubiquitous language.
Examples of strategic techniques include:
Important
|
You don’t have to use all of them to be successfully. Treat DDD as pharmacy. You should choose the ones that fit your needs and the complexity of the system you’re building. |
Let’s take a look at the modelling process.
The larger part of the process is a strategic part. That shows us how important it is to understand the problem space before we start coding. The Tactical part is only a small part of the whole process, but still essential to building good quality software.
Note
|
As you’ve likely noticed, we’ve applied strategic techniques from the first chapter. This included breaking down the business domain into bounded contexts and mapping the relationships between them. We didn’t label it as Strategic DDD at the time because we wanted to focus on resolving problems rather than the tools. However, it’s now necessary to fully explain DDD to ensure everyone has a clear understanding of what it entails. |
Tactical DDD focus on solution implementation for problems that we already comprehend. They provide a set of patterns and building blocks that help developers apply the solution (domain model) in the code and maintain it in the long term.
Note
|
The patterns of tactical DDD are used only in the Contracts microservice due to its domain complexity. |
In Chapter 3, we had to find a way to handle the extreme growth of one of our modules - Contracts. Based on concrete factors, the decision was to:
-
Extract a separate microservice from the modular monolith for Contracts
-
Introduce an external component - RabbitMQ - to handle communication between modules and extracted microservice
-
Extract building blocks to a separate solution that is built as a NuGet package and reuse it in both modular monolith and microservice
For several weeks, this solution worked as a charm.
However, new requirements appear. The way we signed contracts changed. Additionally, there are new possible actions:
-
Termination of the existing Binding Contract
-
Attaching annex to the existing Binding Contract
We already know that there is a plan to complicate Contracts even more in the upcoming weeks and months. Contracts module becomes increasingly complex.
Note
|
In this step, we will focus on tactical Domain-Driven Design. As business logic grows and becomes more complex, we consider applying the Domain Model in the Contracts microservice. It requires a change in thinking and might initially give the impression of something complicated. Nevertheless, it will make this module more straightforward to extend and maintain without dealing with spaghetti code in the long run. |
Important
|
It makes no sense to consider the Domain Model in typical CRUD modules or those based on querying, e.g., Reports. You don’t need to apply the same patterns in all modules—such behavior is a typical antipattern. Choose a matching solution based on your needs! |
Business requirements changed a lot in comparison to Chapter 3:
-
Contract can still be prepared but doesn’t have the force of law - we treat it as a draft.
-
After the Contract is signed, a Binding Contract is created. It has the force of law and binds the customer with us.
-
After three months, at any time, the customer can terminate Binding Contract without any penalty.
-
It is possible to attach an Annex to the existing Binding Contract. This way, the customer can extend the contract for another year without preparing a new Contract for him.
-
Annex can only be attached if a Binding Contract is active - has not yet expired or wasn’t terminated.
The assumptions remain unchanged to keep the environment comparable to the previous step.
In this step, we don’t change the project structure of the application. We focus only on implementing new features and refactoring the code of the Contracts microservice.
We introduce elements like:
Above image is the result of the Event Storming Design Level workshop.
Let’s focus on business rules that we identified during the workshop.
-
Annex Can Only Start During Binding Contract Period:
-
An annex can only be attached if it falls within the active period of the Binding Contract. This ensures that all extensions and modifications are valid within the contract’s timeframe.
-
-
Annex Can Only Be Attached To Active Contracts:
-
The Binding Contract must be active, meaning it hasn’t expired or been terminated. This rule prevents any modifications to contracts that are no longer valid.
-
-
The Previous Annex Must Be Signed:
-
Any new annex can only be added if the previous annex has been signed. This maintains a clear and enforceable order of amendments, ensuring that no annex is added without proper authorisation.
-
Let’s take a look closer to this rule. Previous Annex Must Be Signed. This rule is connected with the relationship between the new and previous annex. This is invariant.
Note
|
Invariant is a rule or condition that must always be true for a system to be considered in a valid state. It ensures the integrity and consistency of the domain model |
To enforce these business rules and maintain consistency, we need a robust way to protect invariant, especially in a concurrent environment.
This is where the concept of an Aggregate comes into play.
That’s why the Binding Contract entity has to be promoted to Aggregate Root that will guard the annexes invariants.
Important
|
Aggregate is a pattern that solves specific problems. It is not a panacea. "Aggregate Design Canvas" will help you make this decision consciously and consider all essential aspects like purpose, invariants, concurrency and performance. We’re encouraging you to use it in your design process. |
Let’s proceed with and analysis and take a look at the Annex. Annexes are a part of the Binding Contract aggregate. They have to be uniquely identified and encapsulate business logic. We will model it as an entity inside the Binding Contract aggregate.
Binding Contract has signature property which has business logic and is no requirement to be uniquely identified. Signature can be compared by its properties, so we’ve chosen value object as building block to model this concept.
Every time we attach annex to the Binding Contract, we want to notify other parts of the system about this event. This is a perfect use case for Domain Events.
An Entity is a representation of a business concept that has its own identity. It is defined by its attributes, behavior, and identity. Entities are used to model objects that have a lifecycle and are mutable.
-
They have a unique identity
-
They represent a business concept
-
They have behavior (methods)
-
They have state (properties)
-
They can be changed over time
-
They encapsulate business logic
-
They can raise domain events after creating or state change
-
They can be internal part of an aggregate
-
They can be becoming aggregate root when needed of protecting invariants
-
In one bounded context concept can be modelled as a value object and in another as an entity
public sealed class Annex : Entity
{
public AnnexId Id { get; init; } // Unique Entity Id
public BindingContractId BindingContractId { get; init; }
public DateTimeOffset ValidFrom { get; init; } // State
// EF needs this constructor to create non-primitive types
private Annex() { }
private Annex(BindingContractId bindingContractId, DateTimeOffset validFrom)
{
Id = AnnexId.Create();
BindingContractId = bindingContractId;
ValidFrom = validFrom;
var @event = AnnexAttachedToBindingContractEvent.Raise(Id, BindingContractId, ValidFrom); // Raise domain event
RecordEvent(@event);
}
internal static Annex Attach(BindingContractId bindingContractId, DateTimeOffset validFrom) =>
new(bindingContractId, validFrom); // Behavior method
}
Note
|
You probably have heard about Anemic Domain Model. This is known as anti-pattern. It is an entity that has only properties and no behavior. It is acceptable when you have simple CRUD operations. In complex process, we recommend encapsulating behavior (methods) in the domain entity instead of having this logic in the service layer. |
A Value Object represents a business concept without a lifecycle. Unlike entities, value objects lack identity. They are immutable, serving as explicit types that describe specific aspects of the domain.
They enhance our domain model’s expressiveness, prevent invalid object state and help us avoid primitive obsession.
"Primitive Obsession" is a code smell where we use primitive types to represent domain concepts.
For e.g., you can treat SSN as string. String allows put to field every character, but SSN has a specific format. Take a look at the image above. You can see that SSN is just only small subset of string possible values. Value object is precise and can validate a format during object initialization.
-
Iccid (International Circuit Card Identifier) in the telecommunication domain (this not just string but every character matter)
-
Money in the financial domain (amount and currency)
-
Address in the e-commerce domain
-
PhoneNumber in the telecommunication domain
-
Email in the e-commerce domain
-
Energy Indicator in the eco domain (amount and unit)
-
They have no identity
-
They represent a business concept
-
They can encapsulate business logic like validation during object initialisation
-
They can be used as a part of an entity or aggregate root
-
They’re immutable
-
They’re compared by their properties
-
They have equals and hashcode methods implemented
-
In one bounded context concept can be modelled as a value object and in another as an entity
public sealed partial class Signature
{
private static readonly Regex SignaturePattern = SignatureAllowedCharacters();
public DateTimeOffset Date { get; }
public string Value { get; }
private Signature(DateTimeOffset date, string value)
{
Date = date;
if (!SignaturePattern.IsMatch(value))
{
throw new SignatureNotValidException(value);
}
Value = value;
}
public static Signature From(DateTimeOffset signedAt, string signature) =>
new(signedAt, signature);
[GeneratedRegex(@"^[A-Za-z\s]+$")]
private static partial Regex SignatureAllowedCharacters();
}
The Aggregate enforces consistency by aggregating changes that must occur together to uphold the aggregate’s invariants. This means that all changes within the aggregate are applied in a way that preserves the business rules and consistency requirements.
It consists of one root entity, known as the Aggregate Root, and may include other entities and value objects. Consider the aggregate root as a safeguard for invariants, ensuring that the aggregate is consistent immediately. These invariants are critical domain rules that must remain consistent, and they’re inseparable.
Note
|
The pattern name “aggregate”, might be confusing. An aggregate doesn’t aggregate data or collections of objects or entities. Instead, it aggregates together the pieces of data that must change together to maintain consistency rules. |
Aggregates are also transactional boundaries. All changes to the aggregate should be done through the aggregate root. This ensures that the aggregate is always in a consistent state.
Note
|
When you’re looking for boundaries of an aggregate, you should consider which data has to change together (has to be in the same transaction) and which data can be changed independently. |
-
They enforce business rules and invariants internally
-
They have a unique identity
-
They encapsulate entities and value objects and protect them from direct access
-
They’re transactional boundaries
-
They have a lifecycle
-
They’re lightweight
-
Each method execution state change is wrapped in a transaction
-
They can raise domain events after creating or state change after creating or state change
The aggregate has its root. Entity which has been chosen as root will wrap all invariants, usually other entities and value objects will be also included. It will be the only way to access them. This Root entity is called Aggregate Root.
Domain Events are used to capture and communicate important events that occur within the domain, they’re treated as internal events. They can be transformed to public events and published to the outside world to notify other parts of the system about the changes and trigger workflows.
public sealed record AnnexAttachedToBindingContractEvent(
Guid Id,
AnnexId AnnexId,
BindingContractId BindingContractId,
DateTimeOffset ValidFrom,
DateTime OccuredAt) : IDomainEvent
{
internal static AnnexAttachedToBindingContractEvent Raise(
AnnexId annexId,
BindingContractId bindingContractId,
DateTimeOffset validFrom)
=> new(
Guid.NewGuid(),
annexId,
bindingContractId,
validFrom,
DateTime.UtcNow);
}
public interface IDomainEvent
{
Guid Id { get; }
DateTime OccuredAt { get; }
}
Note
|
Because of entities and aggregates are highly encapsulated; domain events are the only way to check if something happened in the domain model. They’re used as assertion in unit tests that make them more business expressive. |
Persistence Ignorance is a principle that states that domain model shouldn’t be aware of the persistence mechanism. It shouldn’t have any dependencies on the database or any other storage mechanism. This allows the domain model to be more focused on the business logic and be easier to test. When are you working on infrastructure, you don’t touch crucial business logic, which is good.
To run the Fitnet
application, you will need to have the recent .NET SDK
installed on your computer.
Click here
to download it from the official Microsoft website.
The Fitnet
application requires Docker
to run properly.
There are only 5 steps you need to start the application:
-
Create you own personal access token in GitHub (it is needed to be able to download our GH Packages for
Common
). Instruction how to do it you can find here. Your PAT must have only one value ofread:packages
. Note the token somewhere as it won’t be possible to read it again. -
Go to
Contracts\Src
folder and editDockerfile
. You must changeyour_username
andyour_personal_access_token
to your own values (your GH username and PAT that you generated in Step 1). Repeat the step forModularMonolith\Src
. -
Make sure that you go back to
root
directory of Chapter 3. -
Run
docker-compose build
to build the image of the application. -
Run
docker-compose up
to start the application. In the meantime it will also start Postgres inside container.
The Fitnet
modular monolith application runs on port :8080
. Please navigate to http://localhost:8080 in your browser or http://localhost:8080/swagger/index.html to explore the API.
The Contracts
microservice runs on port :8081
. Please navigate to http://localhost:8081 in your browser or http://localhost:8081/swagger/index.html to explore the API.
That’s it! You should now be able to run the application using either one of the above. 👍
Before you build or debug code in Rider
or Visual Studio
IDE, you first have to provide your username and previously generated PAT for artifactory to download packages for Common
which is a part of this repository. When you load the solution, your IDE should request the credentials:
-
Rider:
-
Visual Studio:
In case of any issues, you can add nuget feed manually:
-
Rider
-
Open
JetBrains Rider
, right click on the solution in the solution explorer and clickManage NuGet Packages
. -
Click on the
Sources
tab. -
Click the
+
button to add a new package source. -
In the
Add Package Source
window, provide Artifactory URL in thehttps://nuget.pkg.github.com/evolutionary-architecture/index.json
, fill your Github Username and PAT. -
Click
OK
to confirm the new package source. -
Make sure your new package source is enabled and then click
OK
to close theSettings
window. -
You sould be promted for user name and password (PAT).
-
-
Visual Studio
-
Open
Microsoft Visual Studio
, right click on the solution in the solution explorer and clickManage NuGet Packages for Solution
. -
Click on the
gears
icon. -
Click the
+
button to add a new package source. -
Set the package name and se the source to Artifactory URL
https://nuget.pkg.github.com/evolutionary-architecture/index.json
. -
You sould be promted for user name and password (PAT).
-
Click
OK
to confirm the new package source.
-
You should now be able to restore and download the EvolutionaryArchitecture nuget packages from your Artifactory source within Rider.
Note
|
The provided instruction is primarily intended for JetBrains Rider. However, the procedure for adding a NuGet package source in alternative IDEs like Visual Studio is quite similar. |
Running integration tests for both the Fitnet
Modular Monolith and Fitness.Contracts
applications involves similar steps, as the testing setup for both projects.
To run the integration tests for project, you can use either the command:
dotnet test
or the IDE test Explorer
.
These tests are written using xUnit
and require Docker
to be running as they use test containers
package to run PostgresSQL in a Docker
container during testing.
Therefore, make sure to have Docker
running before executing the integration tests.