EntityBase Reference
EntityBase<T> is the foundation class for entities in Neatoo. It provides change tracking, validation, persistence awareness, and UI binding capabilities that make building DDD applications straightforward.
Class Hierarchy
Neatoo’s base classes form a hierarchy where each level adds specific capabilities:
Base<T>
└── ValidateBase<T>
└── EntityBase<T>
| Class | Purpose |
|---|---|
Base<T> |
Property management, parent-child relationships, INotifyPropertyChanged, async task tracking |
ValidateBase<T> |
Rules engine, validation messages, IsValid, IsBusy |
EntityBase<T> |
Persistence tracking (IsNew, IsModified, IsDeleted), Save(), factory integration |
Choose your base class based on what you need:
EntityBase<T>- Full entities with persistence tracking (most common)ValidateBase<T>- Validation without persistence (wizard steps, DTOs with rules)Base<T>- Value objects, simple bindable objects
Class Declaration
Entities must be declared as partial classes inheriting from EntityBase<T>:
internal partial class Person : EntityBase<Person>, IPerson
{
// ...
}
The partial keyword is required because the Roslyn Source Generator creates additional partial class files containing:
- Property backing field implementations
- Factory-related infrastructure
Constructor Pattern
Entity constructors must accept IEntityBaseServices<T> and pass it to the base class:
public Person(IEntityBaseServices<Person> services,
IUniqueNameRule uniqueNameRule) : base(services)
{
RuleManager.AddRule(uniqueNameRule);
}
The constructor is where you:
- Inject services via dependency injection
- Add business rules to the
RuleManager - Perform any one-time initialization
IEntityBaseServices<T> provides framework services to the entity. You never interact with it directly; just pass it to base().
Partial Properties
Entity properties are declared as partial properties:
[DisplayName("First Name*")]
[Required(ErrorMessage = "First Name is required")]
public partial string? FirstName { get; set; }
[DisplayName("Last Name*")]
[Required(ErrorMessage = "Last Name is required")]
public partial string? LastName { get; set; }
public partial string? Email { get; set; }
The source generator creates full property implementations that:
- Store values in the
PropertyManager - Raise
PropertyChangedevents - Trigger rules when values change
- Track modification state
Supported Attributes
Properties support standard data annotation attributes:
[Required]- Value must not be null/empty[StringLength]- Min/max string length[Range]- Numeric range validation[DisplayName]- UI-friendly name
Meta-Properties
Meta-properties provide real-time status information about the entity. They are bindable, meaning UI components automatically update when state changes.
IsNew
public virtual bool IsNew { get; protected set; }
true when the entity was created via a [Create] factory method and has not yet been persisted. The factory’s Save() method routes to [Insert] when IsNew is true.
var factory = serviceProvider.GetRequiredService<IPersonFactory>();
var person = factory.Create(); // person.IsNew == true
await factory.Save(person); // Calls [Insert], then person.IsNew == false
IsModified
public virtual bool IsModified =>
PropertyManager.IsModified || IsDeleted || IsNew || IsSelfModified;
true when this entity OR any child entity has been modified. This cascades up the aggregate hierarchy. Use this to determine if any changes need to be saved.
IsSelfModified
public virtual bool IsSelfModified =>
PropertyManager.IsSelfModified || IsDeleted || IsMarkedModified;
true when this specific entity has been modified, ignoring child entities. Useful when you need to know if the entity itself changed versus its children.
IsMarkedModified
public virtual bool IsMarkedModified { get; protected set; }
true when MarkModified() has been called explicitly. This is separate from property-change-based modification tracking.
IsDeleted
public virtual bool IsDeleted { get; protected set; }
true when Delete() has been called. The factory’s Save() method routes to [Delete] when IsDeleted is true.
person.Delete(); // person.IsDeleted == true
await factory.Save(person); // Calls [Delete]
IsSavable
public virtual bool IsSavable => IsModified && IsValid && !IsBusy && !IsChild;
true when the entity can be saved. All conditions must be met:
IsModified- There are changes to persistIsValid- All validation rules pass!IsBusy- No async rules are currently executing!IsChild- This is a root entity (children save through their parent)
Bind save buttons to IsSavable:
<button @onclick="Save" disabled="@(!person.IsSavable)">Save</button>
IsChild
public virtual bool IsChild { get; protected set; }
true when this entity is a child within an aggregate. Child entities cannot be saved directly; they are persisted when the aggregate root is saved.
When an entity is assigned to another entity’s property or added to an EntityListBase, MarkAsChild() is called automatically.
IsValid
public virtual bool IsValid { get; } // Inherited from ValidateBase<T>
true when this entity and all child entities pass validation (no rule messages). Cascades up the aggregate hierarchy.
IsSelfValid
public virtual bool IsSelfValid { get; } // Inherited from ValidateBase<T>
true when this specific entity passes validation, ignoring child entities.
IsBusy
public virtual bool IsBusy { get; } // Inherited from Base<T>
true when async rules or property setters are executing. The UI can show loading indicators based on this property.
IsSelfBusy
public virtual bool IsSelfBusy { get; } // Inherited from Base<T>
true when this specific entity has async operations in progress, ignoring children.
ModifiedProperties
public virtual IEnumerable<string> ModifiedProperties => PropertyManager.ModifiedProperties;
Returns the names of properties that have been modified since the entity was loaded or saved. Useful for:
- Debugging change tracking
- Building update statements that only include changed columns
- Audit logging
foreach (var propName in person.ModifiedProperties)
{
Console.WriteLine($"Changed: {propName}");
}
PropertyMessages
public IReadOnlyCollection<IPropertyMessage> PropertyMessages { get; }
// Inherited from ValidateBase<T>
All validation messages for this entity. Each message includes:
- Property name
- Message text
- Severity (Error, Warning, Information)
foreach (var message in person.PropertyMessages)
{
Console.WriteLine($"{message.PropertyName}: {message.Message}");
}
Parent Property
public IBase? Parent { get; } // Inherited from Base<T>
Reference to the parent entity in the aggregate hierarchy. Set automatically when:
- An entity is assigned to another entity’s property
- An entity is added to an
EntityListBase
Only the aggregate root should have Parent == null.
// PersonPhone.Parent will be set to the Person instance
person.PersonPhoneList.Add(newPhone);
// newPhone.Parent == person (not the list!)
Note: For items in an EntityListBase, Parent points to the list’s parent, not the list itself.
RuleManager
protected IRuleManager RuleManager { get; } // Inherited from ValidateBase<T>
Manages business rules attached to the entity. Use in the constructor to add rules:
public Person(IEntityBaseServices<Person> services,
IUniqueNameRule uniqueNameRule,
IFullNameRule fullNameRule) : base(services)
{
RuleManager.AddRule(uniqueNameRule);
RuleManager.AddRule(fullNameRule);
}
Adding Rules
Rules are added via AddRule():
RuleManager.AddRule(injectedRule);
For inline validation, use the fluent API:
// Simple validation
RuleManager.AddValidation(
nameof(Email),
(Person p) => p.Email?.Contains("@") == true
? RuleMessage.None
: RuleMessage.Error("Invalid email format"));
// Async validation
RuleManager.AddValidationAsync(
nameof(Username),
async (Person p, CancellationToken ct) =>
{
var exists = await CheckUsernameExists(p.Username, ct);
return exists
? RuleMessage.Error("Username already taken")
: RuleMessage.None;
});
// Action (transformation without message)
RuleManager.AddAction(
nameof(FirstName),
(Person p) => p.FullName = $"{p.FirstName} {p.LastName}");
Methods
Save()
public virtual async Task<IEntityBase> Save()
Persists the entity through its factory. Routes to [Insert], [Update], or [Delete] based on entity state.
Throws SaveOperationException if:
IsChildistrue(children save through parent)IsValidisfalse(validation failed)- Not modified (nothing to save)
IsBusyistrue(async operations pending)- No factory method available
try
{
var saved = await person.Save();
}
catch (SaveOperationException ex)
{
// Handle save failure
Console.WriteLine(ex.Reason);
}
Delete()
public void Delete()
Marks the entity for deletion. Sets IsDeleted = true. The actual deletion occurs when Save() is called.
person.Delete(); // Mark for deletion
await person.Save(); // Execute [Delete] factory method
UnDelete()
public void UnDelete()
Reverses a Delete() call. Sets IsDeleted = false. Useful when the user cancels a delete operation.
person.Delete();
// User cancels
person.UnDelete();
MarkModified()
protected virtual void MarkModified()
Explicitly marks the entity as modified, even if no properties changed. Useful when external factors require a save.
MarkAsChild()
protected virtual void MarkAsChild()
Called automatically when an entity is assigned as a child. You rarely need to call this directly.
MarkUnmodified()
protected virtual void MarkUnmodified()
Resets modification tracking. Called automatically after successful save operations.
RunRules()
public Task RunRules(string propertyName, CancellationToken? token = null)
public Task RunRules(RunRulesFlag flags, CancellationToken? token = null)
Manually executes rules. Normally rules run automatically when properties change. Manual execution is useful for:
- Initial validation before display
- Re-running rules after external data changes
await person.RunRules(RunRulesFlag.All);
WaitForTasks()
public Task WaitForTasks() // Inherited from Base<T>
Waits for all pending async operations (rules, property setters) to complete. Useful before checking validation state:
person.Email = "test@example.com"; // May trigger async rule
await person.WaitForTasks(); // Wait for validation to complete
if (person.IsValid) { ... }
PauseAllActions()
public virtual IDisposable PauseAllActions()
Temporarily suppresses property change events and rule execution. Useful for bulk updates:
using (person.PauseAllActions())
{
// These won't trigger individual property changes or rules
person.FirstName = "John";
person.LastName = "Doe";
person.Email = "john@example.com";
}
// Events fire and rules run after dispose
Returns an IDisposable for use with using statement.
Events
PropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
// Inherited from Base<T> via INotifyPropertyChanged
Standard .NET property change notification. Fires when any property or meta-property changes.
NeatooPropertyChanged
public event NeatooPropertyChangedEventHandler? NeatooPropertyChanged;
Enhanced property change event with additional context. The NeatooPropertyChangedEventArgs includes:
- Property name
- Source entity
- Parent chain (breadcrumbs)
Events propagate up the aggregate hierarchy. Override ChildNeatooPropertyChanged to react to child changes:
protected override Task ChildNeatooPropertyChanged(NeatooPropertyChangedEventArgs eventArgs)
{
// React to any child property change
return base.ChildNeatooPropertyChanged(eventArgs);
}
PropertyManager
protected IEntityPropertyManager PropertyManager { get; }
Provides access to individual property objects and their metadata.
Accessing Property Objects
Access properties by name using the indexer:
IEntityProperty firstNameProp = person[nameof(person.FirstName)];
Or through PropertyManager:
IEntityProperty prop = person.PropertyManager["FirstName"];
Property-Level Meta-Properties
Each property is a class with its own meta-properties:
IEntityProperty prop = person[nameof(person.FirstName)];
bool isModified = prop.IsModified; // Has this property changed?
bool isValid = prop.IsValid; // Does this property pass validation?
bool isBusy = prop.IsBusy; // Is an async rule running for this property?
This enables fine-grained UI binding:
<input @bind="person.FirstName"
class="@(person[nameof(person.FirstName)].IsValid ? "" : "error")" />
Complete Example
Here is a complete entity definition using EntityBase<T>:
internal partial class Person : EntityBase<Person>, IPerson
{
public Person(IEntityBaseServices<Person> services,
IUniqueNameRule uniqueNameRule) : base(services)
{
RuleManager.AddRule(uniqueNameRule);
// Inline validation
RuleManager.AddValidation(
nameof(Email),
(Person p) => string.IsNullOrEmpty(p.Email) || p.Email.Contains("@")
? RuleMessage.None
: RuleMessage.Error("Invalid email format"));
}
// Identity
public partial Guid? Id { get; set; }
// Properties with validation
[Required(ErrorMessage = "First Name is required")]
public partial string? FirstName { get; set; }
[Required(ErrorMessage = "Last Name is required")]
public partial string? LastName { get; set; }
public partial string? Email { get; set; }
public partial string? Notes { get; set; }
// Child collection
public partial IPersonPhoneList? PersonPhoneList { get; set; }
// Factory methods
[Create]
public void Create([Service] IPersonPhoneListFactory phoneListFactory)
{
PersonPhoneList = phoneListFactory.Create();
}
[Remote]
[Fetch]
public async Task<bool> Fetch(
[Service] IPersonDbContext dbContext,
[Service] IPersonPhoneListFactory phoneListFactory)
{
var entity = await dbContext.Persons
.Include(p => p.Phones)
.FirstOrDefaultAsync(p => p.Id == Id);
if (entity == null) return false;
// Load properties without triggering rules
LoadProperty(nameof(Id), entity.Id);
LoadProperty(nameof(FirstName), entity.FirstName);
LoadProperty(nameof(LastName), entity.LastName);
LoadProperty(nameof(Email), entity.Email);
LoadProperty(nameof(Notes), entity.Notes);
PersonPhoneList = await phoneListFactory.Fetch(entity.Phones);
return true;
}
[Remote]
[Insert]
public async Task Insert([Service] IPersonDbContext dbContext)
{
Id = Guid.NewGuid();
var entity = new PersonEntity
{
Id = Id.Value,
FirstName = FirstName,
LastName = LastName,
Email = Email,
Notes = Notes
};
dbContext.Persons.Add(entity);
await dbContext.SaveChangesAsync();
}
[Remote]
[Update]
public async Task Update([Service] IPersonDbContext dbContext)
{
var entity = await dbContext.Persons.FindAsync(Id);
// Update only modified properties
if (this[nameof(FirstName)].IsModified)
entity.FirstName = FirstName;
if (this[nameof(LastName)].IsModified)
entity.LastName = LastName;
if (this[nameof(Email)].IsModified)
entity.Email = Email;
if (this[nameof(Notes)].IsModified)
entity.Notes = Notes;
await dbContext.SaveChangesAsync();
}
[Remote]
[Delete]
public async Task Delete([Service] IPersonDbContext dbContext)
{
var entity = await dbContext.Persons.FindAsync(Id);
if (entity != null)
{
dbContext.Persons.Remove(entity);
await dbContext.SaveChangesAsync();
}
}
}
Related Topics
- Entity Overview - Conceptual introduction
- Rule Overview - Business rules and validation
- Factory Overview - Entity lifecycle and persistence
- Person Example - Complete working example