PreSave Event

Summary

The PreSave event runs just before an entity is persisted by SaveChanges(). It is used to populate properties that must have values before saving — such as generating a document number from a sequence, or populating a cached field from related data.

When does it fire?

PreSave fires during SaveChanges(), after all property-level events have run but before the data is written to the database. It runs only for entities that have been modified in the current context.

SaveChanges() → PreSave (per modified entity) → Entity Validate → Write to database

Syntax

Property-level PreSave

Set a specific property if it is empty before saving:

salesOrder.OnPreSave(o => o.DocNumber)
    .IfEmpty()
    .From(expression);

Entity-level PreSave

Run general logic before saving the entity:

salesOrder.OnPreSave().Do((entity, args) => { /* logic */ });

Fluent API (property-level)

Step Method Description
Required with property .IfEmpty() Only set the property if it is currently empty/null
Required .From(expression) The expression that produces the value

Fluent API (entity-level)

Step Method Description
Required .Do(action) The action to execute before saving

Scenarios

1. Auto-generating a document number

When a sales order is saved for the first time, generate a document number from a database sequence if one has not been set.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void GenerateDocNumber()
    {
        salesOrder.OnPreSave(o => o.DocNumber).IfEmpty().From((_, args) =>
        {
            var sequenceManager = args.GetService<ISequenceManager>();
            string docNumber;
            bool isDuplicate;
            do
            {
                var nextVal = sequenceManager
                    .GetNextSequenceValueAsync("SalesOrderNumber").Result;
                docNumber = nextVal.ToString();
                isDuplicate = args.GetEntities<SalesOrder>()
                    .Any(order => order.DocNumber == docNumber);
            } while (isDuplicate);

            return docNumber;
        });
    }
}

2. Setting a description from the first detail line

If the order description was not explicitly set, default it to the first line item's description.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void DefaultDescription()
    {
        salesOrder.OnPreSave(o => o.Description).IfEmpty().From(o =>
        {
            var firstLine = o.Details.FirstOrDefault();
            return firstLine != null
                ? GetFirstLine(firstLine.Description) ?? string.Empty
                : string.Empty;
        });
    }

    static string? GetFirstLine(string? text) =>
        text?.Split('\n', 2).FirstOrDefault()?.Trim();
}

3. Caching a customer name on the order

Store the customer's full name directly on the order for efficient querying, updating it on every save.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void CacheCustomerName()
    {
        salesOrder.CustomerName.OnPreSave()
            .From(o => o.SellToCustomer?.FullName ?? string.Empty);
    }
}

Use entity-level PreSave to perform cross-entity operations before the save completes.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void UpdateInventoryReservations()
    {
        salesOrder.OnPreSave().Do((order, args) =>
        {
            if (order.Status != SalesOrderStatus.Confirmed) return;

            foreach (var detail in order.Details)
            {
                if (detail.Product == null) continue;
                detail.Product.ReservedQuantity += detail.Quantity;
            }
        });
    }
}

5. Setting a computed SKU on a product UOM

Before saving a UOM, build its full SKU by combining the product SKU with the unit name.

[Logic]
public class ProductUomBL(ProductUom.Logic productUom)
{
    [RegisterLogic]
    public void BuildUomSku()
    {
        productUom.OnPreSave(u => u.Sku).IfEmpty().From(uom =>
        {
            if (uom.Product == null || string.IsNullOrEmpty(uom.Name))
                return string.Empty;
            return $"{uom.Product.Sku}-{uom.Name}".ToUpperInvariant();
        });
    }
}

PreSave vs. Compute vs. Default

PreSave Compute Default
When During SaveChanges() When a dirty property is read When trigger changes and target is empty
Overwrites user input Depends on IfEmpty() Yes No
Use for Final population before persistence Derived/calculated values Initial suggested values
Runs for Modified entities only Any time property is read On trigger property change

Notes

  • PreSave runs once during SaveChanges() — it does not run on every property change.
  • PreSave has full access to services via args.GetService<T>() and data via args.GetEntities<T>().
  • Entity-level PreSave (.OnPreSave().Do(...)) runs for the whole entity, not a specific property.