id: domain-driven-design
Domain-Driven Design in SophiChain
Developer's Guide: Tactical and Strategic DDD Patterns
id: domain-driven-design
📖 Overview
This is a prescriptive guide for implementing Domain-Driven Design (DDD) in SophiChain modules using ABP Framework. All developers MUST follow these patterns to ensure consistency, maintainability, and extensibility across the platform.
Target Audience: Module developers, contributors, architects
When to Use: Creating any new module or bounded context in SophiChain
id: domain-driven-design
🎯 Core Concepts
Bounded Contexts
Each module represents a bounded context with:
- Clear boundaries - Well-defined responsibilities
- Ubiquitous language - Domain-specific terminology
- Independent models - No shared entities across contexts
- Context mapping - Explicit relationships between contexts
SophiChain Bounded Contexts Pattern:
- Module = Bounded Context - Each module represents one bounded context
- Clear Domain Language - Use terms from the business domain
- Independence - Modules should be independently deployable
- Integration - Modules communicate via domain events or APIs
Examples in SophiChain:
CatalogModule- Product domain: products, categories, inventoryOrderModule- Order domain: orders, order items, shipmentsCustomerModule- Customer domain: customers, addresses, preferences
id: domain-driven-design
🏗️ Building Blocks
1. Entities
Definition: Objects with identity that persists over time
ABP Base Classes:
// Simple entity with ID
public class MyEntity : Entity<Guid>
{
// Properties
}
// Entity with audit fields
public class MyEntity : FullAuditedAggregateRoot<Guid>
{
// CreationTime, CreatorId, LastModificationTime, etc.
}
// Multi-tenant entity
public class MyEntity : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; set; }
}
Guidelines:
- ✅ Use
Guidfor IDs (distributed system friendly) - ✅ Inherit from ABP base classes (automatic audit trail)
- ✅ Implement
IMultiTenantfor tenant isolation - ✅ Use private setters with public methods for business logic
- ✅ Validate in constructors and methods
Example:
public class Product : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public string Code { get; private set; }
public string Name { get; private set; }
public bool IsActive { get; private set; }
public decimal Price { get; private set; }
public Guid? TenantId { get; set; }
// Private constructor for EF Core
private Product() { }
// Public constructor with validation
public Product(
Guid id,
string code,
string name,
decimal price,
Guid? tenantId = null) : base(id)
{
Code = Check.NotNullOrWhiteSpace(code, nameof(code));
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
Price = Check.Range(price, nameof(price), 0, decimal.MaxValue);
IsActive = false;
TenantId = tenantId;
}
// Business methods encapsulate state changes
public void Activate()
{
if (IsActive) return;
IsActive = true;
AddLocalEvent(new ProductActivatedEvent { ProductId = Id });
}
public void Deactivate()
{
if (!IsActive) return;
IsActive = false;
AddLocalEvent(new ProductDeactivatedEvent { ProductId = Id });
}
public void UpdatePrice(decimal newPrice)
{
Check.Range(newPrice, nameof(newPrice), 0, decimal.MaxValue);
var oldPrice = Price;
Price = newPrice;
AddLocalEvent(new ProductPriceChangedEvent
{
ProductId = Id,
OldPrice = oldPrice,
NewPrice = newPrice
});
}
}
id: domain-driven-design
2. Value Objects
Definition: Objects without identity, defined by their attributes
Implementation Pattern:
// Use C# records for immutability
public record Address(
string Street,
string City,
string PostalCode,
string Country)
{
// Validation in constructor
public Address : this()
{
Street = Check.NotNullOrWhiteSpace(Street, nameof(Street));
City = Check.NotNullOrWhiteSpace(City, nameof(City));
PostalCode = Check.NotNullOrWhiteSpace(PostalCode, nameof(PostalCode));
Country = Check.NotNullOrWhiteSpace(Country, nameof(Country));
}
// Business methods
public bool IsInCountry(string countryCode)
{
return Country.Equals(countryCode, StringComparison.OrdinalIgnoreCase);
}
public override string ToString() =>
$"{Street}, {City} {PostalCode}, {Country}";
}
// Another example: Price value object
public record Price(decimal Amount, string Currency)
{
public Price Add(Price other)
{
if (Currency != other.Currency)
throw new BusinessException("Cannot add prices with different currencies");
return this with { Amount = Amount + other.Amount };
}
public Price Multiply(decimal factor) => this with { Amount = Amount * factor };
public override string ToString() => $"{Amount:N2} {Currency}";
}
Guidelines:
- ✅ Use C#
recordtypes for immutability - ✅ Validate in constructor
- ✅ Provide business operations as methods
- ✅ Override
ToString()for display - ❌ No identity or lifecycle
id: domain-driven-design
3. Aggregates
Definition: Cluster of entities with one root, transactional boundary
Pattern:
// Aggregate Root
public class Order : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public string OrderNumber { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public Guid? TenantId { get; set; }
// Child entities as collection
public virtual ICollection<OrderItem> Items { get; protected set; }
// Private constructor for EF Core
private Order()
{
Items = new Collection<OrderItem>();
}
// Factory method for creating new aggregates
public static Order Create(Guid id, string orderNumber, Guid? tenantId = null)
{
var order = new Order
{
Id = id,
OrderNumber = orderNumber,
Status = OrderStatus.Draft,
TenantId = tenantId
};
// Publish domain event
order.AddLocalEvent(new OrderCreatedEvent { OrderId = id });
return order;
}
// Business methods manage children - NEVER expose collection directly
public void AddItem(Guid productId, string productName, int quantity, decimal unitPrice)
{
// Validation
if (Status != OrderStatus.Draft)
throw new BusinessException("CannotModifyNonDraftOrder");
Check.Range(quantity, nameof(quantity), 1, int.MaxValue);
Check.Range(unitPrice, nameof(unitPrice), 0, decimal.MaxValue);
// Create child entity
var item = new OrderItem(GuidGenerator.Create())
{
ProductId = productId,
ProductName = productName,
Quantity = quantity,
UnitPrice = unitPrice
};
Items.Add(item);
RecalculateTotal();
}
public void RemoveItem(Guid itemId)
{
if (Status != OrderStatus.Draft)
throw new BusinessException("CannotModifyNonDraftOrder");
var item = Items.FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
Items.Remove(item);
RecalculateTotal();
}
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new BusinessException("OrderAlreadySubmitted");
if (!Items.Any())
throw new BusinessException("CannotSubmitEmptyOrder");
Status = OrderStatus.Submitted;
AddLocalEvent(new OrderSubmittedEvent { OrderId = Id, TotalAmount = TotalAmount });
}
// Private business logic
private void RecalculateTotal()
{
TotalAmount = Items.Sum(i => i.Quantity * i.UnitPrice);
}
}
// Child entity - only accessed through aggregate root
public class OrderItem : Entity<Guid>
{
public Guid ProductId { get; internal set; }
public string ProductName { get; internal set; }
public int Quantity { get; internal set; }
public decimal UnitPrice { get; internal set; }
// Internal constructor - can only be created by aggregate root
internal OrderItem(Guid id) : base(id) { }
}
Guidelines:
- ✅ Only aggregate root has repository
- ✅ Children accessed only through root
- ✅ Transaction boundary = aggregate boundary
- ✅ Use
internalconstructors for child entities - ✅ Publish domain events from aggregate root
id: domain-driven-design
4. Domain Services
Definition: Stateless services for domain logic that doesn't fit in entities
Pattern:
public class OrderFulfillmentService : DomainService
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryRepository _inventoryRepository;
private readonly IShippingRepository _shippingRepository;
public OrderFulfillmentService(
IOrderRepository orderRepository,
IInventoryRepository inventoryRepository,
IShippingRepository shippingRepository)
{
_orderRepository = orderRepository;
_inventoryRepository = inventoryRepository;
_shippingRepository = shippingRepository;
}
public virtual async Task<ShipmentResult> ProcessFulfillmentAsync(Guid orderId)
{
// Multi-entity coordination
var order = await _orderRepository.GetAsync(orderId);
// Complex business validation
if (order.Status != OrderStatus.Paid)
throw new BusinessException("OrderNotPaid");
// Check inventory availability
foreach (var item in order.Items)
{
var available = await _inventoryRepository.CheckAvailabilityAsync(
item.ProductId,
item.Quantity);
if (!available)
throw new BusinessException("InsufficientInventory")
.WithData("ProductId", item.ProductId);
}
// Reserve inventory
await ReserveInventoryAsync(order);
// Create shipment
var shipment = await CreateShipmentAsync(order);
// Update order status
order.MarkAsShipped(shipment.Id);
await _orderRepository.UpdateAsync(order);
return new ShipmentResult { ShipmentId = shipment.Id };
}
// Private helper methods
private async Task ReserveInventoryAsync(Order order)
{
foreach (var item in order.Items)
{
await _inventoryRepository.ReserveAsync(
item.ProductId,
item.Quantity,
order.Id);
}
}
private async Task<Shipment> CreateShipmentAsync(Order order)
{
var shipment = new Shipment(GuidGenerator.Create(), order.Id);
return await _shippingRepository.InsertAsync(shipment);
}
}
When to Use Domain Services:
- ✅ Multi-entity operations
- ✅ Complex business logic
- ✅ Domain calculations
- ❌ Simple CRUD (use application services)
- ❌ Infrastructure concerns (use infrastructure services)
Guidelines:
- ✅ Inherit from ABP's
DomainService - ✅ Make methods
virtualfor extensibility - ✅ Inject repository interfaces, not implementations
- ✅ Keep stateless (no fields except injected dependencies)
- ✅ Use meaningful method names (business language)
id: domain-driven-design
5. Domain Events
Definition: Events representing something that happened in the domain
Pattern:
// 1. Define event (Domain layer)
public class OrderShippedEvent
{
public Guid OrderId { get; set; }
public Guid ShipmentId { get; set; }
public Guid CustomerId { get; set; }
public DateTime ShippedAt { get; set; }
}
// 2. Publish from aggregate (Domain layer)
public class Order : AggregateRoot<Guid>
{
public OrderStatus Status { get; private set; }
public Guid? ShipmentId { get; private set; }
public void MarkAsShipped(Guid shipmentId)
{
if (Status != OrderStatus.Paid)
throw new BusinessException("CannotShipUnpaidOrder");
Status = OrderStatus.Shipped;
ShipmentId = shipmentId;
// Publish domain event
AddLocalEvent(new OrderShippedEvent
{
OrderId = Id,
ShipmentId = shipmentId,
CustomerId = CustomerId,
ShippedAt = Clock.Now
});
}
}
// 3. Handle event (Application layer)
public class OrderShippedEventHandler
: ILocalEventHandler<OrderShippedEvent>,
ITransientDependency
{
private readonly INotificationService _notificationService;
private readonly IInventoryRepository _inventoryRepository;
public virtual async Task HandleEventAsync(OrderShippedEvent eventData)
{
// Send notification to customer
await _notificationService.SendAsync(
eventData.CustomerId,
"OrderShipped",
new { OrderId = eventData.OrderId });
// Update inventory tracking
await _inventoryRepository.MarkAsShippedAsync(eventData.OrderId);
}
}
Guidelines:
- ✅ Use for important domain events
- ✅ Publish via
AddLocalEvent()orAddDistributedEvent() - ✅ Keep event data minimal (IDs + key data)
- ✅ Use outbox pattern for reliability
- ✅ Make handlers idempotent
id: domain-driven-design
6. Repositories
Definition: Abstraction for data access, one per aggregate root
Pattern:
// Interface in Domain layer
public interface IProductRepository : IRepository<Product, Guid>
{
Task<Product?> FindBySkuAsync(string sku);
Task<List<Product>> GetActiveAsync();
Task<List<Product>> GetByCategoryAsync(Guid categoryId);
Task<List<Product>> SearchByNameAsync(string searchTerm);
}
// Implementation in Infrastructure layer (MongoDB example)
public class MongoProductRepository
: MongoDbRepository<Product, Guid>,
IProductRepository
{
public MongoProductRepository(
IMongoDbContextProvider<MyModuleMongoDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
public virtual async Task<Product?> FindBySkuAsync(string sku)
{
return await (await GetMongoQueryableAsync())
.FirstOrDefaultAsync(p => p.Sku == sku);
}
public virtual async Task<List<Product>> GetActiveAsync()
{
return await (await GetMongoQueryableAsync())
.Where(p => p.IsActive)
.ToListAsync();
}
public virtual async Task<List<Product>> GetByCategoryAsync(Guid categoryId)
{
return await (await GetMongoQueryableAsync())
.Where(p => p.CategoryId == categoryId && p.IsActive)
.ToListAsync();
}
public virtual async Task<List<Product>> SearchByNameAsync(string searchTerm)
{
return await (await GetMongoQueryableAsync())
.Where(p => p.Name.Contains(searchTerm))
.ToListAsync();
}
}
Guidelines:
- ✅ One repository per aggregate root only
- ✅ Interface in Domain, implementation in Infrastructure
- ✅ Inherit from ABP repository base classes
- ✅ Add domain-specific query methods
- ✅ Use
virtualmethods for extensibility - ❌ Don't create repositories for child entities
id: domain-driven-design
📐 Layered Architecture
Layer Responsibilities
┌──────────────────────────────────────┐
│ Domain.Shared │
│ (Enums, Constants, Value Objects) │
└──────────────────────────────────────┘
↑
┌──────────────────────────────────────┐
│ Domain │
│ (Entities, Aggregates, Services) │
└──────────────────────────────────────┘
↑
┌──────────────────────────────────────┐
│ Application │
│ (App Services, DTOs, Handlers) │
└──────────────────────────────────────┘
↑
┌──────────────────────────────────────┐
│ Infrastructure │
│ (Repositories, External Services) │
└──────────────────────────────────────┘
Dependency Rules
- ✅ Domain.Shared - No dependencies (pure C#)
- ✅ Domain - Depends only on Domain.Shared
- ✅ Application - Depends on Domain + Domain.Shared
- ✅ Infrastructure - Depends on Domain (implements interfaces)
- ❌ Never - Domain depends on Application or Infrastructure
id: domain-driven-design
🎨 Naming Conventions
Namespaces (Follow This Pattern)
// Domain.Shared (no .Domain prefix)
namespace SophiChain.{ModuleName}.{Context};
// Example: namespace SophiChain.Catalog.Products;
// Domain
namespace SophiChain.{ModuleName}.Domain.{Context};
// Example: namespace SophiChain.Catalog.Domain.Products;
// Domain Services subdirectory
namespace SophiChain.{ModuleName}.Domain.{Context}.DomainServices;
// Example: namespace SophiChain.Catalog.Domain.Products.DomainServices;
// Application
namespace SophiChain.{ModuleName}.Application.{Context};
// Example: namespace SophiChain.Catalog.Application.Products;
Files (Naming Conventions)
- Entities:
Product.cs,Order.cs,Customer.cs - Value Objects:
Money.cs,Address.cs,DateRange.cs - Domain Services:
OrderFulfillmentService.cs,PricingCalculator.cs - Repositories:
IProductRepository.cs,IOrderRepository.cs - Events:
OrderShippedEvent.cs,ProductCreatedEvent.cs
id: domain-driven-design
✅ Best Practices
Entity Design
- ✅ Encapsulate state with private setters
- ✅ Provide business methods, not just properties
- ✅ Validate in constructor and methods
- ✅ Use
Checkhelper for validation - ✅ Publish domain events for state changes
Aggregate Design
- ✅ Keep aggregates small
- ✅ Reference other aggregates by ID only
- ✅ One aggregate per transaction
- ✅ Children cannot be accessed directly
- ✅ Use domain events for cross-aggregate communication
Domain Service Design
- ✅ Stateless operations only
- ✅ Multi-entity coordination
- ✅ Complex business logic
- ✅ Virtual methods for extensibility
- ❌ Don't put CRUD logic here
Repository Design
- ✅ One per aggregate root
- ✅ Interface in Domain
- ✅ Custom query methods
- ✅ Return domain objects
- ❌ Don't expose IQueryable in interface
id: domain-driven-design
📚 ABP Framework Features
Built-in DDD Support
Entity Base Classes:
Entity<TKey>- Basic entityAggregateRoot<TKey>- Aggregate rootFullAuditedEntity<TKey>- With audit fieldsFullAuditedAggregateRoot<TKey>- Aggregate + audit
Interfaces:
IMultiTenant- Multi-tenancy supportIHasExtraProperties- Dynamic propertiesISoftDelete- Soft delete support
Helpers:
Check.NotNull()- Validation helpersGuidGenerator.Create()- GUID generationClock.Now- Time abstraction
id: domain-driven-design
📖 References
id: domain-driven-design
🎯 Next Steps
Apply these patterns when creating your own modules:
- Define your bounded contexts
- Identify aggregates and entities
- Create domain services for complex logic
- Publish domain events for state changes
- Write repository interfaces
id: domain-driven-design