Introduction to Event-Driven Architecture
What is this?
In a traditional application, business logic lives in service classes with imperative code — you write step-by-step instructions that fetch data, check conditions, update fields, and save. In Benevia Core, business logic is declared as events on properties and entities. The framework runs your logic automatically at the right time.
You don't call your business logic. It fires by itself when properties change, entities are created, or data is saved.
A simple example
Suppose a sales order detail has a TotalPrice that should always equal Quantity × UnitPrice. In a traditional codebase, you'd manually recalculate this everywhere Quantity or UnitPrice is set. With events, you declare it once:
[Logic]
public class SalesOrderDetailBL(SalesOrderDetail.Logic salesOrderDetail)
{
[RegisterLogic]
public void ComputeTotalPrice()
{
salesOrderDetail.Compute(d => d.TotalPrice)
.From(d => d.Quantity * d.UnitPrice)
.DirtyBy(d => new { d.Quantity, d.UnitPrice });
}
}
This says: "TotalPrice is computed from Quantity × UnitPrice. Whenever Quantity or UnitPrice changes, mark TotalPrice as dirty so it recomputes on the next read." You never call this code directly — the framework handles it.
Key concepts
Logic containers
Every entity gets a generated logic container (e.g., SalesOrder.Logic, Contact.Logic). This is the entry point for registering events. You receive it through constructor injection in your business logic class.
Business logic classes
Business logic lives in classes decorated with [Logic]. Each method that registers events is decorated with [RegisterLogic]. These methods run once at application startup to wire up the event subscriptions.
[Logic]
public class ProductBL(Product.Logic product)
{
[RegisterLogic]
public void AutoCorrectSku()
{
product.AutoCorrect(p => p.Sku)
.Transform(sku => sku?.ToUpperInvariant());
}
}
Event args
Most event handlers accept either a simple signature or a detailed one with args:
// Simple — just the entity
.Do(entity => { ... })
// Detailed — entity + args for services and data access
.Do((entity, args) => {
var service = args.GetService<IMyService>();
var otherEntities = args.GetEntities<OtherEntity>();
})
The args object provides:
args.Context— the currentEventContextargs.GetService<T>()— resolve a service from DIargs.GetEntities<T>()— query other entitiesargs.GetEntity<T>(id)— retrieve a specific entity by key
The pipelines
The event system has four pipelines that cover the full lifecycle of an entity: creation, property changes, saving, and deletion.
Entity creation pipeline
When a new entity is constructed with new Entity(context):
new Entity(context) → Added → (properties are set via the property set pipeline)
- Added — Initialize dynamic properties (e.g., set
OrderDateto today), create related child entities, or copy defaults from the environment. Runs once per entity creation.
After Added completes, any properties set on the new entity go through the property set pipeline below.
Property set pipeline
When you set a property value, events fire in this order:
Set property → ReadOnly → AutoCorrect → Validate → Dirty dependents → Changed
- ReadOnly — Can the property be written to? If any read-only condition is true, the setter throws.
- AutoCorrect — Transform the value before validation (e.g., uppercase a code, round a number).
- Validate — Reject invalid values. The property keeps its last valid value.
- Dirty dependents — Mark computed properties that depend on this one as dirty.
- Changed — React to the successful change (e.g., sync related data).
Save pipeline
When SaveChanges() is called:
SaveChanges() → PreSave → Entity Validate → Compute dirty persisted properties → Write to database
- PreSave — Populate fields that need save-time data (e.g., generate a document number from a sequence).
- Entity Validate — Validate the entity as a whole (e.g., "an order must have at least one line").
- Compute dirty persisted properties - Any property that is dirty and is persisted is computed since the new value needs to be saved. Virtual properties do not compute.
- Write to database — Persist the changes.
Entity deletion pipeline
When context.Delete(entity) is called:
Delete request → Deleting (can abort) → Entity removed → Deleted (post-cleanup)
- Deleting — Runs before removal. Can abort the deletion with
AbortIf(e.g., "cannot delete a customer with open orders"). Can also specify additional entities to cascade-delete withAlsoDelete. - Entity removed — The entity is removed from the context.
- Deleted — Runs after removal. Use for post-deletion cleanup, transferring responsibilities to other entities, or logging. The entity data is still readable but no longer tracked.
Event types at a glance
Entity lifecycle events
| Event | When | Purpose |
|---|---|---|
| Added | Entity is created | Initialize dynamic properties, create child entities |
| Deleting | Before entity is deleted | Prevent deletion or specify cascade deletes |
| Deleted | After entity is deleted | Post-deletion cleanup, transfer responsibilities |
Property value pipeline events
| Event | When | Purpose |
|---|---|---|
| ReadOnly | Property setter is called | Block writes based on business rules |
| AutoCorrect | After read-only check | Transform/normalize the value |
| Validate | After auto-correct | Reject invalid values |
| Compute | Dirty property is read | Calculate a value from other properties |
| Default | Trigger property changes | Set a value when the target is empty |
| Changed | After successful set | React to value changes, sync related data |
| CollectionChanged | Child collection changes | React to items added/removed in child collections |
Save-time events
| Event | When | Purpose |
|---|---|---|
| PreSave | Before persisting | Populate fields that need save-time data |
| Entity Validate | Before persisting | Cross-property or cross-entity validation |
Custom operations
| Event | When | Purpose |
|---|---|---|
| Methods | Explicitly invoked via API | Operations exposed as c# methods and OData endpoints. |
Walkthrough: building logic for a sales order
Let's see how multiple events work together on a sales order. Each snippet below is a separate [RegisterLogic] method inside a [Logic] class.
1. Initialize defaults when the order is created
salesOrder.OnAdded().Do(o =>
o.OrderDate = DateOnly.FromDateTime(DateTime.UtcNow));
When a new SalesOrder is created, the order date is set to today.
2. Default the price level from the customer
salesOrder.Default(o => o.PriceLevel)
.OnChange(o => o.SellToCustomer)
.From(o => o.SellToCustomer?.PriceLevel);
When the user selects a customer, the price level auto-fills from that customer's setting — but only if the price level is currently empty. If the user already chose a price level, it is not overwritten.
3. Compute the subtotal from detail lines
salesOrder.Compute(o => o.Subtotal)
.From(o => o.Details.Sum(d => d.TotalPrice))
.DirtyWithRelation(o => o.Details)
.DirtyBy(d => d.TotalPrice);
The subtotal is the sum of all detail line totals. DirtyWithRelation connects the parent to the child collection so that adding, removing, or modifying a detail line marks the subtotal as needing recomputation.
4. Lock fields when the order is finalized
salesOrder.ReadOnly(o => o.SellToCustomer).If(IsFinalized);
salesOrder.ReadOnly(o => o.OrderDate).If(IsFinalized);
static bool IsFinalized(SalesOrder o) =>
o.Status == SalesOrderStatus.Shipped || o.Status == SalesOrderStatus.Closed;
Once an order is shipped or closed, its customer and order date can no longer be changed.
5. Validate before saving
salesOrder.ValidateOnSave()
.RejectIf(o => !o.Details.Any())
.WithMessage("A sales order must have at least one detail line");
When SaveChanges() is called, the order is rejected if it has no detail lines.
6. Generate a document number on first save
salesOrder.OnPreSave(o => o.DocNumber).IfEmpty().From((_, args) =>
{
var sequenceManager = args.GetService<ISequenceManager>();
return sequenceManager.GetNextSequenceValueAsync("SalesOrderNumber").Result.ToString();
});
If the document number is still empty at save time, generate one from a database sequence.
How it all fits together
None of these methods call each other. They are independent declarations. The framework orchestrates them:
- User creates a new sales order → Added fires, sets
OrderDate. - User picks a customer → Default fires, fills
PriceLevel. - User adds a detail line, sets
QuantityandUnitPrice→ Compute on the detail fires, calculatesTotalPrice. The parent'sSubtotalis marked dirty. - User reads
Subtotal→ Compute fires, sums detail totals. - User tries to change
SellToCustomeron a shipped order → ReadOnly blocks it. - User calls
SaveChanges()→ PreSave generatesDocNumber, Entity Validate checks for at least one detail line, then data is written.
Each event is small, focused, and testable in isolation. The framework handles the orchestration.
Entity state
You can inspect the runtime state of any property through the _State accessor:
// Check if a property is dirty (needs recomputation)
customer._State.FullName.IsDirty;
// Check if a property is read-only
orderDetail._State.Quantity.IsReadOnly;
// Get the original value before changes
customer._State.OriginalValue;
Next steps
You now understand the core concepts: logic containers, the property set pipeline, the save pipeline, and how events work together. Before you start coding, read Common Pitfalls to avoid the most frequent mistakes developers make with event-driven logic.
Then dive into each event. See Event reference guide. Each page includes the syntax reference, fluent API options, and real-world scenarios with code.