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):

  1. Stores the new value in the backing field
  2. Marks the property as dirty
  3. Fires Changed events immediately
  4. Marks dependent computed properties as dirty (they'll recalculate on next read)
  5. 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:

  1. HTTP Request Arrives
POST /api/SalesOrders
Content-Type: application/json

{
  "OrderDate": "2025-10-01",
  "Customer": { "Id": "CUST001" },
  "Details": [
    { "Product": { "Id": "PROD123" }, "Quantity": 5 }
  ]
}
  1. Deserialization with Context Injection Where: Benevia.Core.APIBenevia.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.

  1. 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.

  1. Save Changes Triggers PreSave and Validation Where: Benevia.Core.API.EventsBenevia.Core.Events

When SaveChanges() is called, the decorated EventsSaveDataWriter intercepts the call to:

  1. Execute ISaveLogic.Execute() - runs PreSave and Validate events
  2. 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:

  1. Detects Subtotal is dirty (because Details changed)
  2. Executes the compute lambda
  3. Calculates sum of all detail line totals
  4. Updates Subtotal property
  5. 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 OrderDate is empty → sets to today
  • If Status is empty → sets to Open
  1. SaveLogic Execution Pipeline Where: Benevia.Core.Events

The SaveLogic.Execute() method orchestrates the final pre-save logic and validation:

  1. Compute all dirty properties - Recalculates any properties marked as dirty
  2. Fire PreSave events - Applies defaults, auto-corrections, and other pre-save logic
  3. Validate required properties - Ensures all required fields have values
  4. 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.

  1. 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:

  1. TrackedDbContextFactory - Decorates IDbContextFactory<ApiDbContext> to ensure entity state tracking is properly initialized for the Events system
  2. EventsSaveDataWriter - Decorates IDataWriter to execute business logic (PreSave, Validate) before database commits
  3. EventsDataDeleter - Decorates IDataDeleter to 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's IDataContext
  • DbDataTracker - provides entity change tracking from EF Core to Events
  • EventsEntityDeserializingHook - injects EventContext into 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