Aggregates and Entity Graphs
Aggregates are clusters of domain objects treated as a single unit. In Neatoo, aggregates form naturally through entity relationships, with automatic parent assignment, modification tracking, and validation propagation throughout the object graph.
What is an Aggregate?
An Aggregate is a cluster of entities and value objects with a defined boundary. All access from outside the aggregate must go through a single entry point called the Aggregate Root.
Real-World Analogy: The Order Form
Think of a paper order form. The form contains:
- Header information (order date, customer reference)
- Line items (products, quantities, prices)
- Totals and calculations
- Payment details
When you work with this form:
- You pick up the entire form, not individual line items
- You add or remove line items through the form
- When you submit, everything goes together
- The form validates itself (do totals match? are required fields filled?)
You cannot walk into the warehouse with just a line item and say “ship this.” The line item only makes sense in the context of its order.
This is exactly how Neatoo aggregates work.
The Aggregate Root Pattern
The Aggregate Root is the top-level entity that:
- Has global identity (can be referenced from outside the aggregate)
- Controls access to all entities within the aggregate
- Enforces invariants across the entire aggregate
- Serves as the unit of persistence
In Neatoo Terms
[Factory]
internal partial class Order : EntityBase<Order>, IOrder
{
public partial Guid? Id { get; set; } // Global identity
public partial DateTime OrderDate { get; set; }
public partial string? CustomerName { get; set; }
public partial IOrderLineList? Lines { get; set; } // Child collection
public partial decimal Subtotal { get; set; }
public partial decimal Tax { get; set; }
public partial decimal Total { get; set; }
}
The Order is the Aggregate Root. The Lines collection contains child entities that:
- Cannot be saved independently
- Reference the Order as their Parent
- Contribute to the Order’s modification and validation state
Aggregate Structure Diagram
graph TD
subgraph "Order Aggregate"
Order["Order (Aggregate Root)<br/>IsChild = false<br/>Parent = null"]
LineList["OrderLineList<br/>EntityListBase"]
Line1["OrderLine<br/>IsChild = true"]
Line2["OrderLine<br/>IsChild = true"]
Line3["OrderLine<br/>IsChild = true"]
Address["ShippingAddress<br/>Value Object (Base)"]
end
Order --> LineList
Order --> Address
LineList --> Line1
LineList --> Line2
LineList --> Line3
Line1 -.->|Parent| Order
Line2 -.->|Parent| Order
Line3 -.->|Parent| Order
Key observations:
- Order has no parent (
Parent == null) andIsChild == false - OrderLine entities have
IsChild == trueandParentpointing to Order - ShippingAddress is a value object (uses
Base<T>), not an entity - The entire structure saves and loads as a unit
Parent-Child Relationships
Neatoo automatically manages parent-child relationships through two mechanisms.
Automatic Parent Assignment
When you assign an entity to another entity’s property, the framework:
- Sets the child’s
Parentproperty to the parent entity - Calls
MarkAsChild()on the child - Subscribes to the child’s property change events
// In Order's Create method
[Create]
public void Create([Service] IOrderLineListFactory lineListFactory)
{
Lines = lineListFactory.Create();
// Lines.Parent is now set to this Order
}
Collection Item Parent Assignment
When items are added to an EntityListBase, the framework:
- Sets the item’s
Parentto the list’s parent (not the list itself) - Marks the item as a child
- Registers for property change events
var order = await orderFactory.Create();
var newLine = await order.Lines.AddLine();
// newLine.IsChild == true
// newLine.Parent == order (not order.Lines)
Note that Parent points to the Order, not the OrderLineList. This allows line items to access their parent order directly.
Accessing the Parent
Child entities can cast Parent to access parent-specific functionality:
internal partial class OrderLine : EntityBase<OrderLine>, IOrderLine
{
// Read-only property to access typed parent
public IOrder? ParentOrder => Parent as IOrder;
// Use in validation rules
private void ValidateAgainstOrderDate()
{
if (ParentOrder?.OrderDate > DeliveryDate)
{
// Handle invalid state
}
}
}
PropertyChanged Propagation
When any property changes within an aggregate, events propagate up to the root.
How Propagation Works
- Property value changes on a child entity
- Child raises
PropertyChangedandNeatooPropertyChangedevents - Parent’s
ChildNeatooPropertyChangedhandler receives the event - Parent re-evaluates its own meta-properties (
IsModified,IsValid, etc.) - Parent raises its own
PropertyChangedfor affected meta-properties - Process repeats up to the aggregate root
Example: Modification Cascade
// User changes a line item price
order.Lines[0].UnitPrice = 29.99m;
// This triggers:
// 1. OrderLine.PropertyChanged("UnitPrice")
// 2. OrderLine.IsModified becomes true
// 3. Order.ChildNeatooPropertyChanged receives the event
// 4. Order.IsModified becomes true (child is modified)
// 5. Order.PropertyChanged("IsModified")
// 6. UI bound to order.IsModified updates automatically
Reacting to Child Changes
Override ChildNeatooPropertyChanged to react to changes anywhere in the aggregate:
protected override Task ChildNeatooPropertyChanged(
NeatooPropertyChangedEventArgs eventArgs)
{
// Recalculate totals when any line item changes
if (eventArgs.PropertyName == nameof(IOrderLine.UnitPrice) ||
eventArgs.PropertyName == nameof(IOrderLine.Quantity))
{
RecalculateTotals();
}
return base.ChildNeatooPropertyChanged(eventArgs);
}
private void RecalculateTotals()
{
Subtotal = Lines?.Sum(l => l.UnitPrice * l.Quantity) ?? 0;
Tax = Subtotal * 0.08m;
Total = Subtotal + Tax;
}
Modification Tracking Through the Graph
Neatoo tracks modification state at every level of the aggregate.
Entity-Level Modification
Each EntityBase tracks its own modifications:
| Property | Meaning |
|---|---|
IsSelfModified |
This entity’s properties changed |
IsModified |
This entity OR any child is modified |
ModifiedProperties |
Which properties changed |
Aggregate-Level Modification
The aggregate root’s IsModified is true if:
- Any property on the root changed
- Any child entity is modified
- Any child entity was added
- Any child entity was deleted
var order = await orderFactory.Fetch(orderId);
// order.IsModified == false
order.Lines[0].Quantity = 5;
// order.Lines[0].IsModified == true
// order.IsModified == true (child is modified)
order.CustomerName = "Updated Name";
// order.IsSelfModified == true (root property changed)
// order.IsModified == true
Why This Matters
Modification tracking enables:
- Smart Save: Only persist what changed
- Dirty Checking: Warn users about unsaved changes
- Optimistic Concurrency: Track exactly what needs updating
- UI Binding: Enable/disable save buttons based on
IsSavable
Validation Propagation
Validation state also propagates through the aggregate.
How Validation Cascades
Each entity maintains:
| Property | Meaning |
|---|---|
IsSelfValid |
This entity passes all its rules |
IsValid |
This entity AND all children pass validation |
PropertyMessages |
Validation messages for this entity |
The aggregate root’s IsValid is true only when every entity in the graph passes validation.
var order = await orderFactory.Create();
order.CustomerName = "John Doe";
// order.IsSelfValid might be true
order.Lines.AddLine();
// New line has Required fields that are empty
// order.Lines[0].IsValid == false
// order.IsValid == false (child is invalid)
Cross-Entity Validation
Rules can validate relationships between entities:
public class OrderTotalRule : RuleBase<IOrder>
{
public OrderTotalRule() : base(o => o.Subtotal, o => o.Lines) { }
protected override IRuleMessages Execute(IOrder target)
{
var calculatedSubtotal = target.Lines?
.Sum(l => l.UnitPrice * l.Quantity) ?? 0;
if (Math.Abs(calculatedSubtotal - target.Subtotal) > 0.01m)
return (nameof(target.Subtotal),
"Subtotal does not match line items").AsRuleMessages();
return None;
}
}
The Class Hierarchy
Neatoo’s base classes build on each other:
Base<T> - Properties, parent-child, INotifyPropertyChanged
└── ValidateBase<T> - Rules engine, validation messages, IsValid, IsBusy
└── EntityBase<T> - IsNew, IsModified, IsDeleted, Save(), factory integration
Matching DDD Patterns to Base Classes
| I Need… | Use |
|---|---|
| An entity with identity and persistence | EntityBase<T> |
| A validated object without persistence | ValidateBase<T> |
| A value object (immutable data holder) | Base<T> |
| A collection of entities | EntityListBase<I> |
| A collection with validation | ValidateListBase<I> |
Hierarchy Constraint
Critical Rule: You cannot nest an EntityBase under a Base.
Why? Base<T> does not propagate modification tracking. If you placed an EntityBase inside a Base, modifications to the entity would not bubble up to the aggregate root.
Valid patterns:
// EntityBase containing EntityBase - OK
public partial class Order : EntityBase<Order>
{
public partial IOrderLineList? Lines { get; set; } // EntityListBase
}
// EntityBase containing Base - OK (value object as leaf)
public partial class Order : EntityBase<Order>
{
public partial IAddress? ShippingAddress { get; set; } // Base
}
// Base containing Base - OK
public partial class Address : Base<Address>
{
public partial IGeoCoordinate? Coordinates { get; set; } // Base
}
Invalid pattern:
// Base containing EntityBase - NOT ALLOWED
public partial class SomeValueObject : Base<SomeValueObject>
{
// This breaks modification tracking!
public partial ISomeEntity? Entity { get; set; } // EntityBase - NO!
}
Saving Aggregates
When you save an aggregate root, the entire aggregate persists as a unit.
The Save Flow
var order = await orderFactory.Create();
order.CustomerName = "New Customer";
order.Lines.AddLine();
order.Lines[0].ProductName = "Widget";
order.Lines[0].Quantity = 2;
// Save the entire aggregate
await order.Save();
What happens:
order.Save()is called- Framework checks
IsSavable(must be modified, valid, not busy, not a child) IsNewis true, so the generated factory calls[Insert]- Your
Insertmethod persists the Order - Your code calls the child factory to persist Lines
- Framework marks everything as unmodified
Child Entities Cannot Save Themselves
var line = order.Lines[0];
await line.Save(); // Throws! line.IsChild == true
This is by design. Children must save through their aggregate root to maintain transactional consistency.
Handling Insert/Update/Delete
Your factory methods handle the persistence:
[Remote]
[Insert]
public async Task Insert(
[Service] IOrderDbContext db,
[Service] IOrderLineListFactory lineListFactory)
{
Id = Guid.NewGuid();
var entity = new OrderEntity();
MapTo(entity);
db.Orders.Add(entity);
// Persist child collection
await lineListFactory.Save(Lines, entity.Id);
await db.SaveChangesAsync();
}
[Remote]
[Update]
public async Task Update(
[Service] IOrderDbContext db,
[Service] IOrderLineListFactory lineListFactory)
{
var entity = await db.Orders.FindAsync(Id);
MapModifiedTo(entity); // Only modified properties
// Persist child changes (inserts, updates, deletes)
await lineListFactory.Save(Lines, entity.Id);
await db.SaveChangesAsync();
}
Best Practices
Keep Aggregates Small
Large aggregates cause:
- Long load times
- Lock contention
- Complex save logic
- Memory pressure
Design aggregates around true invariants. If two entities can change independently without violating business rules, they probably belong in separate aggregates.
Reference Other Aggregates by ID
Do not nest one aggregate root inside another. Use IDs:
// Good - reference by ID
public partial Guid CustomerId { get; set; }
// Bad - embedding another aggregate
public partial ICustomer? Customer { get; set; } // Don't do this
Use Meaningful Methods
Expose domain operations, not just property setters:
// In OrderLineList
public async Task<IOrderLine> AddLine()
{
var line = await _lineFactory.Create();
Add(line);
return line;
}
public void RemoveLineAndRecalculate(IOrderLine line)
{
Remove(line);
ParentOrder?.RecalculateTotals();
}
Validate at the Right Level
- Property-level: Data format, required fields
- Entity-level: Single entity invariants
- Aggregate-level: Cross-entity invariants
Related Topics
- DDD Concepts - Overview of DDD patterns in Neatoo
- EntityBase Reference - Complete entity API
- EntityListBase Reference - Collection handling
- Rules Philosophy - Business rule approach