Entity Validate Event (ValidateOnSave)
Summary
The Entity Validate event runs during SaveChanges() to validate the entity as a whole — across multiple properties or with complex business rules that go beyond single-property validation. If validation fails, the save is aborted and the error message is surfaced to the caller.
When does it fire?
Entity validation fires during SaveChanges(), after PreSave and before the data is written to the database:
SaveChanges() → PreSave → Entity Validate → (if valid) Write to database
→ (if invalid) Abort save
Syntax
Basic validation
salesOrder.ValidateOnSave()
.RejectIf(entity => /* condition */)
.WithMessage("Error message");
Conditional validation (only when specific properties changed)
salesOrder.ValidateOnSave()
.WhenChanged(o => new { o.StartDate, o.EndDate })
.RejectIf(o => o.EndDate < o.StartDate)
.WithMessage("End date must be after start date");
Fluent API
| Step | Method | Description |
|---|---|---|
| Optional | .WhenChanged(properties) |
Only validate when these properties have changed |
| Required | .RejectIf(condition) |
Condition that, when true, rejects the entity |
| Required | .WithMessage(message) |
Error message shown when validation fails |
RejectIf overloads
| Signature | Description |
|---|---|
.RejectIf(entity => bool) |
Validate against the entity |
.RejectIf((entity, args) => bool) |
Validate with access to services and data via args |
WithMessage overloads
| Signature | Description |
|---|---|
.WithMessage("string") |
Static error message |
.WithMessage(entity => "string") |
Dynamic message with access to the entity |
Scenarios
1. Requiring a ship date for confirmed orders
A sales order must have a ship date before it can be saved in a non-draft status.
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void RequireShipDate()
{
salesOrder.ValidateOnSave()
.RejectIf(order =>
!order.ShipDate.HasValue
&& order.Status != SalesOrderStatus.Draft)
.WithMessage("Ship Date is required for non-draft orders");
}
}
2. Validating that a sales order has at least one detail line
An order cannot be saved without any detail lines.
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
[RegisterLogic]
public void RequireAtLeastOneLine()
{
salesOrder.ValidateOnSave()
.RejectIf(o => !o.Details.Any())
.WithMessage("A sales order must have at least one detail line");
}
}
3. Cross-field validation triggered only when fields change
Validate the full name only when the first or last name has been modified.
[Logic]
public class ContactBL(Contact.Logic contact)
{
[RegisterLogic]
public void ValidateFullName()
{
contact.ValidateOnSave()
.WhenChanged(c => new { c.FirstName, c.LastName })
.RejectIf(c => string.IsNullOrWhiteSpace(c.FullName))
.WithMessage("Full name cannot be empty when first name or last name is changed");
}
}
4. Preventing duplicate customer IDs across the system
Validate that no other customer has the same ID.
[Logic]
public class CustomerBL(Customer.Logic customer)
{
[RegisterLogic]
public void ValidateUniqueId()
{
customer.ValidateOnSave()
.RejectIf((c, args) =>
args.GetEntities<Customer>()
.Any(other => other.Id == c.Id && other.Guid != c.Guid))
.WithMessage(c => $"Customer ID '{c.Id}' is already in use");
}
}
5. Validating a product has a selling unit
A product must have at least one UOM marked as sellable before it can be saved.
[Logic]
public class ProductBL(Product.Logic product)
{
[RegisterLogic]
public void RequireSellingUnit()
{
product.ValidateOnSave()
.RejectIf(p => !p.Uoms.Any(u =>
u.SellableOption == SellableOption.Sellable
|| u.SellableOption == SellableOption.DefaultSellingUnit))
.WithMessage("A product must have at least one sellable unit of measure");
}
}
Entity Validate vs. Property Validate
| Property Validate | Entity Validate (ValidateOnSave) | |
|---|---|---|
| When | Immediately on property set | During SaveChanges() |
| Scope | Single property value | Entire entity (cross-field) |
| User experience | Instant feedback | Feedback on save |
| Use for | Format, range, type checks | Business rules, cross-field logic |
| Aborts | Reverts value, shows error | Aborts save, shows error |
Notes
- Entity validation runs during
SaveChanges()— not on every property change. - Use
WhenChanged()to limit validation to specific scenarios and avoid unnecessary work. - If validation fails, the save is completely aborted — no partial writes occur.
- Multiple
ValidateOnSavesubscribers can exist on the same entity; they all run independently. - For immediate property-level validation, see VALIDATE.md.