Rules Engine Reference
The Neatoo rules engine provides trigger-based business rule execution for validation and data transformation. Rules are classes that declare dependencies on properties and execute automatically when those properties change.
Rule Base Classes
Neatoo provides two abstract base classes for creating rules:
RuleBase
For synchronous rules that do not require async operations:
public class AgeValidationRule : RuleBase<Person>
{
public AgeValidationRule() : base(p => p.Age) { }
protected override IRuleMessages Execute(Person target)
{
if (target.Age < 0)
return (nameof(target.Age), "Age cannot be negative").AsRuleMessages();
if (target.Age > 150)
return (nameof(target.Age), "Age value seems unrealistic").AsRuleMessages();
return None;
}
}
Key characteristics:
- Inherits from
RuleBase<T>whereTis the entity interface type - Override
Execute(T target)to implement logic - Return
Nonewhen validation passes - Return
IRuleMessageswhen validation fails
AsyncRuleBase
For rules requiring database access, API calls, or other async operations:
public class UniqueEmailRule : AsyncRuleBase<Person>
{
private readonly IEmailService _emailService;
public UniqueEmailRule(IEmailService emailService)
: base(p => p.Email)
{
_emailService = emailService;
}
protected override async Task<IRuleMessages> Execute(
Person target, CancellationToken? token = null)
{
if (string.IsNullOrEmpty(target.Email))
return None;
var exists = await _emailService.EmailExistsAsync(
target.Email, token ?? CancellationToken.None);
return exists
? (nameof(target.Email), "Email already in use").AsRuleMessages()
: None;
}
}
Key characteristics:
- Inherits from
AsyncRuleBase<T> - Override
Execute(T target, CancellationToken? token)for async logic - Sets
IsBusy = trueon the entity while executing - Supports cancellation through the token parameter
Defining Trigger Properties
Trigger properties determine when a rule executes. Specify them in the constructor.
Using Lambda Expressions (Recommended)
public class FullNameRule : RuleBase<Person>
{
// Runs when FirstName OR LastName changes
public FullNameRule()
: base(p => p.FirstName, p => p.LastName) { }
protected override IRuleMessages Execute(Person target)
{
target.FullName = $"{target.FirstName} {target.LastName}";
return None;
}
}
Using Property Names
public class FullNameRule : RuleBase<Person>
{
public FullNameRule()
: base(nameof(Person.FirstName), nameof(Person.LastName)) { }
protected override IRuleMessages Execute(Person target)
{
target.FullName = $"{target.FirstName} {target.LastName}";
return None;
}
}
Adding Triggers After Construction
public class DynamicRule : RuleBase<Order>
{
public DynamicRule()
{
AddTriggerProperties(o => o.Subtotal);
AddTriggerProperties(o => o.TaxRate);
AddTriggerProperties(o => o.DiscountPercent);
}
protected override IRuleMessages Execute(Order target)
{
target.Total = target.Subtotal * (1 + target.TaxRate) *
(1 - target.DiscountPercent);
return None;
}
}
The Execute() Method
The Execute() method is where rule logic lives.
Synchronous Execute
protected override IRuleMessages Execute(Person target)
{
// Access entity properties
var firstName = target.FirstName;
var lastName = target.LastName;
// Perform validation
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
return (nameof(target.FirstName), "At least one name is required")
.AsRuleMessages();
// Transform data
target.FullName = $"{firstName} {lastName}".Trim();
// Return None when validation passes
return None;
}
Asynchronous Execute
protected override async Task<IRuleMessages> Execute(
Person target, CancellationToken? token = null)
{
var ct = token ?? CancellationToken.None;
// Perform async operations
var isAvailable = await _userService.CheckUsernameAsync(
target.Username, ct);
if (!isAvailable)
return (nameof(target.Username), "Username is taken").AsRuleMessages();
// Async data transformation
target.UserDetails = await _userService.GetDetailsAsync(target.Id, ct);
return None;
}
Accessing Parent Entities
Child entities can access their parent:
protected override IRuleMessages Execute(OrderLine target)
{
var order = target.Parent as IOrder;
if (order == null)
return (nameof(target.ProductId), "Line must belong to an order")
.AsRuleMessages();
// Use parent data in validation
if (target.ShipDate < order.OrderDate)
return (nameof(target.ShipDate), "Ship date cannot be before order date")
.AsRuleMessages();
return None;
}
Returning Rule Messages
No Errors (Validation Passed)
return None;
// or
return RuleMessages.None;
Single Error Message
// Tuple syntax
return (nameof(target.Email), "Invalid email format").AsRuleMessages();
// Explicit construction
return new RuleMessage(nameof(target.Email), "Invalid email format")
.AsRuleMessages();
Multiple Error Messages
// Array syntax
return new[]
{
(nameof(target.FirstName), "First name is required"),
(nameof(target.LastName), "Last name is required")
}.AsRuleMessages();
// Collection builder
var messages = new RuleMessages();
messages.Add(nameof(target.FirstName), "First name is required");
messages.Add(nameof(target.LastName), "Last name is required");
return messages;
Conditional (Fluent) Syntax
// Single condition
return RuleMessages.If(
string.IsNullOrEmpty(target.Name),
nameof(target.Name),
"Name is required");
// Chained conditions
return RuleMessages
.If(string.IsNullOrEmpty(target.Email),
nameof(target.Email), "Email is required")
.ElseIf(() => !target.Email.Contains("@"),
nameof(target.Email), "Email format is invalid")
.ElseIf(() => target.Email.Length > 100,
nameof(target.Email), "Email is too long");
Clear Previous Messages for Property
When a property becomes valid, previous messages are automatically cleared. To explicitly clear:
return new RuleMessage(nameof(target.Email)).AsRuleMessages();
// Clears any existing message for Email, adds no new message
Cascading Rules
Rules can trigger other rules by modifying properties:
// Rule 1: Calculate line total
public class LineTotalRule : RuleBase<OrderLine>
{
public LineTotalRule() : base(l => l.Quantity, l => l.UnitPrice) { }
protected override IRuleMessages Execute(OrderLine target)
{
target.Total = target.Quantity * target.UnitPrice;
return None;
}
}
// Rule 2: Calculate order subtotal (triggers when Lines collection changes)
public class OrderSubtotalRule : RuleBase<Order>
{
public OrderSubtotalRule() : base(o => o.Lines) { }
protected override IRuleMessages Execute(Order target)
{
target.Subtotal = target.Lines?.Sum(l => l.Total) ?? 0;
// Setting Subtotal triggers OrderTotalRule
return None;
}
}
// Rule 3: Calculate order total
public class OrderTotalRule : RuleBase<Order>
{
public OrderTotalRule() : base(o => o.Subtotal, o => o.TaxRate) { }
protected override IRuleMessages Execute(Order target)
{
target.Tax = target.Subtotal * target.TaxRate;
target.Total = target.Subtotal + target.Tax;
return None;
}
}
When orderLine.UnitPrice changes:
LineTotalRuleruns, setsorderLine.TotalOrderSubtotalRuleruns (Lines collection changed), setsorder.SubtotalOrderTotalRuleruns (Subtotal changed), setsorder.Taxandorder.Total
Preventing Infinite Loops
The framework prevents a rule from triggering itself. If Rule A sets Property X, and Rule A triggers on Property X, it will not re-run from its own change.
Dependency Injection
Rules support constructor injection:
public class UniqueNameRule : AsyncRuleBase<Person>
{
private readonly IPersonRepository _repository;
private readonly ILogger<UniqueNameRule> _logger;
public UniqueNameRule(
IPersonRepository repository,
ILogger<UniqueNameRule> logger)
: base(p => p.FirstName, p => p.LastName)
{
_repository = repository;
_logger = logger;
}
protected override async Task<IRuleMessages> Execute(
Person target, CancellationToken? token = null)
{
_logger.LogDebug("Checking name uniqueness for {Name}",
$"{target.FirstName} {target.LastName}");
var exists = await _repository.NameExistsAsync(
target.FirstName, target.LastName, target.Id);
return exists
? (nameof(target.FirstName), "This name already exists").AsRuleMessages()
: None;
}
}
Registration
Register rules in your DI container:
// Individual registration
builder.Services.AddScoped<IUniqueNameRule, UniqueNameRule>();
// Convention-based registration
builder.Services.RegisterMatchingName(
typeof(IUniqueNameRule).Assembly,
ServiceLifetime.Scoped);
Creating Rule Interfaces
Define interfaces for rules to support DI:
public interface IUniqueNameRule : IRule<IPerson> { }
public class UniqueNameRule : AsyncRuleBase<Person>, IUniqueNameRule
{
// ...
}
RuleManager Methods
The RuleManager is accessed through ValidateBase<T> and provides rule management.
AddRule
Add a rule instance:
public Person(IEntityBaseServices<Person> services,
IUniqueNameRule uniqueNameRule,
IAgeValidationRule ageRule) : base(services)
{
RuleManager.AddRule(uniqueNameRule);
RuleManager.AddRule(ageRule);
}
AddValidation (Inline Sync Validation)
Add simple validation without a separate class:
// Single trigger property
RuleManager.AddValidation(
nameof(Email),
(Person p) => string.IsNullOrEmpty(p.Email) || p.Email.Contains("@")
? RuleMessage.None
: RuleMessage.Error("Invalid email format"));
// Multiple trigger properties
RuleManager.AddValidation(
(Person p) =>
{
if (p.StartDate > p.EndDate)
return (nameof(p.StartDate), "Start must be before end").AsRuleMessages();
return RuleMessages.None;
},
p => p.StartDate, p => p.EndDate);
AddValidationAsync (Inline Async Validation)
RuleManager.AddValidationAsync(
async (Person p, CancellationToken ct) =>
{
var exists = await emailService.ExistsAsync(p.Email, ct);
return exists
? (nameof(p.Email), "Email in use").AsRuleMessages()
: RuleMessages.None;
},
p => p.Email);
AddAction (Inline Sync Transformation)
For rules that transform data without validation:
// Calculate full name
RuleManager.AddAction(
(Person p) => p.FullName = $"{p.FirstName} {p.LastName}",
p => p.FirstName, p => p.LastName);
// Normalize data
RuleManager.AddAction(
(Person p) => p.Email = p.Email?.ToLowerInvariant(),
p => p.Email);
AddActionAsync (Inline Async Transformation)
RuleManager.AddActionAsync(
async (Order o, CancellationToken ct) =>
{
o.TaxRate = await taxService.GetRateAsync(o.ShipToZip, ct);
},
o => o.ShipToZip);
Data Annotation Attributes
Neatoo integrates with standard .NET System.ComponentModel.DataAnnotations attributes. These are automatically converted to synchronous validation rules that execute when the decorated property changes.
Supported Attributes
The following DataAnnotations are fully supported:
// Required - value must not be null or empty
[Required(ErrorMessage = "First name is required")]
public partial string? FirstName { get; set; }
// StringLength - string length constraints
[Required]
[StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters")]
public partial string? LastName { get; set; }
// EmailAddress - email format validation
[EmailAddress(ErrorMessage = "Invalid email format")]
public partial string? Email { get; set; }
// Range - numeric range validation
[Range(0, 150, ErrorMessage = "Age must be between 0 and 150")]
public partial int Age { get; set; }
// RegularExpression - pattern matching
[RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Invalid ZIP code")]
public partial string? ZipCode { get; set; }
// MinLength/MaxLength - collection and string size
[MinLength(1, ErrorMessage = "At least one item required")]
public partial IList<string>? Tags { get; set; }
// Compare - cross-property comparison
[Compare(nameof(Password), ErrorMessage = "Passwords must match")]
public partial string? ConfirmPassword { get; set; }
Complete Attribute Reference
| Attribute | Purpose | Applies To |
|---|---|---|
[Required] |
Value must not be null or empty | Any type |
[StringLength] |
Min/max string length | string |
[MinLength] |
Minimum length | string, collections |
[MaxLength] |
Maximum length | string, collections |
[Range] |
Numeric range (min, max) | Numeric types, DateTime |
[EmailAddress] |
Email format validation | string |
[RegularExpression] |
Regex pattern matching | string |
[Compare] |
Equality with another property | Any type |
How Attribute Validation Works
When you apply a validation attribute to a partial property:
- Source Generator Detects - The Neatoo source generator finds attributes on partial properties
- Rule Created - A sync validation rule is generated for each attribute
- Trigger Set - The rule triggers when the decorated property changes
- Validation Runs - On property change, the attribute validation executes
- Message Set - If validation fails, the error message is added to
PropertyMessages
// What you write
[Required(ErrorMessage = "Name is required")]
[StringLength(50, ErrorMessage = "Name too long")]
public partial string? Name { get; set; }
// What Neatoo effectively generates (conceptually)
// Rule 1: Required check
// Rule 2: StringLength check
// Both trigger on Name property changes
Custom Error Messages
All attributes support custom error messages via the ErrorMessage property:
[Required(ErrorMessage = "Please enter your email address")]
[EmailAddress(ErrorMessage = "That doesn't look like a valid email")]
public partial string? Email { get; set; }
If you omit ErrorMessage, a default message is used.
Combining Attributes with Custom Rules
Attributes work alongside custom rules. Use attributes for simple format validation and custom rules for complex business logic:
[Factory]
internal partial class Person : EntityBase<Person>, IPerson
{
public Person(
IEntityBaseServices<Person> services,
IUniqueEmailRule uniqueEmailRule) : base(services)
{
// Custom async rule for database validation
RuleManager.AddRule(uniqueEmailRule);
}
// Attribute handles format, custom rule handles uniqueness
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
public partial string? Email { get; set; }
}
Execution order:
- Attribute-based rules run first (sync)
- Custom sync rules run next
- Custom async rules run last
DisplayName Attribute
The [DisplayName] attribute provides a user-friendly name for UI binding:
[DisplayName("First Name*")]
[Required(ErrorMessage = "First name is required")]
public partial string? FirstName { get; set; }
This is used by UI components like MudNeatoo for labels:
@* Uses DisplayName for the label *@
<MudNeatooTextField For="() => person.FirstName" />
Limitations of Attribute Validation
Attribute validation has inherent limitations:
| Limitation | Solution |
|---|---|
| No async operations | Use AsyncRuleBase<T> for database checks |
| No cross-property complex logic | Use RuleBase<T> with multiple triggers |
| No contextual/conditional logic | Use custom rules with DI services |
| Single error message per attribute | Return multiple messages from custom rules |
Example: Comprehensive Property Validation
Combining attributes with inline and custom rules:
[Factory]
internal partial class Registration : EntityBase<Registration>, IRegistration
{
public Registration(
IEntityBaseServices<Registration> services,
IUsernameAvailableRule usernameRule) : base(services)
{
// Async rule for database check
RuleManager.AddRule(usernameRule);
// Inline rule for password strength
RuleManager.AddValidation(
nameof(Password),
(Registration r) =>
{
if (string.IsNullOrEmpty(r.Password)) return RuleMessage.None;
if (!r.Password.Any(char.IsUpper))
return RuleMessage.Error("Password needs an uppercase letter");
if (!r.Password.Any(char.IsDigit))
return RuleMessage.Error("Password needs a number");
return RuleMessage.None;
});
}
// Attribute: format and length
[Required(ErrorMessage = "Username is required")]
[StringLength(30, MinimumLength = 3, ErrorMessage = "Username must be 3-30 characters")]
[RegularExpression(@"^[a-zA-Z0-9_]+$", ErrorMessage = "Letters, numbers, and underscores only")]
public partial string? Username { get; set; }
// Attribute: basic requirement
[Required(ErrorMessage = "Password is required")]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
public partial string? Password { get; set; }
// Attribute: cross-property comparison
[Required(ErrorMessage = "Please confirm your password")]
[Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public partial string? ConfirmPassword { get; set; }
// Attribute: format
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
public partial string? Email { get; set; }
}
Running Rules Manually
RunRules for Specific Property
await person.RunRules(nameof(person.Email));
RunRules with Flags
// Run all rules
await person.RunRules(RunRulesFlag.All);
// Run only sync rules
await person.RunRules(RunRulesFlag.SyncOnly);
// Run rules for all properties
await person.RunRules(RunRulesFlag.CheckAllProperties);
When to Run Rules Manually
Typically rules run automatically. Manual execution is useful for:
- Initial validation when an entity loads
- Re-validation after external data changes
- Testing specific rule behavior
// Before save, ensure all rules have run
[Insert]
public async Task Insert([Service] IDbContext db)
{
await RunRules(RunRulesFlag.All);
if (!IsValid)
throw new ValidationException("Entity has validation errors");
// Proceed with insert...
}
The IsBusy State
When async rules execute, the entity’s IsBusy property becomes true:
// Entity level
bool isBusy = person.IsBusy; // Any async operation in progress
bool selfBusy = person.IsSelfBusy; // This entity specifically
// Property level
bool emailBusy = person[nameof(person.Email)].IsBusy;
UI Integration
<input @bind="person.Email"
disabled="@person[nameof(person.Email)].IsBusy" />
@if (person[nameof(person.Email)].IsBusy)
{
<span class="spinner">Checking...</span>
}
<button @onclick="Save"
disabled="@(person.IsBusy || !person.IsSavable)">
@(person.IsBusy ? "Validating..." : "Save")
</button>
WaitForTasks
Wait for all async operations to complete:
person.Email = "test@example.com"; // Triggers async validation
await person.WaitForTasks(); // Wait for completion
if (person.IsValid)
{
await person.Save();
}
Unit Testing Rules
Rules are regular classes that you can unit test:
Testing Synchronous Rules
[Fact]
public void AgeValidationRule_WhenNegative_ReturnsError()
{
// Arrange
var rule = new AgeValidationRule();
var person = new MockPerson { Age = -5 };
// Act
var messages = rule.Execute(person);
// Assert
Assert.Contains(messages,
m => m.PropertyName == nameof(IPerson.Age));
}
[Fact]
public void AgeValidationRule_WhenValid_ReturnsNone()
{
// Arrange
var rule = new AgeValidationRule();
var person = new MockPerson { Age = 30 };
// Act
var messages = rule.Execute(person);
// Assert
Assert.Empty(messages);
}
Testing Asynchronous Rules
[Fact]
public async Task UniqueEmailRule_WhenExists_ReturnsError()
{
// Arrange
var mockService = new Mock<IEmailService>();
mockService.Setup(s => s.EmailExistsAsync("taken@example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var rule = new UniqueEmailRule(mockService.Object);
var person = new MockPerson { Email = "taken@example.com" };
// Act
var messages = await rule.Execute(person);
// Assert
Assert.Contains(messages,
m => m.Message.Contains("already in use"));
}
[Fact]
public async Task UniqueEmailRule_WhenAvailable_ReturnsNone()
{
// Arrange
var mockService = new Mock<IEmailService>();
mockService.Setup(s => s.EmailExistsAsync("new@example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var rule = new UniqueEmailRule(mockService.Object);
var person = new MockPerson { Email = "new@example.com" };
// Act
var messages = await rule.Execute(person);
// Assert
Assert.Empty(messages);
}
Creating Test Doubles
For testing, create minimal implementations:
public class MockPerson : IPerson
{
public Guid? Id { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public int Age { get; set; }
// Implement required interface members...
}
Complete Rule Example
Here is a complete rule implementation from the Neatoo example project:
public interface IUniquePhoneNumberRule : IRule<IPersonPhone> { }
public class UniquePhoneNumberRule : RuleBase<PersonPhone>, IUniquePhoneNumberRule
{
public UniquePhoneNumberRule()
: base(p => p.PhoneNumber, p => p.PhoneType) { }
protected override IRuleMessages Execute(PersonPhone target)
{
// Access parent through the aggregate
var parent = target.Parent as IPerson;
if (parent?.PersonPhoneList == null)
return None;
// Check for duplicates among siblings
var isDuplicate = parent.PersonPhoneList
.Where(p => p != target)
.Any(p => p.PhoneNumber == target.PhoneNumber);
return RuleMessages
.If(isDuplicate,
nameof(target.PhoneNumber),
"Phone number must be unique");
}
}
Usage in entity:
[Factory]
internal partial class PersonPhone : EntityBase<PersonPhone>, IPersonPhone
{
public PersonPhone(
IEntityBaseServices<PersonPhone> services,
IUniquePhoneNumberRule uniquePhoneRule,
IUniquePhoneTypeRule uniqueTypeRule) : base(services)
{
RuleManager.AddRule(uniquePhoneRule);
RuleManager.AddRule(uniqueTypeRule);
}
public partial Guid? Id { get; set; }
[Required]
public partial PhoneType PhoneType { get; set; }
[Required]
public partial string? PhoneNumber { get; set; }
public IPerson? ParentPerson => Parent as IPerson;
}
Common Patterns
Cross-Property Validation
public class DateRangeRule : RuleBase<Event>
{
public DateRangeRule() : base(e => e.StartDate, e => e.EndDate) { }
protected override IRuleMessages Execute(Event target)
{
if (target.StartDate > target.EndDate)
return (nameof(target.EndDate),
"End date must be after start date").AsRuleMessages();
return None;
}
}
Conditional Validation
public class ShippingAddressRule : RuleBase<Order>
{
public ShippingAddressRule()
: base(o => o.RequiresShipping, o => o.ShippingAddress) { }
protected override IRuleMessages Execute(Order target)
{
if (target.RequiresShipping && string.IsNullOrEmpty(target.ShippingAddress))
return (nameof(target.ShippingAddress),
"Shipping address is required").AsRuleMessages();
return None;
}
}
Format Normalization
public class PhoneNormalizationRule : RuleBase<Contact>
{
public PhoneNormalizationRule() : base(c => c.Phone) { }
protected override IRuleMessages Execute(Contact target)
{
if (!string.IsNullOrEmpty(target.Phone))
{
// Remove non-digits
target.Phone = new string(target.Phone.Where(char.IsDigit).ToArray());
}
return None;
}
}
Related Topics
- Rules Philosophy - Conceptual overview
- EntityBase Reference - RuleManager on entities
- DDD Concepts - Business rules in DDD
- Person Example - Complete working example