Compute Event
Summary
The Compute event defines how a property value is calculated from other properties or data. Computed properties are lazily evaluated — they only recalculate when they are dirty (marked as needing recomputation). A property becomes dirty when one of its declared dependencies changes.
When does it fire?
A compute event fires when a dirty property is read (via its getter). It does not fire on every property change — only when the computed value is actually needed.
Get property → Is dirty? → Yes → Run Compute → Return new value
→ No → Return cached value
Syntax
salesOrderDetail.Compute(d => d.TotalPrice)
.From(d => d.Quantity * d.UnitPrice)
.DirtyBy(d => new { d.Quantity, d.UnitPrice });
Fluent API
| Step | Method | Description |
|---|---|---|
| Optional | .If(condition) |
Only compute when the condition is true |
| Required | .From(expression) |
The expression that produces the computed value |
| Recommended | .DirtyBy(properties) |
Which properties on the same entity mark this as dirty when changed |
| Optional | .DirtyByMethod(method) |
Marks this dirty after a method on the same entity runs |
| For 1 navigation hop | .DirtyWithRelation(nav).DirtyBy(props) |
Marks dirty when a property on a related entity changes |
| For 2 navigation hops | .DirtyWithRelation(hop1, hop2).DirtyBy(props) |
Marks dirty through a 2-level navigation chain |
| For 3 navigation hops | .DirtyWithRelation(hop1, hop2, hop3).DirtyBy(props) |
Marks dirty through a 3-level navigation chain |
Scenarios
1. Calculating a line total from quantity and price
A sales order detail's total price is the product of quantity and unit price. Whenever either value changes, the total must be recalculated.
[Logic]
public class SalesOrderDetailBL(SalesOrderDetail.Logic salesOrderDetail)
{
[RegisterLogic]
public void ComputeTotalPrice()
{
salesOrderDetail.Compute(d => d.TotalPrice)
.From(d => d.Quantity * d.UnitPrice)
.DirtyBy(d => new { d.Quantity, d.UnitPrice });
}
}
2. Deriving a display name from multiple fields
A contact's full name is built from first and last name. It recomputes whenever either name part changes.
[Logic]
public class ContactBL(Contact.Logic contact)
{
[RegisterLogic]
public void ComputeFullName()
{
contact.Compute(c => c.FullName)
.From(c => $"{c.FirstName} {c.LastName}".Trim())
.DirtyBy(c => new { c.FirstName, c.LastName });
}
}
3. Computing a parent total from a child collection
A sales order's subtotal is the sum of its detail line totals. DirtyWithRelation connects the parent to the child collection so that adding, removing, or changing a detail line marks the subtotal dirty.
The reference property on the child side must be decorated with [OppositeSideCollection(..., CollectionLoadMode.LoadAll)].
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void ComputeSubtotal()
{
salesOrder.Compute(o => o.Subtotal)
.From(o => o.Details.Sum(d => d.TotalPrice))
.DirtyWithRelation(o => o.Details)
.DirtyBy(d => d.TotalPrice);
}
}
4. Pulling a value through a single navigation hop
An order's city comes from its customer. When Customer.City changes, or when the Customer reference itself is replaced, ShipToCity is marked dirty.
The navigation property must have an [OppositeSideProperty(...)] or [OppositeSideCollection(...)] attribute so the system can navigate back from the related entity.
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void ComputeShipToCity()
{
salesOrder.Compute(o => o.ShipToCity)
.From(o => o.Customer!.City)
.DirtyWithRelation(o => o.Customer)
.DirtyBy(c => c.City);
}
}
5. Multi-level dirty: 2 navigation hops
When a computed value reaches through two related entities, pass both navigation selectors to DirtyWithRelation. The system registers dirty targets at every level of the chain automatically:
- When
PrimaryContact.FirstNamechanges →Descriptionis dirtied - When
Customer.PrimaryContactreference changes →Descriptionis dirtied - When
Order.Customerreference changes →Descriptionis dirtied
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void ComputeDescription()
{
salesOrder.Compute(o => o.Description)
.If(o => o.Customer?.PrimaryContact != null)
.From(o => $"{o.Customer!.PrimaryContact!.FirstName} {o.Customer!.PrimaryContact!.LastName}")
.DirtyWithRelation(o => o.Customer, c => c.PrimaryContact)
.DirtyBy(p => new { p.FirstName, p.LastName });
}
}
Every navigation property in the chain must have an [OppositeSideProperty(...)] or [OppositeSideCollection(...)] attribute.
6. Multi-level dirty: 3 navigation hops
Three-level chains work the same way — pass all three selectors. The system handles dirty registration at all four levels (leaf, second intermediate, first intermediate, root).
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void ComputeShipCity()
{
salesOrder.Compute(o => o.ShipCity)
.If(o => o.Customer?.PrimaryContact?.MailingAddress != null)
.From(o => o.Customer!.PrimaryContact!.MailingAddress!.City)
.DirtyWithRelation(o => o.Customer, c => c.PrimaryContact, p => p.MailingAddress)
.DirtyBy(a => a.City);
}
}
7. Compute with service injection
When the compute expression needs external data or services, use the overload that provides args.
[Logic]
public class CustomerBL(Customer.Logic customer)
{
[RegisterLogic]
public void ComputeOpenOrderCount()
{
customer.Compute(c => c.OpenOrderCount)
.From((c, args) =>
{
return args.GetEntities<SalesOrder>()
.Count(o => o.SellToCustomer == c
&& o.Status == SalesOrderStatus.Open);
});
}
}
DirtyWithRelation requirements
Every navigation property used in DirtyWithRelation must declare its opposite side so the system can navigate back from the related entity to the root:
| Navigation type | Required attribute on the navigation property |
|---|---|
| Reference (one-to-one) | [OppositeSideProperty("PropName", "Label")] |
| Collection (one-to-many) | [OppositeSideCollection("PropName", "Label", CollectionLoadMode.LoadAll)] |
Performance note:
CollectionLoadMode.LoadAlleagerly loads the full collection when navigating dirty relationships. Use it only when the collection is expected to be small.
Dirty from a method
DirtyByMethod marks the property dirty after a method runs — for when a method changes state the compute depends on but doesn't set directly.
counter.Compute(c => c.Result)
.From(c => c.Counter * 10)
.DirtyByMethod(c => c.Increment);
counter.Method(c => c.Increment).Do(c => c.Counter += 1);
After Increment runs, Result recomputes on the next read. Chain more .DirtyByMethod(...) calls to react to several methods.
Multiple subscribers on one property
Many [Logic] classes can compute the same property. A later subscriber reads the previous result with args.PriorSubscriberValue. Prior subscribers do not run unless that value is read.
// base value
invoice.Compute(d => d.UnitPrice).From(d => 30m).DirtyBy(d => d.Product);
// later subscriber adds to the prior value
invoice.Compute(d => d.UnitPrice)
.From((d, args) => args.PriorSubscriberValue + 10m)
.DirtyBy(d => d.Product);
PriorSubscriberValue is lazy (prior runs only when read), cached (read it twice, prior runs once), and returns default(T) when there is no prior subscriber. Order follows feature order, then [RegisterLogic] source order within a class.
Notes
- If
DirtyByis omitted, the property is only dirty on load (it computes once per entity load). - A Compute and Changed subscriber pair on the same property will not cause cycles. The Changed subscriber does not fire when the value is set by the Compute subscriber.
- Compute conditions (
.If()) must be fast — they are evaluated on every read of a dirty property.