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 ValidateOnSave subscribers can exist on the same entity; they all run independently.
  • For immediate property-level validation, see VALIDATE.md.