CollectionChanged Event

Summary

The CollectionChanged event fires when items in an observable collection (child entities) are added, removed, replaced, or cleared. Use it to react to structural changes in parent-child relationships — for example, recalculating totals when a line item is removed from an order.

When does it fire?

CollectionChanged fires whenever the contents of an ObservableCollection change. This includes:

  • Add: A new child entity is added to the collection
  • Remove: A child entity is removed from the collection
  • Replace: A child entity is replaced with another
  • Clear (Reset): The entire collection is cleared

Note: Collections must use CollectionLoadMode.LoadAll (observable collections) for this event to fire. Paged collections (CollectionLoadMode.Paged) do not support collection change events.

Syntax

salesOrder.Details.OnCollectionChanged()
    .Do((parentEntity, args) => { /* logic */ });

The args parameter provides:

  • args.Action — the type of change (NotifyCollectionChangedAction): Add, Remove, Replace, Reset
  • args.NewItems — items added (for Add/Replace actions)
  • args.OldItems — items removed (for Remove/Replace actions)

Scenarios

1. Recalculating availability when a detail line is removed

When a sales order detail is removed, recalculate availability for the remaining detail lines.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void RecalcOnDetailRemoved()
    {
        salesOrder.Details.OnCollectionChanged().Do((order, args) =>
        {
            if (args.Action == NotifyCollectionChangedAction.Remove)
            {
                foreach (var detail in order.Details.ToList())
                {
                    // Recalculate availability for remaining details
                    var subtotalState = (IPropertyState)order._State.Subtotal;
                    subtotalState.GetPropertyLogic().DependencyManager
                        .MarkDirty(order, subtotalState);
                }
            }
        });
    }
}

2. Setting line number value on newly added detail line

When a new detail line is added to the collection, initialize its line order.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void InitializeNewDetails()
    {
        salesOrder.Details.OnCollectionChanged().Do((order, args) =>
        {
            if (args.Action == NotifyCollectionChangedAction.Add)
            {
                foreach (SalesOrderDetail detail in args.NewItems!)
                {
                    if (detail.LineOrder == 0)
                    {
                        var maxLineOrder = order.Details
                            .Where(d => d != detail)
                            .Select(d => d.LineOrder)
                            .DefaultIfEmpty(0)
                            .Max();
                        detail.LineOrder = maxLineOrder + 1;
                    }
                }
            }
        });
    }
}

3. Validating maximum number of detail lines

Prevent adding more than a maximum number of lines to a sales order.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void LimitDetailLines()
    {
        salesOrder.Details.OnCollectionChanged().Do((order, args) =>
        {
            if (args.Action == NotifyCollectionChangedAction.Add
                && order.Details.Count > 100)
            {
                var prompts = args.GetService<IUserPromptHandler>();
                prompts.AddMessage("A sales order cannot have more than 100 detail lines",
                    PromptLevel.Error);
            }
        });
    }
}

Observable vs. Paged Collections

Collection change events only work with observable collections (CollectionLoadMode.LoadAll).

Feature Observable (LoadAll) Paged
CollectionChanged events Yes No
Loaded into memory All items at once On demand
Use when Small collections (< 100 items) Large collections
Example SalesOrder.Details Product.SalesOrderDetails

Notes

  • In your entity model, use CollectionLoadMode.LoadAll on the [OppositeSideCollection] attribute for collections that need change events.
  • CollectionChanged fires synchronously within the same operation.
  • For reacting to the deletion of an entity (as opposed to its removal from a collection), see Deleted event.
  • When possible, use the Compute event to compute totals rather than the CollectionChanged event. The DirtyWithRelation method on Compute events handles the most common collection-change scenario (recalculating parent aggregates) automatically.