DDD
Let’s say we have a requirement, saying:
The same specification can be coded by different people differently. See for yourself, which version do you prefer?
patient.SetShotType(ShotType.FluVaccince);
patient.SetDode(DoseType.Standard)
patient.SetNurse(nurse);Although this code snippet appears simpler, the one below aligns better with our requirements, making it more intuitive, easier to understand, and maintain.
Vaccine vaccine = Vaccine.CreateStandardFluDose();
nurse.AdministerFluVaccine(patient, vaccine);This is what DDD is about.
Domain-Driven Design (DDD) is a software development approach that focuses on modeling software to match the business domain.
In today’s article, we’ll explore the strategic patterns of Domain-Driven Design — ubiquitous language, domain, subdomain, and bounded contexts — and how they can help you gain a clearer understanding of your business logic.
That’s not all — we’ll also explore how to translate specification requirements into code using the following tactical patterns: layers, modules, shared kernel, anti-corruption layer, entity, value object, aggregate root, event, domain service, factory, repository, and specification.
Without any further ado, let us begin.
🔷Strategic Patterns
Strategic patterns help you model the structure of your domain by organizing subdomains, drawing boundaries, and defining a common glossary.
They focus on what the domain is.
Ubiquitous Language
The Ubiquitous Language is the biggest achievement DDD brings. The whole point is to ensure developers and domain experts “speak” the same language. If your domain expert mentions “Users”, it means you should have an entity class called “User”, if they say “create”, you should have a method with the same name.
You have already seen how ubiquitous language affects the code:
Domain experts often use their own jargon, while tech professionals use different terms to describe the same concepts. It is important to follow the same consistent terminology; otherwise project can run into major problems.
Different language creates a disconnection between everyday conversations and the code. To bridge this gap, it’s essential to establish a shared glossary of terms that everyone consistently uses.
The ubiquitous language is a common language between developers and domain experts
Domain
The domain is what the business does, its processes and structure. It is the highest level of the business area. For example, banking systems, healthcare services, etc.
The domain is your business area around which your software is built
SubDomain
A domain consists of multiple subdomains, each representing a distinct aspect of the business. For instance, a healthcare system may include components like an online queue platform, user management, and report generation.
The subdomain is a distinct area of the larger domain with its own concerns, rules, and processes
Subdomains can also be categorized:
- core domain — is the most important part of your system. It differentiates your business from competitors and is what you should focus on the most (e.g. patient management)
- supporting subdomain — subdomain that is necessary to run the business but is not unique or strategic (e.g. reporting)
- generic subdomain — common subdomains that can be solved by off-the-shelf software (e.g. notifications)
Bounded Context
It is often hard to work on the entire system. To reduce the complexity, you can isolate different parts of the domain.
The bounded context defines clear boundaries between domain parts
Usually, bounded contexts correspond to subdomains, but not necessarily.
Notice that each bounded context has its own ubiquitous language. The same entity may have different names between contexts (doctor, user).
Context Map
Bounded Contexts do not work in isolation. They often interact with each other. Their communication is illustrated on the context map.
The context map illustrates how different bounded contexts relate and interact within a larger system
There are different patterns that describe their communication. Some of the practicals we will discuss, but about others you can read here.
🔧 Tactical Patterns
Tactical patterns provide concrete tools and building blocks for implementing domain logic in the code.
They focus on how to model components to reflect business rules and behavior accurately.
Layers
Originally, DDD introduced the following layers to separate your code into technical parts:
- Presentation Layer — handles interaction with users or external systems (UI, API, CLI, etc)
- Application Layer — orchestrates use cases, manages application behaviour like thread coordination, sessions, transaction boundaries, etc (its responsibility is to define what the application does, not to implement business rules)
- Domain Layer — represents concepts of the business, its rules, entities, and behaviour, that are written in requirements
- Infrastructure Layer — provides technical capabilities like persistence, logging, caching, etc
Personally, I prefer Onion architecture because it leverages the dependency inversion principle, keeping the domain model at the center and ensuring that high-level business rules are not dependent on low-level technical details.
That said, you’re still free to stick to the traditional DDD architecture🙃.
And yes, while DDD is a methodology for designing software systems, you can also refer to it as an architecture. I mentioned that before, and a lot of people were triggered… so I will say it again to light fires under a few buns😏.
Module
You are not forced to implement all bounded contexts in a single application. The code can be grouped by namespaces, where each has its own architecture.
In the code, this usually translates to separate modules within a monolith or independent microservices.
Shared Kernel
Even when you have separate bounded contexts, it is likely they will have something in common.
A small, explicitly shared subset of the domain model that is mutually agreed upon and maintained by teams is called a shared kernel.
It is usually small and includes marker interfaces, value objects, or even domain events, as long as they are truly shared concepts.
interface IEntity { }
readonly struct Money : IEquatable<Money>
{
public required decimal Amount { get; init; }
public required string Currency { get; init; }
}Anti-Corruption Layer
Sometimes, one module evolves faster than another. As a result, it turns into legacy systems that suffer from quality issues such as convoluted data schemas. Not to adhere the obsolete APIs, you can introduce an intermediate component — Anti-Corruption Layer — through which communication is performed.
An Anti-Corruption Layer acts as a translator between two bounded contexts or external systems, allowing your domain model to remain pure and consistent, even when interacting with legacy models.
public class ShipmentDto
{
public string package_id { get; set; }
public int delivered_at { get; set; }
public string status { get; set; }
}
// Anti-Corruption Layer
public class ShipmentDto
{
public string ShipmentId { get; }
public DateTimeOffset DeliveryDate { get; }
public ShipmentStatus Status { get; }
public ShipmentDto(Legacy.ShipmentDto shipmentDto)
{
ShipmentId = shipmentDto.package_id;
DeliveryDate = DateTimeOffset.FromUnixTimeSeconds(shipmentDto.delivered_at);
Status = Enum.Parse<ShipmentStatus>(shipmentDto.status);
}
}This is basically the adapter pattern on a larger scale.
Entity
When your domain has an object that can be distinguished from others and has a lifetime, it can be designed as an entity.
class Patient
{
public int Id { get; private set; }
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
public Address Address { get; private set; }
public void UpdateAddress(Address newAddress)
{
Address = newAddress;
}
. . .
}As you can see, the entity has an ID to be uniquely identified, and other properties describing its state. These properties can be simple types or custom value objects (see below).
Notice⚠️
DDD emphasizes using domain-aware identifier, instead of an autoincremental number or GUID. For example, in the shipping domain, an order can be identified by a transport waybill number. However, designing a class for each ID is just not practical. So do not stess too much when
intis used as ID🙃.
To maintain its lifetime, the entity has to be persisted in some kind of storage (like a database), and also be mutable, reflecting changes in the real-world concept they model.
Even though DDD can be implemented with different paradigms, more than often you will find it in conjunction with OOP. As a result, entities are rich and not anemic, containing both data and behaviour, where both properties and methods match ubiquitous language terminology.
Value Object
If your domain object is not unique, you can design it with value objects:
record Address(string Street, string City, string ZipCode);This class has no ID and can be differentiated by its attributes. If any property changes, it’s essentially a different value object.
It means that value objects are immutable. Two value objects with the same property values are considered the same. They need to be compared by properties and not by reference.
Similar to entities, value objects can and should have behavior, validation, static factory methods, etc.
Notice⚠️
— The same concept in different system can be entity or value object. Everything depends on the business specification.
— Be pragmatic, do not create Value Object for every type like email, color, etc. Sometimesstringis enough 🙃. Do it when it makes sense for you and your domain. For example, Value Objects are great to encapsulate validation when you try to create them. Or when they consist of multiple propertiesStatusInfo(status, date).
Aggregate Root
An aggregate is a cluster of related domain objects (entities and value objects) that are treated as a single unit for data changes and consistency.
The aggregate is the most complex pattern, even though it may not look like that at the beginning:
// Value Object
public record Address(string Street, string City, string ZipCode);
// Entity
private class MedicalRecord
{
public Guid Id { get; private set; }
public string Description { get; private set; }
public DateTime UpdatedAt { get; private set; }
// internal, only aggregate can use it
internal void ChangeDescription(
string newDescription,
DateTime updatedAt)
{
Description = newDescription;
UpdatedAt = updatedAt;
}
}
// Aggregate Root
public class Patient
{
public Guid Id { get; private set; }
public string Name { get; private set; }
private readonly List<MedicalRecord> _medicalHistory = new(); // private
public IReadOnlyCollection<MedicalRecord> MedicalHistory => _medicalHistory.AsReadOnly();
public void AddMedicalRecord(string description)
{
var record = new MedicalRecord(description);
_medicalHistory.Add(record);
}
public void UpdateMedicalRecordDescription(
Guid recordId,
string newDescription,
DateTime updatedAt)
{
var record = _medicalHistory.FirstOrDefault(r => r.Id == recordId);
if (record is null) throw new InvalidOperationException("Record not found.");
record.ChangeDescription(newDescription, updatedAt);
}
}There are multiple things to discuss:
- the aggregate is just an entity; it has fields and is stored in the database
public class Patient
{
public Guid Id { get; private set; }
public string Name { get; private set; }
. . .
}- the aggregate defines boundaries that must not be accessed directly
The aggregate root (Patient) is the only entry point to interact with its internals. All operations — creation, updates, deletions — must be invoked on the root, which enforces business rules and invariants.
var patient = _patientRepository.GetById(id);
// MedicalRecord.ChangeDescription() is internal
// and can only be invoked by the aggregate
// consumers of Patient can read medical records
// but must go through Patient to modify them
patient.UpdateMedicalRecordDescription(1, "Updated", _timeService.UtcNow);- using a
privatecollection field, better for the aggregate’s encapsulation
This way MedicalRecord cannot be added from “outside the aggregate root” directly to the collection, but only through the method .AddMedicalRecord() which includes validation and other behavior
public class Patient
{
. . .
private readonly List<MedicalRecord> _medicalHistory = new(); // private
public IReadOnlyCollection<MedicalRecord> MedicalHistory => _medicalHistory.AsReadOnly();
public void AddMedicalRecord(string description)
{
// business invariant - cannot add record without description
if (string.IsNullOrWhitespace(description))
{
return;
}
var record = new MedicalRecord(description);
_medicalHistory.Add(record);
}
}Any behavior and validations are controlled by the aggregate root in order to maintain consistency and invariants between the whole aggregate.
Business invariant — is a condition that should be truthy for the entire lifecycle of domain model
Some developers claim that aggregate should always be in a valid state, while others prefer to validate invariants explicitly. In any case, you would always have to choose between purity, encapsulation, and performance.
- it is acceptable to construct an entity outside of the aggregate
Just make sure you still maintain all invariants.
public class MedicalRecord
{
. . .
public MedicalRecord(string description)
{
// validation
if (string.IsNullOrWhitespace(description))
{
throw new ArgumentException(nameof(description));
}
Description = description;
}
}
public class Patient
{
. . .
public void AddMedicalRecord(MedicalRecord record) // use entire entity
{
_medicalHistory.Add(record);
}
}This way, you avoid a long argument list, duplicated code, and you keep your aggregate’s responsibilities focused.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
In any case, you can’t persist an entity on its own — the aggregate is saved as a whole, or not at all.
- changes to the aggregate are atomic — all or nothing. The entire graph of entities should be saved during the transaction
Within a single transaction in a bounded context, only one aggregate should be modified. Therefore, aggregates should be designed with care.
Large aggregates are harder to construct, load into memory, they are less performant and harder to persist during transactions.
On the other hand, with smaller aggregates, it is harder to maintain data consistency and invariants.
Notice⚠️
Use cases often demand updating multiple aggregates within a single transaction. While this technically breaks the rule, it can be a practical choice to avoid the complexity of eventual consistency and additional infrastructure overhead 🙃.
Now, a bit about the relation between classes:
- an aggregate should not hold a reference to another aggregate
Instead, when needed, it should only store the other aggregate’s ID.
class Appointment
{
public Guid Id { get; set; }
public Guid PatientId { get; set; } // reference by ID to Patient aggregate
}Aggregates are consistency boundaries; each manages its own invariants independently.
Holding a direct reference to another aggregate could create tight coupling and break these boundaries.
Referencing by ID keeps aggregates decoupled and allows them to be loaded and persisted separately.
- an entity should belong to only one aggregate to maintain clear boundaries and consistency rules
Aggregates define consistency boundaries — all invariants inside an aggregate are guaranteed to be consistent within a transaction. If the same entity appears in multiple aggregates, it can cause conflicting consistency rules and complex synchronization problems.
Instead, different aggregates can reference the same entity by its ID, but the entity itself “lives” inside only one aggregate.
(You can reuse the type definition of an entity across different aggregates, but each aggregate should own a separate instance of that entity)
- value objects can be part of different aggregates
Value Objects are immutable and don’t have identity, so they can be safely reused across aggregates. Each aggregate that uses the Value Object holds its own copy, so no consistency conflicts arise.
- entities inside the same aggregate can reference each other directly
All entities within an aggregate are part of the same consistency boundary. They are loaded and saved together as one unit. Direct references allow enforcing business rules and invariants across related entities.
It is still preferable to keep dependencies in the form of an acyclic graph or a tree, but not cyclonic.
Events
Often in ubiquitous language, you can hear the following phrases:
- “When…”
- “In case…”
- “On occurrence…”
- “If… then…”
Meaning something should happen as a result of another action.
In that case, you probably need to introduce events and the observer pattern.
There are two types of events:
- domain events — are used to communicate changes and trigger reactions inside the bounded context
- integration events — are used to communicate changes and trigger reactions outside the bounded context
You can find an example of their implementation here, but in short, events are nothing more than just DTOs.
interface IDomainEvent { }
class AppointmentCreatedDomainEvent : IDomainEvent
{
public required Appointment Appointment { get; init; }
}They model something in the past; therefore, they should hold immutable data.
The next step would be to publish the event:
public class AppointmentService(
IAppointmentRepository _appointmentRepository,
IEventBus _eventBus)
{
public void Create(int appointmentId)
{
var appointment = new Appointment(appointmentId);
_appointmentRepository.Save(appointment);
// Publish the event
var appointmentCreatedEvent = new AppointmentCreatedEvent(appointmentId);
_eventBus.Publish(appointmentCreatedEvent);
}
}You also need a handler for this event that will perform any necessary side effects, such as sending notifications, updating read models, or affecting other aggregates.
class EmailService : IHandler<AppointmentCreatedDomainEvent>
{
public async Task Handle(AppointmentCreatedDomainEvent @event)
{
// Send email logic
}
}It is possible to design your system in a more declarative way, avoiding events at all; however, they are a vital part of Event Sourcing.
Domain Service
When applying a ubiquitous language, you can see that nouns are represented as objects, and verbs become the behaviors of those objects.
However, some behaviors don’t naturally belong to any specific entity or value object. In that case, you would define a domain service.
class PatientAdmissionNumberGenerator: IPatientAdmissionNumberGenerator
{
private readonly IDateTimeService _dateTimeService;
private readonly IHospitalRepository _hospitalRepository;
public PatientAdmissionNumberGenerator(
IDateTimeService dateTimeService,
IHospitalRepository hospitalRepository)
{
_dateTimeService = dateTimeService;
_hospitalRepository = hospitalRepository;
}
public string Generate()
{
var hospitalCode = _hospitalRepository.GetHospitalCode();
var utcNow = _dateTimeService.GetCurrentTime();
return $"{hospitalCode}-{utcNow}";
}
}It’s important not to overuse domain services, as doing so can lead to an anemic domain model. Business logic should primarily reside within entities and value objects.
Factory
A factory is a well-known pattern used to encapsulate the creation logic of complex aggregates.
class AppointmentFactory: IAppointmentFactory
{
private readonly IPatientRepository _patientRepository;
private readonly IDateTimeService _dateTimeService;
private readonly IDefaultAssignmentService _defaultAssignmentService;
public AppointmentFactory(
IPatientRepository patientRepository,
IDateTimeService dateTimeService,
IDefaultAssignmentService defaultAssignmentService)
{
_patientRepository = patientRepository;
_dateTimeService = dateTimeService;
_defaultAssignmentService = defaultAssignmentService;
}
public Appointment CreateNewUnconfirmedAppointment(int patientId)
{
var patient = _patientRepository.GetById(patientId);
if (patient == null)
{
throw new PatientNotFoundException("Patient does not exist.", nameof(patientId));
}
if (!patient.IsActive)
{
// domain specific exception
// because duh... ubiquitous language 🙄
throw new CannotCreateAppointmentForInactivePatientException();
}
var now = _dateTimeService.UtcNow;
var doctorId = _defaultAssignmentService.GetDefaultDoctorIdForNewAppointment();
return new Appointment(
patientId: patient.Id,
doctorId: doctorId,
status: AppointmentStatus.Unconfirmed,
createdAt: now
);
}
}It helps ensure that these objects are created in a consistent, valid, and expressive way, aligned with business rules.
For simpler aggregates, you can use static methods or constructors.
Repository
A repository is a domain pattern that provides a collection-like interface to access and manage aggregates from a persistence store (like a database).
Unlike traditional repositories, the one you have in DDD differs in the following key points:
- per aggregate and not per entity
// ❌ bad
interface INoteRepository { . . . }
interface IPresciptionRepository { . . . }
interface IAppointmentRepository { . . . }
// ✅ good
interface IAppointmentRepository { . . . }- operates in terms of domain concepts, not tables or queries
// ❌ table-centric repository
interface IAppointmentRepository
{
Appointment GetById(int id);
IEnumerable<Appointment> GetByColumn(string columnName, object columnValue);
}
// ✅ domain-centric repository
interface IAppointmentRepository
{
IEnumerable<Appointment> GetUpcomingForPatient(int patientId);
void Save(Appointment appointment);
}- manages the retrieval and saving of the entire aggregate root, all its dependent entities, and value objects
class AppointmentRepository: IAppointmentRepository
{
public Appointment GetFirstForPatient(int patientId)
{
// load the entire cluster of entities (aka aggregate)
return _dbContext
.Appointments
.Include(a => a.Notes)
.Include(a => a.Presciption)
.OrderByDescending(x => x.CreatedDate)
.First(a => a.PatientId == patientId);
}
. . .
}- follows the persistence ignorance principle
The persistence ignorance (PI) principle implies that domain models should not know or care about how data is stored, retrieved, or persisted. Domain logic should depend on abstraction to clearly separate it from infrastructure concerns.
Therefore, interfaces of the repositories are defined in the Domain Layer, while their implementations reside in the Infrastructure Layer, leveraging the Dependency Inversion Principle.
Specification
The final pattern we will talk about is the Specification. It encapsulates business rules or criteria that determine whether an object satisfies certain conditions.
The typical interface of them may have the following look:
interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
ISpecification<T> And(ISpecification<T> other);
ISpecification<T> Or(ISpecification<T> other);
ISpecification<T> Not();
}Each implementation is just a business rule:
class ActivePatientSpecification : ISpecification<Patient>
{
public bool IsSatisfiedBy(Patient patient) => patient.IsActive;
}
class SeniorPatientSpecification : ISpecification<Patient>
{
public bool IsSatisfiedBy(Patient patient) => patient.Age >= 65;
}Specifications can be freely composed with others to express more complex conditions without introducing conditional logic:
// Combine specifications:
var seniorSpec = new SeniorPatientSpecification();
var activeSpec = new ActivePatientSpecification();
var seniorActiveSpec = seniorSpec.And(activeSpec);It allows you to express complex queries, validations, or filters in a reusable, composable, and declarative way.
You can often see it combined with repositories, since this is basically a predicate.
interface IPatientRepository
{
IEnumerable<Patient> Get(ISpecification<Patient> spec);
}
. . .
// Combine specifications with operator overloads:
var spec = ActivePatientSpecification.New() && !SeniorPatientSpecification.New();
var patients = _patientRepository.Get(spec); By the way, the specification can also be used to validate already existing objects.
// Combine specifications with operator overloads:
var spec = ActivePatientSpecification.New() && !SeniorPatientSpecification.New();
var somePatient = new Patient();
bool result = seniorActiveSpec.IsSatisfiedBy(spec);The benefits of specifications are:
- business rules and conditions can be reused and combined easily
- keeps domain entities clean from complex conditional logic
- specifications read like natural language business rules
- specifications can be tested in isolation
- when integrated with repositories, specifications can translate to database queries
Summary
DDD helps you to understand and design your domain. But it does not mean you should try to analyze the entire system. It is too huge for that anyway.
Pick a smaller subset, define a bounded context, determine its ubiquitous language, and start modeling it.
Start with the main entities and value objects. Then you can combine them into aggregates. Add repositories and domain services. If needed, you can also implement factory or events.
The more you practice, the better you will understand it.
That being said, I want to ask you: “Do you even want that😅?”.
My main advice — don’t fall into the trap of CRUD DDD. If your domain is simple, then you don’t need DDD. DDD is a tool for dealing with domain complexity, and to use it effectively, that complexity must actually exist — and the entire project team must be capable of applying it properly.
Keep in mind that you can still leverage various DDD practices without fully committing to the entire DDD approach. There’s nothing stopping you from using patterns like ubiquitous language, layers, or repositories on their own. Vice versa, the absence of value objects, specifications or factories doesn’t mean you’re not doing DDD. The most important thing is to understand the essence of these patterns and apply them where they make sense.
💬 What is your favorite DDD pattern? While I cannot imagine DDD without aggregates, I think the most impactful is the ubiquitous language. What is your opinion? Let’s chat.
👏 Give this article a clap if you liked it
☕️ You can also support me using the link below
✅ Follow to see how to implement them in practice
🙃 And keep coding









