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 related level .DirtyWithRelation(nav).DirtyBy(props) Marks dirty when a property on a related entity (a single reference or a collection) changes
For 2 related levels .DirtyWithRelation(level1, level2).DirtyBy(props) Marks dirty through a 2-level chain; each level can be a single reference or a LoadAll collection
For 3 related levels .DirtyWithRelation(level1, level2, level3).DirtyBy(props) Marks dirty through a 3-level chain; each level can be a single reference or a LoadAll collection

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);
    }
}

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 levels

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.FirstName changes → Description is dirtied
  • When Customer.PrimaryContact reference changes → Description is dirtied
  • When Order.Customer reference changes → Description is 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 levels

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. Multi-level dirty through a collection

Any level in the chain can be a collection instead of a single reference — just pass a collection for that level. In this example, a payment counts its closed invoices by going through its list of invoice links to each invoice:

  • When Invoice.ClosedDate changes → ClosedInvoiceCount is dirtied
  • When an InvoiceLink is added to or removed from Payment.InvoiceLinksClosedInvoiceCount is dirtied
  • When an InvoiceLink.Invoice reference is replaced → ClosedInvoiceCount is dirtied
[Logic]
public class PaymentBL(Payment.Logic payment)
{
    [RegisterLogic]
    public void ComputeClosedInvoiceCount()
    {
        payment.Compute(p => p.ClosedInvoiceCount)
            .From(p => p.InvoiceLinks.Count(l => l.Invoice is { } inv && inv.ClosedDate != default))
            .DirtyWithRelation(p => p.InvoiceLinks, l => l.Invoice)  // a collection, then a single reference
            .DirtyBy(i => i.ClosedDate);
    }
}

A collection can be at any level — first, middle, or last — and you can mix them freely (for example reference → collection → reference). A collection used this way must be CollectionLoadMode.LoadAll; CollectionLoadMode.Paged throws an error, because paged collections don't report when items are added or removed. Every time a dependency changes the whole collection is loaded, so keep these collections small (see the performance note below).

8. 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)]

This applies to every level, including collection levels in a multi-level chain. A CollectionLoadMode.Paged collection can't be used here, because paged collections don't report added or removed items, so those changes would be missed.

Performance note: CollectionLoadMode.LoadAll loads the whole collection when it checks dirty relationships. Use it only when the collection is small. This matters most when a collection is a first or middle level in a multi-level chain: when a dependency changes, the whole collection is loaded and every item is followed through the rest of the chain. Keep these collections 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 DirtyBy is 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.