Accepted
In Domain-Driven Design, the domain layer is the heart of the application. However, there's often confusion about what belongs in the domain versus application layers:
Common Mistakes:
- Anemic Domain Models: Entities with only getters/setters, all logic in services
- Bloated Domain: Entities with infrastructure concerns (repositories, external APIs)
- Unclear Boundaries: Business rules scattered between domain and application
- Testing Difficulties: Cannot test business rules independently
Requirements:
- Domain must enforce invariants (rules that must always be true)
- Domain must encapsulate business rules (domain-specific constraints)
- Domain must remain framework-agnostic and persistence-ignorant
- Domain must be testable without infrastructure
- Domain must communicate intent through rich domain model
Domain logic (invariants and business rules) belongs in the Domain layer within Aggregates, Value Objects, and Business Rules.
Invariants (must always be true):
- Value Object validation (e.g., email format, non-negative prices)
- Aggregate consistency rules (e.g., order must have at least one item)
- State transition rules (e.g., can't retire an already retired customer)
Business Rules:
- Domain-specific constraints (e.g., email must be unique)
- Calculation logic (e.g., order total calculation)
- Domain policies (e.g., discount eligibility)
Domain Behavior:
- Aggregate factory methods (e.g.,
Customer.Create()) - State change methods (e.g.,
customer.ChangeEmail()) - Domain event registration
Domain Events:
- Significant business occurrences (e.g.,
CustomerCreatedDomainEvent) - Event registration when state changes
- Single Source of Truth: Business rules live in one place (domain layer)
- Encapsulation: Aggregates protect invariants, enforce rules
- Testability: Domain logic testable without infrastructure
- Ubiquitous Language: Domain model reflects business terminology
- Reusability: Domain logic reused across different use cases
- Framework Independence: Domain doesn't depend on ASP.NET, EF Core, or any framework
- Clear Intent: Domain methods express business operations, not technical CRUD
- Business rules enforced consistently (cannot be bypassed)
- Domain logic testable independently of infrastructure
- Rich domain model that expresses business concepts
- Aggregates maintain invariants automatically
- Domain can be reused in different application contexts
- Clear separation between business logic and infrastructure
- Domain code is easy to understand (reflects business language)
- Requires discipline to keep domain pure (no infrastructure dependencies)
- Learning curve for developers unfamiliar with DDD
- More complex than anemic domain models (getters/setters only)
- May feel over-engineered for simple CRUD scenarios
- Domain methods return
Result<T>for operations that can fail - Factory methods preferred over public constructors
- Value Objects are immutable
- Private setters enforce encapsulation
-
Alternative 1: Anemic Domain Model (All Logic in Services)
- Rejected because it leads to procedural programming, not object-oriented
- Business rules scattered across multiple services
- Entities become simple data containers with no behavior
-
Alternative 2: Active Record Pattern
- Rejected because it couples domain to persistence infrastructure
- Entities would have
Save(),Delete()methods (infrastructure concern) - Violates Clean Architecture dependency rules
-
Alternative 3: Transaction Script Pattern
- Rejected because it doesn't scale well with complexity
- No reusable domain model
- Business logic scattered across procedural scripts
- ADR-0001: Domain layer has no outward dependencies
- ADR-0002: Domain methods return Results
- ADR-0011: Complementary decision defining application responsibilities
- bITdevKit Domain Documentation
- bITdevKit Domain Events
- README - Domain Layer
- CoreModule README - Key Building Blocks
- Eric Evans - Domain-Driven Design
- Martin Fowler - Anemic Domain Model
[TypedEntityId<Guid>]
public class Customer : AuditableAggregateRoot<CustomerId>, IConcurrency
{
private readonly List<Address> addresses = [];
private Customer() { } // EF Core constructor
// Private constructor enforces factory method usage
private Customer(string firstName, string lastName, EmailAddress email, CustomerNumber number)
{
this.FirstName = firstName;
this.LastName = lastName;
this.Email = email;
this.Number = number;
}
public string FirstName { get; private set; }
public string LastName { get; private set; }
public EmailAddress Email { get; private set; }
public CustomerNumber Number { get; private set; }
public CustomerStatus Status { get; private set; } = CustomerStatus.Lead;
public IReadOnlyCollection<Address> Addresses => this.addresses.AsReadOnly();
// DOMAIN LOGIC: Factory method with invariants
public static Result<Customer> Create(
string firstName,
string lastName,
string email,
CustomerNumber number)
{
var emailAddressResult = EmailAddress.Create(email);
if (emailAddressResult.IsFailure)
return emailAddressResult.Unwrap();
return Result<Customer>.Success()
// Invariant: Names must not be empty
.Ensure(_ => !string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(lastName),
new ValidationError("Invalid name: both first and last name must be provided"))
// Invariant: Last name cannot be "notallowed"
.Ensure(_ => lastName != "notallowed",
new ValidationError("Invalid last name: 'notallowed' is not permitted"))
.Ensure(_ => email != null, new ValidationError("Email cannot be null"))
.Ensure(_ => number != null, new ValidationError("Number cannot be null"))
.Bind(_ => new Customer(firstName, lastName, emailAddressResult.Value, number))
// Register domain event
.Tap(e => e.DomainEvents.Register(new CustomerCreatedDomainEvent(e)));
}
// DOMAIN LOGIC: State change method with invariants
public Result<Customer> ChangeName(string firstName, string lastName)
{
return this.Change()
.Ensure(_ => !string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(lastName),
"Invalid name: both first and last name must be provided")
.Ensure(_ => lastName != "notallowed",
"Invalid last name: 'notallowed' is not permitted")
.Set(e => e.FirstName, firstName)
.Set(e => e.LastName, lastName)
.Register(e => new CustomerUpdatedDomainEvent(e))
.Apply();
}
// DOMAIN LOGIC: Email change with validation
public Result<Customer> ChangeEmail(string email)
{
return this.Change()
.Set(e => e.Email, EmailAddress.Create(email))
.Register(e => new CustomerUpdatedDomainEvent(e))
.Apply();
}
}Value Objects enforce invariants through validation:
public class EmailAddress : ValueObject
{
private EmailAddress(string value)
{
this.Value = value;
}
public string Value { get; private set; }
// DOMAIN LOGIC: Validation and creation
public static Result<EmailAddress> Create(string value)
{
value = value?.Trim()?.ToLowerInvariant();
// Invariant: Email cannot be empty
if (string.IsNullOrWhiteSpace(value))
return Result<EmailAddress>.Failure()
.WithError(new ValidationError("Email cannot be empty"));
// Invariant: Email must contain @
if (!value.Contains("@"))
return Result<EmailAddress>.Failure()
.WithError(new ValidationError("Invalid email format"));
return new EmailAddress(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return this.Value;
}
}Complex business rules can be extracted to rule classes:
public class EmailShouldBeUniqueRule(string email, IGenericRepository<Customer> repository)
: IBusinessRule
{
public string Message => "Email address already exists";
public async Task<bool> IsSatisfiedAsync(CancellationToken cancellationToken = default)
{
var specification = new Specification<Customer>(e =>
e.Email.Value.ToLowerInvariant() == email.ToLowerInvariant());
var result = await repository.FindAllResultAsync(
specification,
cancellationToken: cancellationToken);
return result.IsSuccess && !result.Value.Any();
}
}Enumerations provide type-safe bounded sets:
public class CustomerStatus : Enumeration
{
public static readonly CustomerStatus Lead = new(1, nameof(Lead), "Lead customer");
public static readonly CustomerStatus Active = new(2, nameof(Active), "Active customer");
public static readonly CustomerStatus Retired = new(3, nameof(Retired), "Retired customer");
private CustomerStatus(int id, string name, string description = null)
: base(id, name)
{
this.Description = description;
}
public string Description { get; private set; }
}| Concern | Layer | Example |
|---|---|---|
| Email format validation | Domain | EmailAddress.Create(email) |
| Email uniqueness check | Domain (rule definition) | EmailShouldBeUniqueRule |
| Email uniqueness execution | Application | Rule.Add(new EmailShouldBeUniqueRule(...)).CheckAsync() |
| Customer name validation | Domain | Customer.Create() invariants |
| Customer number generation | Application | numberGenerator.NextAsync() |
| Customer persistence | Application | repository.InsertResultAsync() |
| Aggregate creation | Domain | Customer.Create() |
| State transitions | Domain | customer.ChangeName() |
| Domain event registration | Domain | DomainEvents.Register(...) |
| Transaction coordination | Application | Handler orchestration |
Unit Tests (no infrastructure needed):
[Fact]
public void Create_WithValidData_ReturnsSuccess()
{
var number = CustomerNumber.Create(2025, 100000).Value;
var result = Customer.Create("John", "Doe", "john@example.com", number);
result.ShouldBeSuccess();
result.Value.FirstName.ShouldBe("John");
result.Value.Email.Value.ShouldBe("john@example.com");
}
[Fact]
public void Create_WithInvalidEmail_ReturnsFailure()
{
var number = CustomerNumber.Create(2025, 100000).Value;
var result = Customer.Create("John", "Doe", "invalid-email", number);
result.ShouldBeFailure();
result.Errors.Should().Contain(e => e.Message.Contains("email"));
}
[Fact]
public void ChangeName_WithNotAllowedLastName_ReturnsFailure()
{
var customer = CreateValidCustomer();
var result = customer.ChangeName("John", "notallowed");
result.ShouldBeFailure();
result.Errors.Should().Contain(e => e.Message.Contains("notallowed"));
}When designing domain logic:
- V Use static factory methods (e.g.,
Create()) instead of public constructors - V Return
Result<T>from methods that can fail - V Use private setters to enforce encapsulation
- V Register domain events for significant state changes
- V Validate invariants in factory methods and state change methods
- V Use Value Objects for concepts with validation rules
- V Use Enumerations for bounded sets
- V Extract complex rules to
IBusinessRuleimplementations - V Keep domain pure (no infrastructure dependencies)
- V Express business concepts through ubiquitous language
X Infrastructure Concerns:
- Database queries (
DbContext,IQueryable) - HTTP calls to external services
- File system access
- Email sending
X Application Concerns:
- Mapping to DTOs
- Transaction management
- Command/Query orchestration
- Caching
X Presentation Concerns:
- HTTP status codes
- JSON serialization attributes
- View models
- Aggregate:
src/Modules/CoreModule/CoreModule.Domain/Model/CustomerAggregate/Customer.cs - Value Object:
src/Modules/CoreModule/CoreModule.Domain/Model/EmailAddress.cs - Enumeration:
src/Modules/CoreModule/CoreModule.Domain/Model/CustomerAggregate/CustomerStatus.cs - Business Rule:
src/Modules/CoreModule/CoreModule.Domain/Rules/EmailShouldBeUniqueRule.cs - Domain Event:
src/Modules/CoreModule/CoreModule.Domain/Events/CustomerCreatedDomainEvent.cs - Tests:
tests/Modules/CoreModule/CoreModule.UnitTests/Domain/