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);
}
}
4. Entity-level PreSave to update related data
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 viaargs.GetEntities<T>(). - Entity-level PreSave (
.OnPreSave().Do(...)) runs for the whole entity, not a specific property.