Common Pitfalls

This page covers mistakes that developers frequently make when working with the event system. Read through these before you start writing business logic — most of them are easy to avoid once you know about them.


Choosing the wrong event

Using Changed when Compute is the right tool

If property B is purely derived from property A, use Compute — not Changed. Compute is lazy, cached, and recalculates only when read. Changed is imperative and runs immediately.

Wrong — imperative update via Changed:

salesOrderDetail.OnChanged(d => d.Quantity)
    .Do(d => d.TotalPrice = d.Quantity * d.UnitPrice);

salesOrderDetail.OnChanged(d => d.UnitPrice)
    .Do(d => d.TotalPrice = d.Quantity * d.UnitPrice);

This duplicates the calculation, runs even when nobody reads TotalPrice, and if TotalPrice has its own Changed subscribers, they fire every time — even if the result didn't change.

Right — declarative Compute:

salesOrderDetail.Compute(d => d.TotalPrice)
    .From(d => d.Quantity * d.UnitPrice)
    .DirtyBy(d => new { d.Quantity, d.UnitPrice });

Use Changed for side effects and synchronizing unrelated data. Use Compute when one property is derived from others.

Confusing Default, Added, and [DefaultValue]

Three mechanisms that look similar but serve different purposes:

Mechanism When it runs Use for
[DefaultValue(1)] attribute Compile time Static literal values (e.g., Quantity = 1)
OnAdded Entity creation Dynamic initialization that always applies (e.g., OrderDate = today)
Default A trigger property changes Filling a field from related data only when the target is empty

Common mistake: Using OnAdded to copy a value from a related entity. The problem is that on creation, the related entity usually isn't set yet.

// Wrong — Product is null when the detail is first created
salesOrderDetail.OnAdded().Do(d =>
    d.Description = d.Product?.SalesDescription ?? "");
// Right — Default fires when Product changes, fills Description if empty
salesOrderDetail.Default(d => d.Description)
    .OnChange(d => d.Product)
    .From(d => d.Product?.SalesDescription ?? string.Empty);

Using property Validate when you need Entity ValidateOnSave (or vice versa)

Event Fires when What happens on failure
Validate A property value is set The property keeps its previous valid value; error shown on that field
ValidateOnSave SaveChanges() is called The entire save is aborted

Use property Validate for rules about a single value: "quantity must be ≥ 1", "email format is invalid."

Use ValidateOnSave for rules that span multiple properties or the entity as a whole: "end date must be after start date", "order must have at least one line."

If you put cross-property validation in a property Validate handler, it may fire before the other property is even set, leading to false rejections.


Compute mistakes

Forgetting DirtyBy

Without DirtyBy, the computed property is never marked dirty after its initial calculation. It returns a stale value forever.

// Bug — TotalPrice computes once and never updates
salesOrderDetail.Compute(d => d.TotalPrice)
    .From(d => d.Quantity * d.UnitPrice);
// Fixed — recalculates when Quantity or UnitPrice changes
salesOrderDetail.Compute(d => d.TotalPrice)
    .From(d => d.Quantity * d.UnitPrice)
    .DirtyBy(d => new { d.Quantity, d.UnitPrice });

This is a silent bug — tests may pass if they only check the initial value.

Side effects inside Compute

Compute is lazily evaluated — it runs only when the property is read, and only if it is dirty. If nobody reads the property, the handler never fires. This makes side effects unpredictable.

// Wrong — logging may fire zero times or many times unpredictably
salesOrder.Compute(o => o.Subtotal)
    .From(o =>
    {
        logger.LogInformation("Recalculating subtotal"); // Don't do this
        return o.Details.Sum(d => d.TotalPrice);
    })
    .DirtyWithRelation(o => o.Details)
    .DirtyBy(d => d.TotalPrice);

Keep Compute handlers as pure functions. Move side effects to Changed handlers which fire deterministically.

Setting other properties inside Compute

A Compute handler for property A should not set property B. It should only return the value for A.

// Wrong — setting Description inside Subtotal's compute
salesOrder.Compute(o => o.Subtotal)
    .From(o =>
    {
        o.Description = $"Order total: {o.Details.Sum(d => d.TotalPrice)}"; // Don't do this
        return o.Details.Sum(d => d.TotalPrice);
    });

If B depends on A, declare a separate Compute for B. If B is a side effect, use Changed on A.


Performance pitfalls

Expensive logic in ReadOnly handlers

ReadOnly conditions are evaluated very frequently — not just when a setter is called, but also when the API sends property metadata to the UI. Every property that has a ReadOnly handler is checked on every API response.

// Wrong — database query in a ReadOnly handler
salesOrder.ReadOnly(o => o.SellToCustomer)
    .If(o =>
    {
        // This runs on EVERY API call that returns this entity
        var hasShipments = dbContext.Shipments.Any(s => s.OrderId == o.Guid);
        return hasShipments;
    });
// Right — fast, in-memory check
salesOrder.ReadOnly(o => o.SellToCustomer)
    .If(o => o.Status == SalesOrderStatus.Shipped || o.Status == SalesOrderStatus.Closed);

If you need information from the database, cache it in a computed property on the entity and check that property in the ReadOnly handler.

CollectionChanged and computes dirtied via a collection requires LoadAll

OnCollectionChanged only works on collections using CollectionLoadMode.LoadAll (observable collections). If the collection uses paged loading, the event silently never fires — no error, no warning.

Make sure the parent-child relationship is configured with LoadAll before relying on collection change events.

Conceptual gaps

Expecting events to fire on direct database changes

Events only fire through the entity's property setters in the event context. These bypass all events:

  • Bulk SQL updates
  • Direct UPDATE statements
  • External database changes
  • Data migration scripts

If you need events to fire, change the data through the entity's properties.

Setting properties in PreSave that trigger further cascades

PreSave runs during SaveChanges(). Setting a property in PreSave fires the full property set pipeline (ReadOnly → AutoCorrect → Validate → Changed). This can cascade unexpectedly.

// Be careful — setting Status here triggers all of Status's event subscribers
salesOrder.OnPreSave().Do((o, args) =>
{
    if (o.Details.All(d => d.IsShipped))
        o.Status = SalesOrderStatus.Shipped; // Fires ReadOnly, Validate, Changed on Status
});

This is not always wrong, but be aware that it happens. If Status has a Changed handler that modifies other properties, those handlers run during the save — which can be surprising.

Thinking imperatively instead of declaratively

This is the biggest mental shift. Instead of writing step-by-step procedures:

"When the user clicks save, check if the order has lines, then check the ship date, then generate a document number..."

You declare independent rules:

"An order rejects save when it has no lines."
"An order rejects save when it is non-draft and has no ship date."
"DocNumber is populated from a sequence if empty at save time."

Each rule is a separate, testable declaration. The framework composes them. Resist the urge to put everything in one large handler — smaller, focused events are easier to reason about and test.