Wolfix.Server applies Domain-Driven Design (DDD) principles to create a rich domain model that encapsulates business logic and enforces business rules.
Core DDD Concepts
Domain-Driven Design provides tactical patterns for organizing business logic:
Entities Objects with identity that persist over time
Value Objects Immutable objects defined by their attributes
Aggregates Clusters of entities with consistency boundaries
Domain Services Operations that don’t belong to a single entity
Repositories Abstractions for data persistence
Domain Events Events representing business facts
Entities
Entities have unique identity and mutable state . Two entities with the same attributes but different IDs are different.
Base Entity
All entities inherit from BaseEntity:
Shared.Domain/Entities/BaseEntity.cs
namespace Shared . Domain . Entities ;
public abstract class BaseEntity
{
public Guid Id { get ; private set ; }
}
The Id has a private setter to prevent external modification, ensuring identity immutability.
Entity Example: Review
Catalog.Domain/ProductAggregate/Entities/Review.cs
public sealed class Review : BaseEntity
{
public string Title { get ; private set ; }
public string Text { get ; private set ; }
public uint Rating { get ; private set ; }
public Guid CustomerId { get ; private set ; }
public Product Product { get ; private set ; }
// Private constructor - use factory method
private Review () { }
// Factory method with validation
public static Result < Review > Create (
string title ,
string text ,
uint rating ,
Product product ,
Guid customerId )
{
if ( string . IsNullOrWhiteSpace ( title ))
return Result < Review >. Failure ( "Title is required" );
if ( string . IsNullOrWhiteSpace ( text ))
return Result < Review >. Failure ( "Text is required" );
if ( rating < 1 || rating > 5 )
return Result < Review >. Failure ( "Rating must be between 1 and 5" );
if ( customerId == Guid . Empty )
return Result < Review >. Failure ( "Customer ID is required" );
var review = new Review
{
Title = title ,
Text = text ,
Rating = rating ,
Product = product ,
CustomerId = customerId
};
return Result < Review >. Success ( review , HttpStatusCode . Created );
}
// Behavior methods
public VoidResult SetTitle ( string title )
{
if ( string . IsNullOrWhiteSpace ( title ))
return VoidResult . Failure ( "Title is required" );
Title = title ;
return VoidResult . Success ();
}
public VoidResult SetRating ( uint rating )
{
if ( rating < 1 || rating > 5 )
return VoidResult . Failure ( "Rating must be between 1 and 5" );
Rating = rating ;
return VoidResult . Success ();
}
}
Entities protect their invariants through:
Private setters
Factory methods with validation
Behavior methods that enforce rules
Value Objects
Value Objects are immutable and defined by their attributes , not identity. Two value objects with identical attributes are considered equal.
Value Object Example: Email
Shared.Domain/ValueObjects/Email.cs
public sealed record Email
{
public string Value { get ; init ; }
private Email ( string value )
{
Value = value ;
}
public static Result < Email > Create ( string email )
{
if ( string . IsNullOrWhiteSpace ( email ))
return Result < Email >. Failure ( "Email is required" );
if ( ! IsValidEmail ( email ))
return Result < Email >. Failure ( "Invalid email format" );
return Result < Email >. Success ( new Email ( email . ToLowerInvariant ()));
}
private static bool IsValidEmail ( string email )
{
try
{
var addr = new System . Net . Mail . MailAddress ( email );
return addr . Address == email ;
}
catch
{
return false ;
}
}
public override string ToString () => Value ;
}
Value Object Example: Address
Shared.Domain/ValueObjects/Address.cs
public sealed record Address
{
public string Street { get ; init ; }
public string City { get ; init ; }
public string State { get ; init ; }
public string ZipCode { get ; init ; }
public string Country { get ; init ; }
private Address () { }
public static Result < Address > Create (
string street ,
string city ,
string state ,
string zipCode ,
string country )
{
if ( string . IsNullOrWhiteSpace ( street ))
return Result < Address >. Failure ( "Street is required" );
if ( string . IsNullOrWhiteSpace ( city ))
return Result < Address >. Failure ( "City is required" );
// Additional validation...
return Result < Address >. Success ( new Address
{
Street = street ,
City = city ,
State = state ,
ZipCode = zipCode ,
Country = country
});
}
}
Use C# record types for value objects - they provide value-based equality by default.
Aggregates
Aggregates are clusters of entities and value objects with a clear consistency boundary . Each aggregate has one Aggregate Root that controls access.
Key Rules
External access only through the root
Aggregate root enforces invariants
One aggregate per transaction
Aggregates reference each other by ID, not object reference
Aggregate Example: Product
Catalog.Domain/ProductAggregate/Product.cs
public sealed class Product : BaseEntity // Aggregate Root
{
// Properties
public string Title { get ; private set ; }
public decimal Price { get ; private set ; }
public decimal FinalPrice { get ; private set ; }
public double ? AverageRating { get ; private set ; }
// Child entities - private backing field, public read-only access
private readonly List < Review > _reviews = [];
public IReadOnlyCollection < ReviewInfo > Reviews => _reviews
. Select ( r => ( ReviewInfo ) r )
. ToList ()
. AsReadOnly ();
private readonly List < ProductMedia > _productMedias = [];
public IReadOnlyCollection < ProductMediaInfo > ProductMedias => _productMedias
. Select ( pm => ( ProductMediaInfo ) pm )
. ToList ()
. AsReadOnly ();
// Factory method
public static Result < Product > Create (
string title ,
string description ,
decimal price ,
ProductStatus status ,
Guid categoryId ,
Guid sellerId )
{
// Validation logic...
var product = new Product ( title , description , price , status , categoryId , sellerId );
product . RecalculateBonuses ();
product . FinalPrice = price ;
return Result < Product >. Success ( product , HttpStatusCode . Created );
}
// Aggregate operations that maintain consistency
public VoidResult AddReview ( string title , string text , uint rating , Guid customerId )
{
Result < Review > createReviewResult = Review . Create ( title , text , rating , this , customerId );
return createReviewResult . Map (
onSuccess : review =>
{
_reviews . Add ( review );
RecalculateAverageRating (); // Maintain aggregate invariant
return VoidResult . Success ();
},
onFailure : errorMessage => VoidResult . Failure ( errorMessage , createReviewResult . StatusCode )
);
}
public VoidResult RemoveReview ( Guid reviewId )
{
Review ? review = _reviews . FirstOrDefault ( r => r . Id == reviewId );
if ( review == null )
return VoidResult . Failure ( "Review not found" , HttpStatusCode . NotFound );
_reviews . Remove ( review );
RecalculateAverageRating (); // Maintain aggregate invariant
return VoidResult . Success ();
}
public VoidResult ChangePrice ( decimal price )
{
if ( price <= 0 )
return VoidResult . Failure ( "Price must be positive" );
Price = price ;
RecalculateFinalPrice (); // Recalculate based on discount
RecalculateBonuses (); // Recalculate bonus points
return VoidResult . Success ();
}
// Private methods that maintain invariants
private void RecalculateAverageRating ()
{
AverageRating = _reviews . Count == 0
? null
: Math . Round ( _reviews . Average ( r => r . Rating ), MidpointRounding . AwayFromZero );
}
private void RecalculateFinalPrice ()
{
if ( Discount == null || Discount . Status == DiscountStatus . Expired )
{
FinalPrice = Price ;
return ;
}
FinalPrice = Price * ( 100 - Discount . Percent ) / 100 ;
}
private void RecalculateBonuses ()
{
Bonuses = ( uint ) Math . Round ( Price * BonusPercent );
}
}
Never expose child entities directly. Always use the aggregate root to modify children, ensuring invariants are maintained.
Aggregate Boundaries
Each aggregate is a transaction boundary . In Wolfix.Server:
Product Aggregate : Product, Review, ProductMedia, Discount
Order Aggregate : Order, OrderItem, Delivery
Category Aggregate : Category, CategoryAttribute, CategoryVariant
Customer Aggregate : Customer (single entity aggregate)
Seller Aggregate : Seller, SellerApplication
Repositories
Repositories provide an abstraction over data persistence for aggregate roots only .
Repository Interface (Domain)
Catalog.Domain/Interfaces/IProductRepository.cs
public interface IProductRepository
{
Task < Product ?> GetByIdAsync ( Guid id , CancellationToken ct );
Task < List < Product >> GetByCategoryIdAsync ( Guid categoryId , CancellationToken ct );
Task < bool > ExistsAsync ( Guid id , CancellationToken ct );
Task AddAsync ( Product product , CancellationToken ct );
Task UpdateAsync ( Product product , CancellationToken ct );
Task DeleteAsync ( Product product , CancellationToken ct );
Task SaveChangesAsync ( CancellationToken ct );
}
Repository Implementation (Infrastructure)
Catalog.Infrastructure/Repositories/ProductRepository.cs
public class ProductRepository : IProductRepository
{
private readonly CatalogDbContext _context ;
public ProductRepository ( CatalogDbContext context )
{
_context = context ;
}
public async Task < Product ?> GetByIdAsync ( Guid id , CancellationToken ct )
{
return await _context . Products
. Include ( p => p . Reviews ) // Load child entities
. Include ( p => p . ProductMedias )
. Include ( p => p . Discount )
. FirstOrDefaultAsync ( p => p . Id == id , ct );
}
public async Task AddAsync ( Product product , CancellationToken ct )
{
await _context . Products . AddAsync ( product , ct );
}
public async Task SaveChangesAsync ( CancellationToken ct )
{
await _context . SaveChangesAsync ( ct );
}
}
Repositories work with aggregate roots only. You never have a ReviewRepository - reviews are accessed through Product.
Domain Services
Domain services encapsulate business logic that doesn’t naturally fit in an entity .
When to Use Domain Services
Operation involves multiple aggregates
Stateless operations
Complex calculations or algorithms
Business rules that span entities
Example: Price Calculation Service
Catalog.Domain/Services/PriceCalculationService.cs
public class PriceCalculationService
{
public decimal CalculateFinalPrice ( decimal basePrice , Discount ? discount , decimal taxRate )
{
decimal price = basePrice ;
// Apply discount
if ( discount != null && discount . Status == DiscountStatus . Active )
{
price = price * ( 100 - discount . Percent ) / 100 ;
}
// Apply tax
price = price * ( 1 + taxRate );
return Math . Round ( price , 2 );
}
public bool IsEligibleForDiscount ( Product product , Customer customer )
{
// Complex business rule spanning multiple aggregates
if ( customer . MembershipLevel == MembershipLevel . Premium )
return true ;
if ( product . Price > 100 && customer . TotalPurchases > 5 )
return true ;
return false ;
}
}
Encapsulation Patterns
Private Constructors
Force use of factory methods:
private Product () { } // For EF Core
private Product ( string title , decimal price , .. .)
{
Title = title ;
Price = price ;
}
public static Result < Product > Create (...) // Only way to create
{
// Validation
return Result < Product >. Success ( new Product ( .. .));
}
Private Setters
Prevent direct property modification:
public string Title { get ; private set ; }
public decimal Price { get ; private set ; }
// Use methods to change properties
public VoidResult ChangeTitle ( string title )
{
// Validation
Title = title ;
return VoidResult . Success ();
}
Collection Encapsulation
Expose read-only collections:
private readonly List < Review > _reviews = [];
public IReadOnlyCollection < ReviewInfo > Reviews => _reviews
. Select ( r => ( ReviewInfo ) r )
. ToList ()
. AsReadOnly ();
public VoidResult AddReview (...) // Only way to add
{
// Business logic
_reviews . Add ( review );
return VoidResult . Success ();
}
Rich vs Anemic Domain Model
Anemic (Anti-pattern)
// ❌ BAD: Anemic domain model - just data bags
public class Product
{
public Guid Id { get ; set ; }
public string Title { get ; set ; }
public decimal Price { get ; set ; }
public List < Review > Reviews { get ; set ; }
}
// Business logic in service layer
public class ProductService
{
public void AddReview ( Product product , string title , string text )
{
var review = new Review { Title = title , Text = text };
product . Reviews . Add ( review );
}
}
Rich Domain Model (Correct)
// ✅ GOOD: Rich domain model - encapsulated business logic
public class Product : BaseEntity
{
private readonly List < Review > _reviews = [];
public IReadOnlyCollection < ReviewInfo > Reviews => /* ... */ ;
public VoidResult AddReview ( string title , string text , uint rating , Guid customerId )
{
Result < Review > createResult = Review . Create ( title , text , rating , this , customerId );
if ( createResult . IsFailure )
return VoidResult . Failure ( createResult );
_reviews . Add ( createResult . Value ! );
RecalculateAverageRating (); // Maintain invariants
return VoidResult . Success ();
}
}
In a rich domain model, entities contain both data AND behavior. Business rules are enforced by the domain, not scattered in services.
Benefits of DDD in Wolfix.Server
Business Logic Centralization All business rules in one place - the domain
Type Safety Compile-time guarantees for business rules
Testability Easy to unit test domain logic in isolation
Maintainability Changes to business logic stay in the domain
Next Steps
Result Pattern Learn how DDD integrates with the Result pattern
Development Guide Start building your own domain models