Aggregate Factory Pattern
The Problem: How to Handle Both New and Existing Aggregates?
When working with DDD aggregates, you need two different creation scenarios:
- Creating NEW aggregates - Generate fresh ID
- Reconstituting EXISTING aggregates - Preserve existing ID (from database, tests, etc.)
If you only have one factory method that always generates a new ID, you can't load existing aggregates from the database!
The Solution: Dual Factory Methods
Pattern Overview
public class Product : Aggregate<ProductId>
{
// ✅ Pattern 1: Parameterless constructor for EF Core
private Product() : base(null!) { }
// ✅ Pattern 2: Private constructor accepting ID
private Product(ProductId id, ...) : base(id) { }
// ✅ Pattern 3: TryCreate for NEW aggregates (generates ID)
public static Result<Product> TryCreate(...) =>
// ... validation ...
.Map(() => new Product(ProductId.NewUniqueV7(), ...));
// ✅ Pattern 4: TryCreateExisting for EXISTING aggregates (accepts ID)
public static Result<Product> TryCreateExisting(ProductId id, ...) =>
// ... validation ...
.Map(() => new Product(id, ...));
// ✅ Pattern 5: Convenience methods that throw
public static Product Create(...) => TryCreate(...).Value;
public static Product CreateExisting(ProductId id, ...) => TryCreateExisting(id, ...).Value;
}
When to Use Each Method
| Method | Use Case | ID Handling | Example |
|---|---|---|---|
TryCreate |
Creating new domain objects | Generates new ID | Product.TryCreate("Laptop", "SKU-001", 999.99m, "Electronics") |
TryCreateExisting |
Loading from database, tests with known IDs | Accepts existing ID | Product.TryCreateExisting(productId, "Laptop", "SKU-001", 999.99m, "Electronics") |
Create |
Tests where validation should never fail | Generates new ID, throws | var product = Product.Create("Laptop", "SKU-001", 999.99m, "Electronics") |
CreateExisting |
Tests needing specific ID | Accepts existing ID, throws | var product = Product.CreateExisting(knownId, "Laptop", "SKU-001", 999.99m, "Electronics") |
Real-World Examples
Example 1: Creating a New Product (Domain Logic)
// ✅ Use TryCreate - generates new ID
public async Task<Result<Product>> CreateProductAsync(ProductDto dto)
{
return await Product.TryCreate(
dto.Name,
dto.Sku,
dto.Price,
dto.Category,
dto.StockQuantity)
.EnsureAsync(
async p => !await _repository.SkuExistsAsync(p.Sku),
Error.Conflict("SKU already exists"))
.TapAsync(async p => await _repository.SaveAsync(p));
}
Example 2: Loading from Database (EF Core)
// ✅ EF Core uses parameterless constructor + property setters
var product = await _dbContext.Products
.FirstOrDefaultAsync(p => p.Id == productId);
// EF Core reconstitutes: new Product() { Id = productId, Name = ..., etc. }
Example 3: Testing with Known IDs
[Fact]
public void Product_with_specific_id_for_testing()
{
// ✅ Use CreateExisting in tests when you need a specific ID
var knownId = ProductId.Create(Guid.Parse("12345678-1234-1234-1234-123456789abc"));
var product = Product.CreateExisting(
knownId,
"Test Product",
"TEST-SKU",
99.99m,
"Test Category");
product.Id.Should().Be(knownId);
}
Example 4: Updating an Existing Product
// ✅ Load existing product, then update
public async Task<Result<Product>> UpdateProductAsync(ProductId id, UpdateProductDto dto)
{
return await _repository.GetByIdAsync(id) // Loads with existing ID
.ToResultAsync(Error.NotFound($"Product {id} not found"))
.Bind(product => product.UpdateDetails(dto.Name, dto.Price, dto.Category))
.TapAsync(async product => await _repository.SaveAsync(product));
}
Example 5: Manual Reconstitution (if not using EF Core)
// ✅ Use TryCreateExisting when manually deserializing
public Result<Product> DeserializeProduct(ProductData data) =>
Product.TryCreateExisting(
data.Id,
data.Name,
data.Sku,
data.Price,
data.Category,
data.StockQuantity,
data.IsActive);
Why Two Factory Methods?
❌ Anti-Pattern: Single Factory Always Generates ID
// ❌ BAD: Can't load existing products!
public static Result<Product> TryCreate(string name, ...) =>
// ...
.Map(() => new Product(ProductId.NewUniqueV7(), ...)); // Always new ID!
// Problem 1: Can't load from database
var existingProduct = Product.TryCreate(dbData.Name, ...); // ❌ Creates NEW ID!
// Problem 2: Can't test with known IDs
var testId = ProductId.Create(Guid.Parse("..."));
var product = Product.TryCreate(...); // ❌ Generates random ID, can't use testId
✅ Correct Pattern: Dual Factory Methods
// ✅ GOOD: Separate methods for different scenarios
// For creating NEW products
public static Result<Product> TryCreate(string name, ...) =>
.Map(() => new Product(ProductId.NewUniqueV7(), ...)); // ✅ New ID
// For EXISTING products
public static Result<Product> TryCreateExisting(ProductId id, string name, ...) =>
.Map(() => new Product(id, ...)); // ✅ Existing ID preserved
Benefits
✅ Type-safe - Compiler ensures you provide an ID when needed
✅ Clear intent - Method name tells you if ID is new or existing
✅ EF Core compatible - Parameterless constructor for ORM
✅ Testable - Can create products with specific IDs in tests
✅ Domain-driven - TryCreate for business logic, TryCreateExisting for infrastructure
Summary
| Scenario | Method | Why |
|---|---|---|
| Creating new product in domain | TryCreate |
Business logic should generate IDs |
| Loading from database | EF Core parameterless constructor | ORM handles reconstitution |
| Manual deserialization | TryCreateExisting |
Preserve existing ID from source |
| Testing with known ID | CreateExisting |
Tests need predictable IDs |
| Quick test setup | Create |
Tests where validation won't fail |
Key Insight: The TryCreate vs TryCreateExisting distinction mirrors the DDD principle that aggregate identity is immutable. New aggregates get new IDs; existing aggregates keep their IDs. 🎯