Aggregate Factory Pattern
When an aggregate can be both created from scratch and reconstituted from existing data, one factory method is not enough.
That is the problem this pattern solves.
- new aggregates need a new ID
- reconstituted aggregates must keep an existing ID
- both paths should enforce the same domain invariants
Tip
Aggregate<TId> does not impose factory methods for you. The pattern below is a Trellis-friendly convention for keeping creation explicit and safe.
The recommended shape
Use two factory paths:
| Method | Use it for | ID behavior |
|---|---|---|
TryCreate(...) |
New aggregate | Generates a new ID |
TryCreateExisting(id, ...) |
Reconstitution, migrations, tests with known IDs | Preserves the supplied ID |
Create(...) |
Same as TryCreate, but for known-good data |
Throws on failure |
CreateExisting(id, ...) |
Same as TryCreateExisting, but for known-good data |
Throws on failure |
A working example
using Trellis;
using Trellis.Primitives;
namespace AggregateFactories;
[Trellis.StringLength(200)]
public partial class ProductName : RequiredString<ProductName> { }
[Trellis.StringLength(64)]
public partial class Sku : RequiredString<Sku> { }
public partial class ProductId : RequiredGuid<ProductId> { }
public sealed record ProductCreated(ProductId ProductId, DateTime OccurredAt) : IDomainEvent;
public sealed class Product : Aggregate<ProductId>
{
private Product(ProductId id, ProductName name, Sku sku)
: base(id)
{
Name = name;
Sku = sku;
IsActive = true;
}
private Product() : base(null!)
{
Name = null!;
Sku = null!;
}
public ProductName Name { get; private set; }
public Sku Sku { get; private set; }
public bool IsActive { get; private set; }
public static Result<Product> TryCreate(ProductName name, Sku sku)
{
var product = new Product(ProductId.NewUniqueV7(), name, sku);
product.DomainEvents.Add(new ProductCreated(product.Id, DateTime.UtcNow));
return Result.Success(product);
}
public static Result<Product> TryCreateExisting(ProductId id, ProductName name, Sku sku)
{
var product = new Product(id, name, sku);
return Result.Success(product);
}
public static Product Create(ProductName name, Sku sku)
{
var result = TryCreate(name, sku);
if (result.IsFailure)
throw new InvalidOperationException(result.Error.Detail);
return result.Value;
}
public static Product CreateExisting(ProductId id, ProductName name, Sku sku)
{
var result = TryCreateExisting(id, name, sku);
if (result.IsFailure)
throw new InvalidOperationException(result.Error.Detail);
return result.Value;
}
}
Why two methods are better than one
If TryCreate(...) always generates an ID, you cannot safely rebuild an existing aggregate:
var knownId = ProductId.Create(Guid.Parse("8e945d6d-e4f4-4dd6-bb50-3ab19f9d9fd1"));
var name = ProductName.Create("Trellis Mug");
var sku = Sku.Create("MUG-001");
var newProduct = Product.Create(name, sku); // new ID
var existingProduct = Product.CreateExisting(knownId, name, sku); // preserved ID
That distinction matters for:
- manual rehydration
- tests that need fixed IDs
- data import or migration code
- reconstitution outside EF Core
Where EF Core fits
When EF Core materializes an aggregate, it typically uses the parameterless constructor and sets properties during rehydration.
That is why many Trellis aggregates use this shape:
- parameterless constructor for EF Core
- private constructor with all required state
TryCreate(...)for new instancesTryCreateExisting(...)for explicit reconstitution outside EF Core
Note
The parameterless constructor is for infrastructure. Your domain code should still prefer explicit factory methods.
Keep validation in one place
Both creation paths should enforce the same rules. A simple way to do that is to validate the arguments before either constructor call.
public static Result<Product> TryCreate(ProductName name, Sku sku)
{
var validation = Validate(name, sku);
if (validation.IsFailure)
return validation.Error;
var product = new Product(ProductId.NewUniqueV7(), name, sku);
product.DomainEvents.Add(new ProductCreated(product.Id, DateTime.UtcNow));
return Result.Success(product);
}
public static Result<Product> TryCreateExisting(ProductId id, ProductName name, Sku sku)
{
var validation = Validate(name, sku);
if (validation.IsFailure)
return validation.Error;
return Result.Success(new Product(id, name, sku));
}
private static Result Validate(ProductName name, Sku sku)
{
if (sku.Value.StartsWith("LEGACY-", StringComparison.OrdinalIgnoreCase))
return Error.Validation("SKU cannot start with LEGACY.", nameof(sku));
return Result.Success();
}
Domain events and reconstitution
A common mistake is raising “created” events while reconstituting existing data.
Use this rule:
TryCreate(...)may raise creation eventsTryCreateExisting(...)usually should not
That keeps UncommittedEvents() meaningful.
What this pattern does not do
These concerns belong elsewhere:
ETagis infrastructure-managedAcceptChanges()belongs after persistence/event publication- repository lookups belong in repositories or handlers, not in the aggregate constructor
Warning
Do not assign ETag in your factory methods. ETag exists for optimistic concurrency and is owned by persistence infrastructure.
A practical checklist
Use this pattern when your aggregate:
- has a strong identity type like
ProductId - needs a constructor for EF Core
- must support both new and existing instances
- raises domain events for true state changes
Summary
The aggregate factory pattern gives you:
- clear intent
- correct identity handling
- safer tests
- cleaner reconstitution paths
- fewer accidental domain events
If the aggregate is new, generate a new ID. If it already exists, preserve the ID you were given.