Disclosure: NDepend provided a complimentary 1-year license for me to test the software featured in this article.
When you first start implementing Domain-Driven Design (DDD) in your project, you invest considerable effort in clearly defining Bounded Contexts, identifying Aggregates, and ensuring your abstraction layers are properly defined.
Fast-forward six months, however, and you'll likely find your initial structure has degraded. A domain service now calls an ORM directly. Value objects have become mutable. While code reviews catch obvious violations, subtle architectural drift slips through the cracks when reviewers focus on business logic rather than structural rules. These small violations compound, eventually turning your DDD project into a Big Ball of Mud unless you actively prevent it.
The solution is to implement fitness functions, which are automated checks that detect when new code deviates from your DDD principles.
One tool that enables this is NDepend, a powerful .NET static analyzer that integrates with your IDE and code pipelines. It offers CQLinq (Code Query LINQ), which lets you write your own static analysis code rules using LINQ. In this article, you'll learn how to build eight CQLinq rules that enforce DDD patterns for Entities, Value Objects, and Aggregates.
A Brief Overview of NDepend and CQLinq
While NDepend is known as a metrics and quality analyzer, its real power for DDD teams lies in CQLinq, which lets you write custom static analysis rules using LINQ syntax. Think of it as querying your codebase like a database. You can ask "Which entity classes have public setters?" or "Which domain services reference infrastructure assemblies?" and get violations back instantly.
This matters because DDD patterns are harder to enforce than style rules. A linter can check brace placement, but only a custom static analyzer rule can verify that an Aggregate Root holds no direct references to other Aggregates, or that all Value Objects are immutable. These custom rules shift feedback from slow, subjective code reviews to fast, objective build failures.
In the sections that follow, you'll learn how to build eight CQLinq rules, each targeting a specific DDD pattern you can implement in your own codebase.
Protecting Architectural Boundaries
Before you can apply tactical DDD patterns, you must isolate the domain. Whether you use Clean Architecture, Hexagonal Architecture, or a traditional layered approach, a core principle of DDD is that your domain logic must remain pure and free of external concerns, such as infrastructure implementations, UI frameworks, and third-party libraries.
Rule 1: No Third-Party Dependencies in the Domain Layer
The only dependency you might consider having is a seedwork project containing a collection of base interfaces and classes that help you scaffold your domain. Some teams also allow specific, lightweight libraries for foundational patterns, like MediatR for dispatching Domain Events.
For example, consider the following entity in an Insurance.Domain project. It violates the dependency rule by referencing an external library to generate a policy document from directly within an entity:
using Insurance.SharedKernel.Base;
using Insurance.Domain.Common;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Insurance.Domain.Aggregates.Policies;
public class Policy : BaseEntity, IAggregateRoot
{
private readonly List<InsuredItem> _insuredItems = [];
public string PolicyNumber { get; private set; }
public Guid CustomerId { get; private set; }
public PolicyPeriod PolicyPeriod { get; private set; }
public Coverage Coverage { get; private set; }
public IReadOnlyCollection<InsuredItem> InsuredItems => _insuredItems.AsReadOnly();
// Constructor and other methods...
public byte[] GeneratePolicyDocument()
{
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(2, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(20));
page.Header()
.Text("Policy Document")
.SemiBold();
// Continue building document...
});
}).GeneratePdf();
}
}
This violates the Dependency Inversion Principle by coupling domain logic to a PDF-generation library. The typical solution is to define an IPolicyDocumentGenerator interface in the application layer and implement it in the infrastructure layer, keeping the domain pure.
You can detect such violations using the following CQLinq rule. Notice how you can easily add exceptions like MediatR to the allowedPrefixes array:
// <Name>Domain Project Third-Party Assembly References</Name>
// <Description>
// The Domain project should not depend on any third-party assemblies.
// </Description>
warnif count > 0
// 1. Define the allowed assemblies for the Domain
let allowedPrefixes = new[] { "Insurance.SharedKernel", "Insurance.Domain" }
let systemPrefixes = new[] { "System", "Microsoft", "mscorlib", "netstandard" }
from t in Application.Types
where t.ParentAssembly.Name == "Insurance.Domain" && !t.IsGeneratedByCompiler
// 2. Identify and group illegal types by their parent assembly
let illegalTypeUsages = t.TypesUsed
.Where(tu => !allowedPrefixes.Any(p => tu.ParentAssembly.Name.StartsWith(p)) &&
!systemPrefixes.Any(p => tu.ParentAssembly.Name.StartsWith(p)))
.GroupBy(tu => tu.ParentAssembly)
where illegalTypeUsages.Any()
// 3. Flatten the results so you see one row per (Domain Type + Illegal Assembly)
from usageGroup in illegalTypeUsages
let assembly = usageGroup.Key
let typesUsedInThisAssembly = usageGroup.Distinct()
let debt = (10 + (typesUsedInThisAssembly.Count() * 2)).ToMinutes().ToDebt()
select new {
DomainType = t,
IllegalAssembly = assembly,
UsedTypes = typesUsedInThisAssembly,
Debt = debt,
Severity = Severity.High
}
Below is a screenshot showing the violation in NDepend:
Enforcing Entity Encapsulation
Entities are objects defined by a unique identity and a lifecycle. They store state as properties that can only be modified through methods defined on the entity itself. This ensures the entity is always responsible for maintaining its own invariants and preventing invalid states.
The following sections contain CQLinq rules that help enforce entity encapsulation.
Rule 2: Require Private Setters
Entity properties must only be modifiable through domain methods, not by external code. Public setters bypass validation logic, making it trivial to leave an entity in an invalid state.
For example, consider the following entity class, which breaks this rule:
using Insurance.SharedKernel.Base;
namespace Insurance.Domain.Aggregates.Customers;
public class Customer : BaseEntity, IAggregateRoot
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
public Address Address { get; set; }
// Constructors and methods...
}
This class exposes public setters for all properties, allowing external code to bypass domain validation entirely.
Here's a CQLinq rule to catch such violations automatically:
// <Name>Entity Properties Must Have Private Setters</Name>
// <Description>
// Entities should protect their state by using private setters,
// forcing state changes through domain methods.
// </Description>
warnif count > 0
// 1. Find all Entity types in the Domain project
from t in Application.Types
where t.ParentAssembly.Name == "Insurance.Domain" &&
t.DeriveFrom("Insurance.SharedKernel.Base.BaseEntity")
// 2. Flag properties with a non-private, non-protected setter
from p in t.Properties
where p.SetMethod != null &&
!p.SetMethod.IsPrivate &&
!p.SetMethod.IsProtected
select new {
t,
Property = p,
Issue = "Property has public/internal setter",
Debt = 10.ToMinutes().ToDebt(),
Severity = Severity.High
}
Below is a screenshot showing the violations from the Customer entity:
Clicking any result takes you directly to the violation in the code.
Rule 3: Ban Public Parameterless Constructors
When implementing DDD entities in C#, you often need a private parameterless constructor so that libraries like EF Core can rehydrate instances via reflection. However, these constructors should usually never be public since entities typically require initial data via constructor parameters to be valid when used from application code.
Consider this Customer class with a public parameterless constructor:
public class Customer : BaseEntity, IAggregateRoot
{
public string FirstName { get; private set; }
public string LastName { get; private set; }
public string Email { get; private set; }
public string PhoneNumber { get; private set; }
public Address Address { get; private set; }
public Customer()
{
}
// Other methods...
}
The public parameter makes it possible for application code to create a customer with no required information like name and contact details:
var customer = new Customer();
// No name or other details.
Here's a CQLinq rule that detects public parameterless constructors on entities:
// <Name>Entities must not have Public Parameterless Constructors</Name>
// <Description>
// Entities should only expose a non-public constructor for libraries
// like EF Core.
// </Description>
warnif count > 0
// 1. Find all non-abstract Entity types
let entities = Application.Types
.Where(t => t.DeriveFrom("Insurance.SharedKernel.Base.BaseEntity") && !t.IsAbstract)
// 2. Flag constructors that are public and take no parameters
from t in entities
from c in t.Constructors
where c.IsPublic && c.NbParameters == 0
select new {
Entity = t,
Constructor = c,
Issue = "Entity has a public parameterless constructor. This allows creating entities in an invalid/empty state.",
Debt = 2.ToMinutes().ToDebt(),
Severity = Severity.Medium
}
Below is a screenshot of the finding in NDepend:
Rule 4: Enforce Immutable Collections
Exposing mutable collection properties in your entities lets consumers call .Add() or .Clear() directly, bypassing the entity's methods. Look at this Policy class:
using Insurance.SharedKernel.Base;
using Insurance.Domain.Common;
namespace Insurance.Domain.Aggregates.Policies;
public class Policy : BaseEntity, IAggregateRoot
{
public string PolicyNumber { get; private set; }
public Guid CustomerId { get; private set; }
public PolicyPeriod PolicyPeriod { get; private set; }
public Coverage Coverage { get; private set; }
public List<InsuredItem> InsuredItems { get; private set; }
// Constructors and other methods...
}
Even though InsuredItems only exposes a public getter, consumers can still manipulate it by calling List<T> methods directly:
// Add an insured item without going through the policy
policy.InsuredItems.Add(anotherInsuredItem);
// Clear all insured items on a policy in the list directly
policy.InsuredItems.Clear();
A better approach is to store the mutable collection in a private field on the entity and expose it only through a read-only collection type, such as IReadOnlyList<T>.
This CQLinq rule flags mutable collection and dictionary properties on entities:
// <Name>Entity properties must use Immutable Collections</Name>
// <Description>
// Entities should always expose collections or dictionaries as immutable types
// so that consumers cannot modify the collection using its API directly.
// </Description>
warnif count > 0
// Common mutable collection types to ban
let mutableCollections = new [] {
"System.Collections.Generic.List",
"System.Collections.Generic.Dictionary",
"System.Collections.Generic.HashSet",
"System.Collections.ObjectModel.Collection"
}
// Fetch all Entity objects
from t in Application.Types
where t.DeriveFrom("Insurance.SharedKernel.Base.BaseEntity")
&& !t.IsAbstract
from p in t.Properties
// Check 1: Is it a banned generic type? (e.g., List<string>)
let typeName = p.PropertyType.Name
let isBannedType = mutableCollections.Any(banned =>
p.PropertyType.FullName != null && p.PropertyType.FullName.StartsWith(banned))
// Check 2: Is it an Array? (e.g., string[])
let isArray = typeName.EndsWith("[]")
where isBannedType || isArray
select new {
Entity = t,
Property = p,
Type = p.PropertyType.Name,
Issue = "Property exposes a mutable collection. Use 'IReadOnlyList<T>', 'IReadOnlyDictionary<TKey, TValue>', 'ImmutableList<T>', or 'IEnumerable<T>'.",
Debt = 5.ToMinutes().ToDebt(),
Severity = Severity.High
}
Notice how the rule highlights the InsuredItems violation on Policy:
Enforce Value Object Integrity
Value objects represent objects without identity in DDD. Rather than being defined by an ID, they are defined by the values they hold. This creates two core requirements: immutability (properties cannot change after construction) and value-based equality (two value objects with identical properties must be considered equal, regardless of object identity).
In C#, you can implement value objects as records (which provide immutability and structural equality automatically) or as classes with proper encapsulation and equality overrides. Either way, the pattern requirements must be enforced consistently throughout your domain.
Rule 5: Prevent Mutable Fields and Properties on Value Objects
Value objects must never expose mutable state, whether through fields or properties. Immutability must be enforced at all levels. Any public or unencapsulated field can be modified directly, and any property with a setter can be changed after construction.
Breaking the immutability of a value object in DDD can lead to an unpredictable domain. Since value objects are often shared and passed around as parameters, making them mutable means that changing the state of a value object in one part of your application might inadvertently change the state of other entities that also reference that particular value object. This can lead to side effects that are difficult to trace.
Consider this Money value object, which violates immutability in multiple ways:
using Insurance.SharedKernel.Base;
namespace Insurance.Domain.Common;
public class Money : IValueObject
{
public string Currency; // Public field - anyone can change this!
public decimal Amount { get; set; } // Public setter - mutable!
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative.", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required.", nameof(currency));
Amount = amount;
Currency = currency;
}
// Equals() and GetHashCode()...
}
Now, external code can mutate this value object despite your constructor validations:
var salary = new Money(50000, "USD");
salary.Currency = "EUR"; // Changed the currency without validation!
salary.Amount = -75000; // Changed the amount directly!
This CQLinq rule flags mutable (non-readonly) fields and properties with setters on value objects:
// <Name>Value Objects must not expose mutable fields or properties</Name>
// <Description>
// Value objects should only expose immutable state through read-only properties.
// Any public/internal fields, mutable (non-readonly) fields, or properties with setters break immutability.
// </Description>
warnif count > 0
let valueObjects = Application.Types.Where(t =>
t.Implement("Insurance.SharedKernel.Base.IValueObject")
&& !t.IsAbstract)
from t in valueObjects
// Flatten fields and properties into a single stream of violations
from m in t.Members.Where(m =>
(m is IField && !m.IsGeneratedByCompiler && (m.IsPublic || m.IsInternal || !((IField)m).IsInitOnly)) ||
(m is IProperty && ((IProperty)m).SetMethod != null && !((IProperty)m).SetMethod.IsPrivate)
)
select new {
ValueObject = t,
Member = m,
Issue = m is IField ?
(m.IsPublic ? "Public field" : m.IsInternal ? "Internal field" : "Mutable field") :
"Property has a setter. Value objects must be immutable.",
Recommendation = m is IField ?
"Make the field readonly and expose via a read-only property." :
"Remove the setter or change it to 'private set;' or 'init;'.",
Severity = Severity.High,
Debt = 5.ToMinutes().ToDebt()
}
Below is a screenshot showing the violation:
Rule 6: Enforce Structural Equality on Value Objects
Because value objects are defined by their values rather than identity, two value objects with identical properties must be considered equal. With C# records, this equality is automatic. When implementing value objects as classes, however, you must override Equals() and GetHashCode() to enable value-based comparison.
Look at this PolicyPeriod class, which lacks proper value equality:
using Insurance.SharedKernel.Base;
namespace Insurance.Domain.Aggregates.Policies;
public class PolicyPeriod : IValueObject
{
public DateTime StartDate { get; private set; }
public DateTime EndDate { get; private set; }
public bool IsActive => DateTime.UtcNow >= StartDate && DateTime.UtcNow <= EndDate;
public int DurationInDays => (EndDate - StartDate).Days;
public PolicyPeriod(DateTime startDate, DateTime endDate)
{
if (endDate <= startDate)
throw new ArgumentException("End date must be after start date.");
StartDate = startDate;
EndDate = endDate;
}
// Missing Equals() and GetHashCode() implementations!
}
Without these overrides, the class uses reference equality. Here's what happens:
var period1 = new PolicyPeriod(new DateTime(2026, 1, 1), new DateTime(2027, 1, 1));
var period2 = new PolicyPeriod(new DateTime(2026, 1, 1), new DateTime(2027, 1, 1));
// Identical values, but comparisons fail
if (period1.Equals(period2)) // false: different memory objects
{
Console.WriteLine("Periods are equal");
}
// Collections break too
var periods = new HashSet<PolicyPeriod> { period1 };
periods.Contains(period2); // false: values are identical but objects differ
This CQLinq rule detects value objects lacking proper equality:
// <Name>Value Objects must implement structural equality</Name>
// <Description>
// Non-record value objects must override Equals() and GetHashCode()
// to ensure value-based equality comparisons work correctly.
// </Description>
warnif count > 0
// Find all value objects that are implemented as classes
let valueObjects = Application.Types.Where(t =>
t.Implement("Insurance.SharedKernel.Base.IValueObject")
&& !t.IsAbstract
&& !t.IsRecord)
// Determine if any of the value objects are missing an overridden Equals or
// GetHashCode method.
from t in valueObjects
let hasEqualsOverride = t.Methods.Any(m => m.Name == "Equals")
let hasHashCodeOverride = t.Methods.Any(m => m.Name == "GetHashCode")
where !hasEqualsOverride || !hasHashCodeOverride
select new {
ValueObject = t,
MissingEquals = !hasEqualsOverride,
MissingHashCode = !hasHashCodeOverride,
Issue = "Value Object does not implement structural equality. Override both Equals() and GetHashCode().",
Recommendation = "Either make this a 'record' or implement Equals() and GetHashCode() to compare by value.",
Severity = Severity.High,
Debt = 10.ToMinutes().ToDebt()
}
This rule targets only non-record value objects, since records provide structural equality automatically. For class-based value objects, missing implementations are flagged as high-severity violations.
Here's what this violation looks like in NDepend:
Enforcing Aggregate Root Consistency
Aggregate roots must maintain clear boundaries and control read/write access to their internal state. This ensures the aggregate root doesn't cause side effects on other entities, and that external code cannot directly modify its state.
Rule 7: Aggregate Roots Should Have Behavioural Methods
Anemic entities are classes with only data properties and no methods. They signal a design where external services validate and modify entity data. In DDD, entities are responsible for validating and updating their own state, meaning aggregate roots should contain methods to modify state rather than just expose properties.
When a class implements IAggregateRoot, it declares itself as a transactional boundary for state mutations. An IAggregateRoot with no behavioral methods is almost certainly an Anemic Domain Model. If the class exists only to represent data for a CQRS read query, it's a Read Model (or DTO) and should not implement IAggregateRoot at all.
This Claim class has a mutable state but defines no methods to manage it:
using Insurance.SharedKernel.Base;
using Insurance.Domain.Common;
namespace Insurance.Domain.Aggregates.Claims;
public class Claim : BaseEntity, IAggregateRoot
{
public string ClaimNumber { get; private set; }
public Guid PolicyId { get; private set; }
public ClaimStatus Status { get; private set; }
public DateTime FiledDate { get; private set; }
private readonly List<ClaimItem> _claimItems = [];
private readonly List<AdjusterNote> _adjusterNotes = [];
public IReadOnlyCollection<ClaimItem> ClaimItems => _claimItems.AsReadOnly();
public IReadOnlyCollection<AdjusterNote> AdjusterNotes => _adjusterNotes.AsReadOnly();
public Claim(string claimNumber, Guid policyId)
{
if (string.IsNullOrWhiteSpace(claimNumber))
throw new ArgumentException("Claim number is required.", nameof(claimNumber));
if (policyId == Guid.Empty)
throw new ArgumentException("Policy ID is required.", nameof(policyId));
ClaimNumber = claimNumber;
PolicyId = policyId;
Status = ClaimStatus.Pending;
FiledDate = DateTime.UtcNow;
}
}
This CQLinq rule flags such violations:
// <Name>Aggregate Roots should have Public Behavioral Methods</Name>
warnif count > 0
// 1. Identify Aggregate Roots (Adjust the string to match your base interface/class)
let aggregateRoots = Application.Types
.Where(t => t.Implement("Insurance.SharedKernel.Base.IAggregateRoot")
&& !t.IsAbstract
&& !t.IsInterface)
from t in aggregateRoots
// 2. Filter for "Real" Public Methods (Behavior)
// We exclude constructors, property accessors, and standard object overrides.
let publicBehaviorMethods = t.Methods.Where(m =>
m.IsPublic &&
!m.IsConstructor &&
!m.IsPropertyGetter &&
!m.IsPropertySetter &&
!m.IsOperator &&
!m.IsStatic &&
// Exclude standard object overrides as they don't count as "Domain Logic"
m.Name != "ToString" &&
m.Name != "GetHashCode" &&
m.Name != "Equals" &&
m.Name != "GetType"
)
// 3. The Violation Condition:
// If the count is 0, the Aggregate Root is just a data structure.
where publicBehaviorMethods.Count() == 0
select new {
AggregateRoot = t,
Issue = "Aggregate Root has no public behavioral methods. It appears to be an Anemic Domain model.",
Recommendation = "Encapsulate logic by adding public methods (e.g., 'Activate()', 'Cancel()') instead of relying on services to manipulate properties.",
Severity = Severity.Medium,
Debt = 30.ToMinutes().ToDebt()
}
Here's a screenshot of the query results showing the Claim:
Rule 8: Aggregate Roots Should Reference Other Aggregates by ID Only
Each aggregate root is a consistency boundary, meaning it owns and controls everything inside it. When one aggregate root holds a direct object reference to another, you risk loading and modifying both within a single transaction, blurring boundaries and creating unintended coupling. Referencing by ID keeps boundaries clean: to work with another aggregate, load it separately through its own repository.
If you're using an ORM like EF Core, you might be tempted to ignore this rule. EF Core navigation properties make it easy to link aggregates together and manage foreign keys behind the scenes. However, this convenience is exactly what leads to issues such as accidental lazy-loading, large memory footprints from eager loading, and the modification of multiple aggregates in a single database transaction. By enforcing ID-only references, you force developers to load other aggregates separately, preserving performance and transactional boundaries.
This Claim class violates the rule by holding a direct reference to Policy instead of storing its ID:
using Insurance.SharedKernel.Base;
using Insurance.Domain.Common;
using Insurance.Domain.Aggregates.Policies;
namespace Insurance.Domain.Aggregates.Claims;
public class Claim : BaseEntity, IAggregateRoot
{
public string ClaimNumber { get; private set; }
public Policy Policy { get; private set; }
public ClaimStatus Status { get; private set; }
public DateTime FiledDate { get; private set; }
private readonly List<ClaimItem> _claimItems = [];
private readonly List<AdjusterNote> _adjusterNotes = [];
public IReadOnlyCollection<ClaimItem> ClaimItems => _claimItems.AsReadOnly();
public IReadOnlyCollection<AdjusterNote> AdjusterNotes => _adjusterNotes.AsReadOnly();
// Constructors and methods...
}
This CQLinq rule detects such violations by scanning aggregate root properties for direct references to other aggregate roots:
// <Name>Aggregate Roots should reference other Aggregates by ID only</Name>
warnif count > 0
// 1. Identify all Aggregate Roots
let aggregateRoots = Application.Types
.Where(t => t.Implement("Insurance.SharedKernel.Base.IAggregateRoot")
&& !t.IsAbstract)
from t in aggregateRoots
from p in t.Properties
// 2. Check if the Property Type is an Aggregate Root
// This includes the type itself
let propertyType = p.PropertyType
where aggregateRoots.Contains(propertyType)
select new {
AggregateRoot = t,
Property = p,
ReferencedType = propertyType,
Issue = propertyType == t
? "Self-Reference: Aggregate Root holds a reference to itself. Use an ID (e.g., ParentId) to avoid deep recursion."
: "Cross-Aggregate Reference: Aggregate Root holds a hard reference to another Aggregate. Use an ID to keep boundaries clean.",
Recommendation = "Replace property '" + p.Name + "' with '" + p.Name + "Id'.",
Severity = Severity.High,
Debt = 1.ToHours().ToDebt()
}
The screenshot below shows how the Policy property violation is identified:
Conclusion
Implementing DDD is an investment in long-term maintainability. However, ensuring the design is preserved over time is a challenge. This article explored eight powerful CQLinq rules you can use to safeguard your domain.
These rules act as fitness functions, which monitor your project's overall structure with every change. By integrating these NDepend rules into your CI/CD pipeline, you can quickly fail builds that violate DDD principles.
Start by auditing your current codebase against one or two of these rules today. As you resolve the most painful violations, you’ll see your "Big Ball of Mud" begin to transform back into a clean, expressive Domain Model.








Top comments (0)