Core Platform: Architectural Overview
Overview
The Benevia Core platform provides a set of libraries for building event-driven, API-based applications. This document explains how Benevia.Core.API and Benevia.Core.Events work together (or separately) to provide entity business logic, validation, and data persistence.
Note: This document covers the three main libraries. Additional packages build on these foundations to provide specific features like PostgreSQL support (
Benevia.Core.Postgres), blob storage (Benevia.Core.Blobs), workflows (Benevia.Core.API.Workflows), and more. See individual package READMEs for details.This document also assumes knowledge of how Benevia Core is used. See README.md for documentation about how to use Benevia Core.
The Main Libraries
1. Benevia.Core.API - RESTful OData API Layer
Provides infrastructure for exposing entities through HTTP endpoints with OData query capabilities.
Key Responsibilities:
- Controllers (
EntityController<TEntity>) for CRUD operations - OData query support (filtering, sorting, expansion)
- Entity deserialization and serialization
- Database context management (
IDataContext,IDataWriter) via Entity Framework - Multi-tenant authentication
See Benevia.Core.API's ARCHITECTURE.md for more details
2. Benevia.Core.Events - Business Logic Event System
Provides an event-driven framework for implementing business logic that executes automatically during entity lifecycle.
Key Responsibilities:
- Entity lifecycle events (Added, Changed, Deleted, PreSave, Validate)
- Property-level events (Compute, Default, AutoCorrect, Validate)
- Business logic containers (
EntityLogic,PropertyLogic) - Change tracking and state management
- Abstractions for needed implementations (
IDataProvider,IDataTracker) - no knowledge of DB (Entity Framework, etc)
See Benevia.Core.Events's ARCHITECTURE.md for more details
3. Benevia.Core.API.Events - Integration Bridge
Connects the API layer with the Events system, enabling automatic business logic execution during API operations.
Key Responsibilities:
- Wires Events system into API request pipeline
- Provides adapter implementations for Events abstractions
Architecture Overview
Benevia.Core.API
| Service | Description |
|---|---|
EntityController<T> |
Generic OData controller providing CRUD endpoints |
IDataContext / DbDataContext |
Entity querying and tracking |
IDataWriter / DbDataWriter |
Handles database commits |
ApiDbContext |
EF Core DbContext with multi-tenancy |
IEntityDeserializingHook |
Hook for entity creation from HTTP requests |
Benevia.Core.Events
| Service | Description |
|---|---|
LogicContext |
Provides DI services and data access to entities |
EntityLogic<T> |
Holds business logic event handlers for an entity |
PropertyLogic<T> |
Manages property-level events (compute, validate, etc.) |
ISaveLogic / SaveLogic |
Orchestrates business logic execution before save |
IDataProvider |
Abstraction for querying entities |
IDataTracker |
Abstraction for tracking entity changes |
EntityBase |
Base class for entities with event support |
How Business Logic Executes (Generated Code)
Every entity property is generated with getters and setters that trigger business logic:
// Generated property
public partial string Name
{
get => productLogic.Name.GetValue(this, _State.Name);
// ↑ Computes value if any dependent properties are dirty
set => productLogic.Name.SetValue(this, _State.Name, value!);
// ↑ Sets the value and fires changed, also marking property dirty
}
Property Getter (GetValue):
- Returns the current property value
- If property is marked dirty and has a Compute handler, recalculates the value first
- Ensures computed properties are always up-to-date when accessed
Property Setter (SetValue):
- Stores the new value in the backing field
- Marks the property as dirty
- Fires Changed events immediately
- Marks dependent computed properties as dirty (they'll recalculate on next read)
- Triggers related property updates
Example:
// When you do this:
order.Quantity = 10; // SetValue fires
order.Price = 5.99; // SetValue fires
// Then when you read:
var total = order.Total; // GetValue recalculates if Total is dirty
Most business logic executes during property changes, not on save!
Request Flow Example
Let's walk through what happens when a client creates a new SalesOrder:
- HTTP Request Arrives
POST /api/SalesOrders
Content-Type: application/json
{
"OrderDate": "2025-10-01",
"Customer": { "Id": "CUST001" },
"Details": [
{ "Product": { "Id": "PROD123" }, "Quantity": 5 }
]
}
- Deserialization with Context Injection
Where:
Benevia.Core.API→Benevia.Core.API.Events
The OData deserializer creates entity instances and calls IEntityDeserializingHook before setting properties. The Events integration provides a hook (EventsEntityDeserializingHook) that injects the EventContext into each entity, enabling business logic to execute.
As properties are set during deserialization, business logic events (Changed, Compute, etc.) fire immediately via the property setters.
- Controller Adds Entity to DataContext
Where:
Benevia.Core.API
The controller receives the deserialized entity and adds it to EF Core. Most business logic has already executed during deserialization as property values were set. The controller then calls SaveChanges() to trigger PreSave/Validate logic and commit to the database.
- Save Changes Triggers PreSave and Validation
Where:
Benevia.Core.API.Events→Benevia.Core.Events
When SaveChanges() is called, the decorated EventsSaveDataWriter intercepts the call to:
- Execute
ISaveLogic.Execute()- runs PreSave and Validate events - Delegate to the underlying
IDataWriter.Commit()- commits to database
This decoration ensures business logic always runs before database persistence.
Example: Computing SalesOrder.Subtotal
Your business logic class:
// SalesOrderBL.cs
[Logic]
public class SalesOrderBL
{
public void Totals(SalesOrder.Logic salesOrder)
{
salesOrder.Subtotal.Compute()
.Apply(doc => doc.Details.Sum(detail => detail.TotalPrice))
.External(o => o.Details)
.DirtyBy(d => [d.TotalPrice]);
}
}
When SaveLogic runs:
- Detects
Subtotalis dirty (becauseDetailschanged) - Executes the compute lambda
- Calculates sum of all detail line totals
- Updates
Subtotalproperty - Continues to next computed property
Example: Setting Default Values
public void SalesOrderOnSave(SalesOrder.Logic salesOrder)
{
salesOrder.OrderDate.PreSave()
.WhenEmpty()
.Set(o => DateOnly.FromDateTime(DateTime.UtcNow));
salesOrder.Status.PreSave()
.WhenEmpty()
.Set(o => SalesOrderStatus.Open);
}
When SaveLogic runs PreSave events:
- If
OrderDateis empty → sets to today - If
Statusis empty → sets toOpen
- SaveLogic Execution Pipeline
Where:
Benevia.Core.Events
The SaveLogic.Execute() method orchestrates the final pre-save logic and validation:
- Compute all dirty properties - Recalculates any properties marked as dirty
- Fire PreSave events - Applies defaults, auto-corrections, and other pre-save logic
- Validate required properties - Ensures all required fields have values
- Run entity validation - Executes custom validation rules on entities
This ensures all computed values are up-to-date and all validation passes before database commit.
- Database Commit
Where:
Benevia.Core.API
After all business logic succeeds and validation passes, the DbDataWriter commits the transaction:
- Checks for validation errors in
IUserPromptHandler - Aborts if errors exist (returns
false) - Otherwise calls EF Core's
SaveChanges()to persist to database
Key Integration Points
Data Provider Adapter
The Events system needs to query entities using IDataProvider. The API.Events bridge provides a DataProviderAdapter that delegates to the API's IDataContext, allowing the Events library to query related entities when needed (such as for reference/collection property lookups).
Change Tracking Adapter
The Events system needs to know which entities are new, changed, or deleted. The API.Events bridge provides a DbDataTracker that adapts EF Core's change tracker to the IDataTracker interface, giving Events access to entity state information.
Save/Delete Pipeline Decoration
The most critical integration points are three decorators that wrap the data access layer to inject business logic execution:
- TrackedDbContextFactory - Decorates
IDbContextFactory<ApiDbContext>to ensure entity state tracking is properly initialized for the Events system - EventsSaveDataWriter - Decorates
IDataWriterto execute business logic (PreSave, Validate) before database commits - EventsDataDeleter - Decorates
IDataDeleterto execute deletion-related business logic before removing entities
These decorations are registered during service setup via AddCoreApiEvents(), which wires up:
DataProviderAdapter- allows Events to query entities through the API'sIDataContextDbDataTracker- provides entity change tracking from EF Core to EventsEventsEntityDeserializingHook- injectsEventContextinto entities during deserialization- Default data type events - includes built-in business logic for common data types
This architecture ensures business logic always executes at the right points in the request lifecycle without requiring manual intervention.
Entity Model Example
Here's how a typical entity is structured:
// Customer.cs (Model)
[ApiEntity]
[NaturalKey(nameof(Id))]
public partial class Customer : EntityBase, IContactAccount
{
[Required]
[Property<DataTypes.IdText>("Id")]
public partial string Id { get; set; }
[ReferenceProperty<Contact>("Primary contact", ReferenceType.OneToMany)]
public virtual partial Contact? PrimaryContact { get; set; }
[Property<DataTypes.ProperNoun>("Full name",
Technical = "Cached from PrimaryContact.FullName")]
public partial string FullName { get; } // Computed, read-only
}
Inheritance Hierarchy
IEntity (Core interface)
↓
IEventEntity (Events interface - adds context)
↓
EntityBase (Events base class)
↓
Customer (Model entity)
Business Logic
How Business Logic is Registered
Business logic classes are discovered and registered automatically using source generators. Methods in classes marked with [Logic] are analyzed:
Method Signature Pattern:
public void {EntityName}_{EventType}({EntityName}.Logic entity)
The generator creates code that registers these as event handlers on the appropriate EntityLogic<TEntity>.
Dependency Flow
Application Startup
↓
AddCoreApiEvents() ← Sets up everything
↓
├─ AddCoreEvents() ← Registers Events services
│ ├─ LogicContext (scoped)
│ ├─ ISaveLogic → SaveLogic
│ ├─ EntityLogicProvider
│ └─ etc...
│
├─ AddCoreApi() ← Registers API services
│ ├─ IDataContext → DbDataContext
│ ├─ IDataWriter → DbDataWriter
│ └─ ApiDbContext (EF Core)
│ └─ etc...
│
└─ Decorations ← Bridges the two systems
├─ IDataWriter → EventsSaveDataWriter (wraps DbDataWriter)
└─ IDbContextFactory → TrackedDbContextFactory
Benefits of This Architecture
1. Separation of Concerns
- API layer handles HTTP, serialization, routing, data access
- Events layer handles business logic, validation
- Integration layer connects them without coupling
2. Automatic Business Logic Execution
Business logic executes automatically as you work with entities.
3. Declarative Business Logic
Business logic is declared in separate classes, not mixed with entity definitions.
4. Testability
Each library can be tested independently:
- Test API controllers with mock
IDataContext - Test business logic with mock
IDataProvider - Test full integration with in-memory database
5. Extensibility
New entities automatically get:
- CRUD endpoints (from
EntityController<T>) - OData querying
- Event lifecycle management
- Business logic execution