Benevia.Core.Events 0.8.7

There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Benevia.Core.Events --version 0.8.7
                    
NuGet\Install-Package Benevia.Core.Events -Version 0.8.7
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Benevia.Core.Events" Version="0.8.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Benevia.Core.Events" Version="0.8.7" />
                    
Directory.Packages.props
<PackageReference Include="Benevia.Core.Events" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Benevia.Core.Events --version 0.8.7
                    
#r "nuget: Benevia.Core.Events, 0.8.7"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Benevia.Core.Events@0.8.7
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Benevia.Core.Events&version=0.8.7
                    
Install as a Cake Addin
#tool nuget:?package=Benevia.Core.Events&version=0.8.7
                    
Install as a Cake Tool

Events introduction

This is an event driven architecture. For example, when a user enters a product in an invoice, an event is fired which then populates the description.

void ProductEntry(SalesInvoiceDetail.Logic salesInvoiceDetail)
{
    inventoryDocDetail.Default(d => d.Description)
        .OnChange(d => d.Product)
        .From(d => d.Product?.SalesDescription ?? string.Empty);
}

This functionality can be overriden by other modules or customizations.

Events fire on...

  • Change of a property
  • Read of a property
  • Create of an entity
  • Delete of an entity
  • Saving of entities in a data context

Why an event driven architecture?

  1. Event driven architecture works well in an app that has entities and many features. If we can write features in such a way that all the functionality is in one place, we can quickly understand and maintain that feature. However, if the code for a feature is in many different places in the code, then it becomes very difficult to maintain and work together as a team. We call this "Feature in a box" or maybe a more common term that has the same concepts is vertical slice architecture.
  2. Additional features can be added without modifying existing code. This is important for a platform that is extended by many different parties. If we modify existing code, we risk breaking other features. By using events, we can add new functionality without touching existing code.

Example events

The ProductAccessory.Quantity property must be at least 1.

    productAccessory.Validate(a => a.Quantity)
        .RejectIf(a => a < 1.0m)
        .WithMessage("Quantity must be at least 1");

When the SellToCustomer is set, set the PriceLevel property to the customer's PriceLevel.

    salesDoc.Default(o => o.PriceLevel)
        .OnChange(o => o.SellToCustomer)
        .From(o => o.SellToCustomer?.PriceLevel);

The total price of a sales order line is computed. Changing the quantity or unit price marks the total price as dirty so that the next time it is read, it recomputes.

    salesOrderDetail.Compute(d => d.TotalPrice)
        .From(d => d.Quantity * d.UnitPrice)
        .DirtyBy(d => new { d.Quantity, d.UnitPrice });

The quantity cannot be changed when the order is closed.

    salesOrderDetail.Quantity.ReadOnly()
        .If(d => d.Order != null && d.Order.Status == OrderStatus.Closed);

Concepts

You will need to understand the following concepts to work with the event system.

Decorating properties in entities

Properties that decorated with the Property attribute or the ReferenceProperty attribute are tracked by the event system. The Property attribute also assigns a data type to the property. This data type has AutoCorrect and Validate events that are fired when the property value changes.

Example:

[ApiEntity]
public partial class SalesOrder : EntityBase
{
  // ...

  [Virtual]
  [Property<DataTypes.Currency>("Subtotal")]
  public partial decimal Subtotal { get; }

  [ReferenceProperty("Sales order", DeleteAction.Cascade)]
  [OppositeSideCollection("Details", "Order details", CollectionLoadMode.LoadAll, Description = "Sales order details", TechnicalDescription = "Details of an invoice can be both materials or non-materials")]
  public virtual partial SalesOrder? SalesOrder { get; set; }
}

Getting started

  1. Dependencies: Create your project and add the Benevia.Core NuGet package.
dotnet add package Benevia.Core.Events
  1. Usings: Add these usings for your entity classes (You may want to put this in global usings if you have one project for your model).
<Project Sdk="Microsoft.NET.Sdk">
  ...
  <ItemGroup>
    <Using Include="Benevia.Core.Events.DataTypes">
			<Alias>DataTypes</Alias>
    </Using>
    <Using Include="Benevia.Core.Contacts.Model" />
    <Using Include="Benevia.Core.Events" />
    <Using Include="Benevia.Core.Events.Models" />
    <Using Include="Benevia.Core.Annotations" />
    <Using Include="System.ComponentModel.DataAnnotations" />
    <Using Include="System.ComponentModel.DataAnnotations.Schema" />
  </ItemGroup>
</Project>
  1. Model: Create public entity classes and decorate the properties with the Property or ReferenceProperty attribute. (see section on decorating entity properties)
  2. Business logic: Create a public class for defining your business logic. (See section on writing business logic)
  3. Register on startup: Register your model and busines logic in your app's startup. Any project that contains entities or defines data types must register the events.

In your Program.cs

// ...

serviceCollection //usually available via builder.Services
    .AddMyAppBL(); //generated extension from your BL
    .AddCoreEvents(o => o.UseInMemory()); //if using with API: AddCoreApiEvents(...)

// ...

serviceProvider //usually available via app.Services
    .UseBusinessLogicEvents();

// ...

Decorating entity properties for event handling

Attributes are used to decorate properties in your entity classes. The Property attribute is used for simple properties such as string, int, decimal, DateTime, etc. The ReferenceProperty attribute is used properties that reference other entities.

<details> <summary>How does source generation work?</summary>

Source generators automatically generate code via attributes. Source generation is used to create...

  • A partial class for the entity.
  • Implements property setters and getters for the partial properties.
  • Creates logic containers for the class and it's properties.
  • Creates logic adapters for interfaces and it's properties
  • A method to initialize events in your app's startup.

To view the generated code, go to the project that contains your entity classes. Example:

  • Benevia.ERP.Model (Project)
    • Dependencies
      • Analyzers
        • Benevia.Core.Events.Generator.EntityCodeGenerator (Generates files for each entity)
          • Product.g.cs
          • Customer.g.cs
        • Benevia.Core.Events.Generator.EventInitializerGenerator (Generates a single file for initializing events in your program.cs)
          • EntityLogicRegistraion.g.cs
        • Benevia.Core.Events.Generator.InterfaceCodeGenerator (Generates files for each interface)
          • IInventoryDoc.g.cs
          • IContactAccount.g.cs
        • Benevia.Core.Events.Generator.DataTypeCodeGen.DataTypeLogicGenerator (Generates files for each data type)
          • ProductPriceDataType.g.cs
          • ProductQuantityDataType.g.cs

This code is generated when your class's cs file is saved. </details>

<details> <summary>Logic containers</summary>

There are 3 main logic containers:

  • EntityLogic: Contains logic for the entity such as Added, Deleting, PreSave, Validate, etc.
  • PropertyLogic: Contains logic for a specific property such as Validated, Changed, Compute, etc.
  • DataTypeLogic: Contains logic for a specific data type such as AutoCorrect and Validate. </details>

Requirements for entity classes:

Entity classes must be...

  • decorated with the [ApiEntity] attribute.
  • public and partial Entity properties must be...
  • decorated with the [Property] or [ReferenceProperty] attribute.
  • public and partial

Data types

Data types are used in the model to define the type of data in a property because the CLR types of int, string, etc. are not specific enough. Examples: Currency, UnitPrice, Percent, MultiLineText, Email, etc.

Use the Benevia.Core.Events.DataTypes namespace to access common data types. You can also create your own data types.

Functionality of a data type:

  • Data annotations: Multiple data annotations can be applied by specifying one data type. This includes size of strings, decimals, numbers, defaults, and formatting. See System.ComponentModel.DataAnnotations
  • Events: AutoCorrect and Validate events are fired
  • Defaults: Property values can be defaulted
  • Defining data: Business logic code or services can understand and work with data based on its type and annotations. This enables type-aware functionality for UI controls, reporting, integrations, and AI systems.

When should I create a new data type in my model?

Create a new data type when it is a different type of data. Good indicators that you should create a new data type are if any of the above items don't match. You can then inherit from a base data type and add any other data annotations.

See the section on creating custom data types.

Simple propeties

[ApiEntity]
[NaturalKey(nameof(Id))]
public partial class Contact : EntityBase
{
    [Required]
    [Property<DataTypes.IdText>("Id")]
    public partial string Id { get; set; }

    [Property<DataTypes.Enum>("Contact type"
        Description = "Contacts can be a business or a person.")]
    public partial ContactType Type { get; set; }

    [Property<DataTypes.ProperNoun>("Name")]
    public partial string Name { get; set; }

    [Property<DataTypes.PositiveInt>("Age")]
    public partial string Age { get; set; }

    [Property<DataTypes.Phone>("Primary Phone")]
    public partial string Phone { get; set; }

    [Property<DataTypes.MultilineText>("Note")]
    public partial string Note { get; set; }

Adding more data annotations

Additional data annotations can be added to the property as needed. By default, the text data type has a max length of 100 characters. If you need a different length, you can specify it with the MaxLength attribute. This will override the data type's max length.

    [MaxLength(80)]
    [Property<DataTypes.Text>("Description")]
    public partial string Description { get; set; }

Note on the DataTypes.Text data type: This is a general purpose text data type. It has a max length of 100 characters. You should normally specify the max length.

Setting the natual key

This is specified on the class with the NaturalKey attribute. You can specify one property as a natural key. The natural key is used to identify an entity in a user friendly way.

The natual key is...

  • a string type
  • unique for the entity type.
  • used to get an entity. All entitites also have a unique identifier (GUID).
  • Setting a reference property with OData.
  • should normally be a required property.
[ApiEntity]
[NaturalKey(nameof(Name))]
public partial class ShippingMethod
{
    [Required]
    [MaxLength(20)]
    [Property<DataTypes.Text>("Name")]
    public partial string Name { get; set; }
}

Virtual properties

Virtual properties are...

  • decorated with the [Virtual] attribute.
  • not persisted.
  • often used for computed values.
  • dirty on load. This means that the first time they are read, they will compute their value.
  • not only read-only. They can have a setter. For example, the full name can be set. A Changed subscriber can split the name into first and last name.
    [Virtual]
    [Property<DataTypes.ProperNoun>("Full Name")]
    public partial string FullName { get; }

Example compute and change with virtual property:

    contact.Compute(c => c.FullName)
        .From(c => $"{c.FirstName} {c.LastName}")
        .DirtyBy(c => new { c.FirstName, c.LastName });
    contact.OnChanged(c => c.FullName)
        .Do(c => 
        {
            var parts = c.FullName?.Split(' ', 2);
            if (parts?.Length == 2)
            {
                c.FirstName = parts[0];
                c.LastName = parts[1];
            }
        });

Note: A Compute/Changed subscriber pair will not be cyclic.

Required properties

Required properties enforce mandatory field validation and prevent entities from being saved with missing critical data. When a required property is empty or null, the system automatically generates validation errors. Required properties cannot have default values - they must be explicitly set by the user. Here is an example of a required property:

Note: A required property can be nullable because it may be null until it is saved.

Remember to make properties required when...

  • This is the Id or name for the entity and without it, the entity can't be identified. Examples: Customer.Id, SalesOrder.DocNumber, or PriceLevel.Name.
  • This is a reference to a parent entity in a parent-child relationship.
[ApiEntity]
public partial class Customer : EntityBase
{
  // ...

  [Required]
  [Property<DataTypes.ProperNoun>("Company Name")]
  public partial string CompanyName { get; set; }
}

Default values

Default values can be set for properties using the DefaultValue attribute.

Note: Do NOT use subscribers such as Default or Added to set default literal static values. Subscribers are for setting dynamic values based on other data or the environment.

    [DefaultValue(1)]
    [Property<DataTypes.Decimal>("Factor",
        Description = "The factor to use in the operation. For example, if the operation is multiply and the value is 12, then 1 case = 12 ea")]
    public partial decimal Factor { get; set; }

Reference and collection properties (Navigation properties)

Reference properties are used to create relationships between entities. They are decorated with the ReferenceProperty attribute.

When defining a reference property, you must specify the delete action. By default, the reference type is OneToMany. You can change this to OneToOne if needed.

    [ReferenceProperty("Image", DeleteAction.SetToNull, ReferenceType.OneToOne,
        Description = "An image of the product")]
    public virtual partial Blob? Image { get; set; }

Many times you will also want to create a collection property on the opposite side of the relationship. This is done with the OppositeSideCollection attribute.

    [Required]
    [ReferenceProperty("Customer", DeleteAction.Restrict,
        Description = "The customer that is buying. This may not be the account that is being billed.")]
    [OppositeSideCollection("Orders", "Sales orders", CollectionLoadMode.Paged, Delete)]
    public virtual partial Customer? SellToCustomer { get; set; }

Delete action on reference properties

This defines delete behavior when the referenced entity is being deleted. There are 3 options. Examples:

  1. Restrict: I am defining SalesOrderDetail.Product, this defines the behavior when the Product is deleted. DeleteAction.Restrict is the best option because I do not want the product deleted if it is on a sales order.
  2. Cascade: I am defining SalesOrderDetail.SalesOrder, this defines the behavior when the SalesOrder is deleted. DeleteAction.Cascade is the best option because I want to delete the sales order details when I am deleting a sales order.
  3. SetNull: I am defining SalesOrderDetail.Uom, this defines the behavior when the Uom is deleted. DeleteAction.SetNull is the best option because I want to allow a UOM to be deleted anytime even if it is referenced by the sales order detail.

Examples with delete action

public partial class SalesDocDetail : EntityBase
{
    [Required]
    [ReferenceProperty("Sales order", DeleteAction.Cascade)]
    [OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll, Description = "Sales order details", TechnicalDescription = "Details of an invoice can be both materials or non-materials")]
    public virtual partial SalesOrder? SalesOrder { get; set; }

    [ReferenceProperty("Product", DeleteAction.Restrict)]

    Product? Product { get; set; }

    [ReferenceProperty("Unit", DeleteAction.SetNull)]
    ProductUom? Unit { get; set; }
}

OppositeSideCollection Attribute & CollectionLoadMode

The OppositeSideCollection attribute is used to define relationships between entities. It specifies the collection property on the opposite side of the relationship and how the collection should be loaded. This is very important for performance.

CollectionLoadMode options:

  • LoadAll (Observable collections): Loads all related entities when accessed (lazy loading). It should only be used when you are sure that the number of related entities is small (normally less than 100).
  • Paged (Queryable collections): Gets related entities as Queryable when accessed and loads them in pages. Example Product.OrderDetails.

Observable collections

ObservableCollections are entity collection properties that automatically track changes and raise events when items are added, removed, or modified. This enables event-driven business logic and change tracking for related entities. Use ObservableCollections for scenarios where you need to react to collection changes or synchronize state.

Note: Observable collections are often used in documents where there is a parent entity and child entities. In these cases, you often do the following:

  • Set the delete action to cascade
  • Set it as required
  • Have an opposite side collection.

Example: SalesOrder.Details

[ApiEntity]
public partial class SalesOrderDetail : EntityBase, ISalesDocDetail
{
    [Required]
    [ReferenceProperty("Sales order", DeleteAction.Cascade)]
    [OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll, Description = "Sales order details", TechnicalDescription = "Details of an invoice can be both materials or non-materials")]
    public virtual partial SalesOrder? SalesOrder { get; set; }

Queryable collections

QueryableCollections provide LINQ-style querying capabilities over entity collections. They allow you to filter, sort, and project related entities efficiently, often backed by database queries for performance. Use this with collections that may contain a large number of items. For example, Customer.Orders.

Example: Product.SalesOrderDetails

[ApiEntity]
public partial class OrderDetail
{
    [Property<int>("Quantity")]
    public partial int Quantity { get; set; }

    [ReferenceProperty("Product", DeleteAction.SetNull)]
    [OppositeSideCollection("Details", "SalesOrderDetails", CollectionLoadMode.Paged)]
    public virtual partial Product? Product { get; set; }
}

Generic Interfaces for Shared Behavior

Generic interfaces enable you to define common behavior across multiple entity types while maintaining type safety in relationships. This is particularly useful for implementing shared patterns like document/detail hierarchies, where different types of documents (Sales Orders, Purchase Orders, Invoices) share common properties and business logic.

Why Use Generic Interfaces?

  • Code Reuse: Define common business logic once in the interface, which is automatically inherited by all implementing classes
  • Type Safety: Generic constraints ensure type-safe relationships between parent and child entities
  • Maintainability: Changes to shared behavior only need to be made in one place
  • Extensibility: New document types can easily implement existing interfaces to gain all shared functionality
  • Consistency: Ensures all implementing entities follow the same patterns and rules

Defining Generic Interfaces

Basic Interface (Parent Entity):

public partial interface IDoc
{
    [Property<DataTypes.Text>("Doc Number")]
    string DocNumber { get; set; }

    [Property<DataTypes.Text>("Description")]
    string Description { get; set; }
}

Generic Interface (Child Entity):

public partial interface IDocDetail<TDoc> where TDoc : IDoc
{
    [Property<DataTypes.Decimal>("Quantity")]
    decimal Quantity { get; set; }

    [Property<DataTypes.Text>("SKU")]
    string SKU { get; set; }

    [Property<DataTypes.Decimal>("Unit Price")]
    decimal UnitPrice { get; set; }

    [Property<DataTypes.Decimal>("Total Price")]
    decimal TotalPrice { get; set; }

    [ReferenceProperty("Doc", DeleteAction.Cascade)]
    [OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll)]
    TDoc Doc { get; set; }
}

Note the generic constraint where TDoc : IDoc - this ensures that the Doc property references an entity that implements IDoc, providing type safety.

Implementing Generic Interfaces

Concrete Parent Entity:

[ApiEntity]
public partial class SalesOrder : IDoc
{
    [Property<DataTypes.Text>("Customer")]
    public partial string Customer { get; set; }

    [Property<DataTypes.Decimal>("Total Price")]
    public partial decimal TotalPrice { get; set; }

    [Property<DataTypes.Enum>("Status")]
    public partial SalesOrderStatus Status { get; set; }
}

Concrete Child Entity:

[ApiEntity]
public partial class SalesOrderDetail : IDocDetail<SalesOrder>
{
    // All properties from IDocDetail<SalesOrder> are automatically included
    // The Doc property is specifically typed as SalesOrder
}

This creates a fully type-safe relationship where SalesOrderDetail.Doc is specifically a SalesOrder, not just any IDoc.

Business Logic for Generic Interfaces

Business logic can be defined directly on interfaces, and it will automatically apply to all implementing classes.

Logic for Parent Interface:

[Logic]
public class IDocBL(IDoc.Logic doc)
{
    [RegisterLogic]
    public void ComputeDescription()
    {
        doc.Compute(d => d.Description)
            .If(d => !string.IsNullOrEmpty(d.DocNumber))
            .From(d => $"Document {d.DocNumber}")
            .DirtyBy(d => d.DocNumber);
    }

    [RegisterLogic]
    public void ValidateDocNumber()
    {
        doc.Validate(d => d.DocNumber)
            .RejectIf(num => string.IsNullOrWhiteSpace(num))
            .WithMessage("Document number is required");
    }
}

Logic for Generic Child Interface:

[Logic]
public class IDocDetailBL<IDoc>(IDocDetail_Test<IDoc>.Logic detail) where IDoc : IDoc_Test
{
    [RegisterLogic]
    public void ComputeTotalPrice()
    {
        detail.Compute(d => d.TotalPrice)
            .From(d => d.Quantity * d.UnitPrice)
            .DirtyBy(d => new { d.Quantity, d.UnitPrice });
    }

    [RegisterLogic]
    public void ValidateQuantity()
    {
        detail.Validate(d => d.Quantity)
            .RejectIf(qty => qty <= 0)
            .WithMessage("Quantity must be greater than zero");
    }

    [RegisterLogic]
    public void ValidateDoc()
    {
        detail.Validate(d => d.Doc)
            .RejectIf(doc => doc == null)
            .WithMessage("Document is required");
    }

    [RegisterLogic]
    public void OnSaveDocDetail()
    {
        detail.ValidateOnSave()
            .RejectIf((d, ctx) =>
            {
                var doc = d.Doc;
                return doc == null;
            })
            .WithMessage("Document is required");
    }
}

Logic for Concrete Classes:

[Logic]
public class SalesOrderBL(SalesOrder.Logic order)
{
    [RegisterLogic]
    public void ComputeTotalPrice()
    {
        order.Compute(o => o.TotalPrice)
            .From((o, args) =>
            {
                var details = args.GetEntities<SalesOrderDetail>()
                    .Where(d => d.Doc != null && d.Doc.Guid == o.Guid);
                return details.Sum(d => d.TotalPrice);
            });
    }

    [RegisterLogic]
    public void ValidateCustomer()
    {
        order.Validate(o => o.Customer)
            .RejectIf(cust => string.IsNullOrWhiteSpace(cust))
            .WithMessage("Customer is required");
    }
}

Logic Inheritance and Execution Order

When using generic interfaces, business logic is applied in this order:

  1. Interface logic (from IDoc or IDocDetail<IDoc>)
  2. Concrete class logic (from SalesOrder or SalesOrderDetail)

This means:

  • Common validations from the interface apply to all implementing classes
  • Concrete class logic can extend or add additional rules
  • Both sets of logic execute - they are cumulative, not overriding

Example: When saving a SalesOrder:

  1. IDoc.DocNumber validation runs (from IDocBL)
  2. IDoc.Description computation runs (from IDocBL)
  3. SalesOrder.Customer validation runs (from SalesOrderBL)
  4. SalesOrder.TotalPrice computation runs (from SalesOrderBL)

Multiple Document Types with Shared Logic

This pattern allows you to create multiple document types that share common logic:

// Another parent entity implementing IDoc
[ApiEntity]
public partial class PurchaseOrder : IDoc
{
    [Property<DataTypes.Text>("Vendor")]
    public partial string Vendor { get; set; }

    [Property<DataTypes.Decimal>("Total Price")]
    public partial decimal TotalPrice { get; set; }
}

// Another child entity implementing IDocDetail
[ApiEntity]
public partial class PurchaseOrderDetail : IDocDetail<PurchaseOrder>
{
    // Automatically gets all IDocDetail properties
    // Doc property is specifically typed as PurchaseOrder
}

Both SalesOrder and PurchaseOrder:

  • Automatically get DocNumber and Description properties from IDoc
  • Inherit the description computation and doc number validation from IDocBL
  • Can define their own specific logic in their respective BL classes

Both SalesOrderDetail and PurchaseOrderDetail:

  • Automatically get all properties from IDocDetail<T>
  • Inherit total price computation and quantity validation from IDocDetailBL
  • Have type-safe references to their specific parent types
  • Can define their own specific logic

Common Patterns

Parent-Child Document Pattern:

// 1. Define interfaces
public partial interface IInvoice
{
    string InvoiceNumber { get; set; }
    DateTime InvoiceDate { get; set; }
    decimal Total { get; set; }
}

public partial interface IInvoiceLine<TInvoice> where TInvoice : IInvoice
{
    TInvoice Invoice { get; set; }
    decimal Quantity { get; set; }
    decimal UnitPrice { get; set; }
    decimal LineTotal { get; set; }
}

// 2. Implement concrete classes
[ApiEntity]
public partial class SalesInvoice : IInvoice { /* ... */ }

[ApiEntity]
public partial class SalesInvoiceLine : IInvoiceLine<SalesInvoice> { }

// 3. Define shared business logic
[Logic]
public class IInvoiceLineBL(IInvoiceLine<IInvoice>.Logic line)
{
    [RegisterLogic]
    public void ComputeLineTotal()
    {
        line.Compute(l => l.LineTotal)
            .From(l => l.Quantity * l.UnitPrice)
            .DirtyBy(l => new { l.Quantity, l.UnitPrice });
    }
}

Best Practices

  1. Use Clear Naming Conventions:

    • Prefix interfaces with I (e.g., IDoc, IDocDetail)
    • Use descriptive generic parameter names (e.g., TDoc not just T)
  2. Define Appropriate Constraints:

    • Always use where TDoc : IBaseInterface to ensure type safety
    • This prevents implementation errors at compile time
  3. Separate Common and Specific Logic:

    • Put truly shared logic in interface BL classes
    • Put entity-specific logic in concrete class BL classes
    • Don't mix concerns
  4. Consider Performance:

    • Use appropriate CollectionLoadMode for collections
    • Be mindful of N+1 queries when computing aggregates
    • Cache frequently accessed computed values when appropriate
  5. Document Relationships:

    • Clearly document which interfaces an entity implements
    • Explain the purpose of each generic parameter
    • Note any assumptions or constraints

Testing Generic Interfaces

When testing entities that implement generic interfaces, verify that:

  1. Interface logic applies correctly:
[Fact]
public void BothDocuments_ShouldShareCommonInterfaceLogic()
{
    var salesOrder = new SalesOrder(context)
    {
        DocNumber = "SO-001",
        Customer = "Test Customer"
    };

    var purchaseOrder = new PurchaseOrder(context)
    {
        DocNumber = "PO-001",
        Vendor = "Test Vendor"
    };

    // Both should get description from IDocBL
    Assert.Equal("Document SO-001", salesOrder.Description);
    Assert.Equal("Document PO-001", purchaseOrder.Description);
}
  1. Type-specific logic works:
[Fact]
public void SalesOrder_ShouldValidateCustomer()
{
    var order = new SalesOrder(context)
    {
        DocNumber = "SO-001",
        Customer = "" // Invalid
    };

    var saved = context.SaveChanges();
    Assert.False(saved);
    Assert.Contains(prompts.Messages, p => p.Text.Contains("Customer is required"));
}
  1. Child entities have correct parent type:
[Fact]
public void SalesOrderDetail_ShouldReferenceCorrectDocType()
{
    var order = new SalesOrder(context) { /* ... */ };
    var detail = new SalesOrderDetail(context)
    {
        Doc = order, // Type-safe: Doc is specifically SalesOrder
        Quantity = 2,
        UnitPrice = 10.00m
    };

    Assert.Same(order, detail.Doc);
    Assert.IsType<SalesOrder>(detail.Doc);
}

Advanced Scenarios

Multiple Interface Implementation:

[ApiEntity]
public partial class Invoice : IDoc, IAuditable, IApprovable
{
    // Gets properties and logic from all three interfaces
}

Nested Generics:

public partial interface IDocDetailWithTax<TDoc, TTax> 
    where TDoc : IDoc
    where TTax : ITaxCalculation
{
    TDoc Doc { get; set; }
    TTax TaxCalculation { get; set; }
}

Conditional Logic Based on Implementation:

[Logic]
public class IDocBL(IDoc.Logic doc)
{
    [RegisterLogic]
    public void CustomLogic()
    {
        doc.OnAdded().Do((d, args) =>
        {
            // Logic can check actual implementation type if needed
            if (d is SalesOrder salesOrder)
            {
                // Sales-specific logic
            }
            else if (d is PurchaseOrder purchaseOrder)
            {
                // Purchase-specific logic
            }
        });
    }
}

Entity state

The State module tracks changes to entities and their properties during business logic execution.

customer._State.FullName.IsDirty; //If true, the FullName property will fire compute events when read.
customer._State.OriginalValue //Get original value of the property before any changes.
customer._State.ValidValue //The last valid value of the property. When reading a property, this is the value that is returned.
customer._State.ProposedValue //The proposed value of the property that has not yet been validated. 

Events

Events define the core business logic operations that can be triggered during property changes.

Logic containers

Logic containers contain the business logic for an entity or data type. Each entity or data type has a corresponding logic container class that holds its business logic methods. This is a singleton class that is instantiated once per application lifetime.

  • The logic container class is generated automatically based on the entity in the model. If the entity defined in the model is Contact, the logic container class will be Contact.Logic.
  • When the app starts up, logic is registered. This is done in a business logic class that is decorated with the [Logic] attribute and it can have one or more logic container parameters. Event methods must be decorated with the [RegisterLogic] attribute.

Example

[Logic]
public class ContactBL(Contact.Logic contact)
{
    [RegisterLogic]
    public void ContactBL()
    {
        // This method runs on startup. Add logic to the contact logic container here.
    }
}

Event Args and Service Injections

Most event handlers provide access to an args parameter (inheriting from EventArgs). This base class provides convenient methods to access the EventContext and resolve services:

  • args.Context: The current EventContext.
  • args.GetService<T>(): Gets a service from the scoped service provider.
  • args.GetEntities<T>(): Gets a queryable for other entities.
  • args.GetEntity<T>(id/naturalKey): Retrieves a specific entity.

Service Injection Example

    contact.FullName.Compute()
        .From((p, args) => 
        {
            var fullNameProvider = args.GetService<IFullNameProvider>();
            return fullNameProvider.GetFullName(p.FirstName, p.LastName);
        })
        .DirtyBy(p => new { p.FirstName, p.LastName });

Most event logic methods (such as Compute, Validate, Default, etc.) accept an args parameter in their lambda expressions. This parameter provides access to DI services, allowing you to resolve and use services directly within your event logic.

Example:

    public void ComputeFullName(ISPerson.Logic person)
    {
        person.FullName.Compute()
            .If((p, args) =>
            {
                var validator = args.GetService<IValueValidatorService>();
                return !validator.IsNullOrEmpty(p.FirstName) && !validator.IsNullOrEmpty(p.LastName);
            })
            .From((p, sp) =>
            {
                var fullNameProvider = sp.GetService<IFullNameProvider>();
                return fullNameProvider.GetFullName(p.FirstName, p.LastName);
            });
    }

In this example, args.GetService<T>() is used to resolve services needed for validation and computation. This pattern is supported in all event types, making your business logic extensible and testable.

What triggers events?

Source generation of the model inserts these methods into entity property getters and setters. This fires all property events.

  • PropertyLogic.SetValue()
  • PropertyLogic.GetValue()

Event services fire these entity events.

  • OnAdded - When a new entity is created in the data context.
  • Deleting - When an entity is deleted in the data context.
  • PreSave - When a data context is saved, this event fires for all modified entities in the context.
flowchart
  A[Set property] --> B{ReadOnly?}
  B -->|Yes| C[Add error message & abort]
  B -->|No| D[Data type AutoCorrect]
  D --> E[Property AutoCorrect] --> F[Data type Validate] --> G[Property Validate] --> H[Dirty dependent properties] --> I[Changed] --> J[Changed on dirtied properties]
flowchart
  A[Get property] --> B{Is dirty?}
  B -->|Yes| C[Compute]
  B -->|No| D[Return previously computed value]

Property events

  • AutoCorrect: Automatically correct or transform property values when they are set or changed.
  • Validate: Defines validation rules that check property values and generate error messages when validation fails.
  • Default: Provides logic for setting dynamic default values when another property is set. This uses the same mechanism as Compute.
  • Compute: Defines how property values are calculated when they are dirty.
  • ReadOnly: Controls write access to properties based on business rules and entity state.
  • Changed: Runs logic when a property value changes.

AutoCorrect Event

AutoCorrectEvent allows you to automatically correct or transform property values when they are set or changed. This is useful for enforcing formatting rules (such as uppercasing country codes) or cleaning up user input.

Example:

person.CountryCode.AutoCorrect().Transform(v => v?.ToUpper());

This ensures that the CountryCode property is always stored in uppercase, and safely handles null values.

Validate Event

Defines validation rules that check property values and generate error messages when validation fails. Each event contains the validation logic and the error message to display.

Example:

contact.Age.Validate()
    .RejectIf(a => a < 0 || a > 120)
    .WithMessage("Age must be between 0 and 120.");

Compute Event

ComputeEvent defines how property values are calculated based on other properties or business rules. It contains the expression logic and optional conditions for when the computation should run.

Dirtying: When a property is marked as dirty, it indicates that its value may be out of date and needs to be recomputed the next time it is read. This is typically done by specifying which other properties, when changed, should mark the computed property as dirty.

Simple compute

student.FullName.Compute()
    .From(s => $"{s.FirstName} {s.LastName}")
    .DirtyBy(s => new { s.FirstName, s.LastName });

Compute from another related entity

This computes the total price of a sales order by summing the total prices of its details. It marks the subtotal as dirty when any detail is added, removed, or when a detail's total price changes.

    salesOrder.Subtotal.Compute()
        .From(doc => doc.Details.Sum(detail => detail.TotalPrice))
        .DirtyWithRelation(o => o.Details)
        .DirtyBy(d => d.TotalPrice);

Compute and Changed subscriber pair A Compute/Changed subscriber pair will not be cyclic. The Changed subscriber will not fire when the property is set in the Compute subscriber.

    inventoryDocDetail.TotalPrice.Compute()
        .From(d => d.Quantity * d.UnitPrice)
        .DirtyBy(d => new { d.Quantity, d.UnitPrice });
    inventoryDocDetail.TotalPrice.OnChanged()
        .Do(d =>
        {
            if (d.Quantity == 0 && d.TotalPrice != 0)
                d.Quantity = 1;
            if(d.Quantity != 0)
                d.UnitPrice = d.TotalPrice / d.Quantity;
        });

ReadOnly Event

ReadOnlyEvent provides access control for properties based on business rules. When a property is marked as read-only, when attempting to set a read-only property the framework throws an exception (InvalidOperationException). Callers that may set a property conditionally should first check the property's state (for example _State.MyProperty.IsReadOnly) before writing.

Key characteristics:

  • Read-only checks are evaluated when attempting to write to a property, not when reading
  • Multiple subscribers can define read-only conditions; if any subscriber returns true, the property becomes read-only
  • Read-only state is tracked on the property state (PropertyState.IsReadOnly)
  • Performance note: Read-only conditions are evaluated every time the property is set and whenever the API retrieves read-only properties—potentially on every request. Your condition must therefore be extremely fast. Do not perform database calls inside If(). If you need database data, compute it once in a computed property and reference that instead.

Simple read-only examples:

Read-only based on related entity properties — make the unit price read-only when the order is shipped or closed:

salesOrderDetail.UnitPrice.ReadOnly()
    .If(d => d.Order != null && (d.Order.Status == OrderStatus.Shipped || d.Order.Status == OrderStatus.Closed));

Multiple read-only conditions A property can have multiple read-only subscribers. If any condition evaluates to true, the property becomes read-only:

// Read-only when order is closed
salesOrderDetail.Quantity.ReadOnly()
    .If(d => d.Order != null && d.Order.Status == OrderStatus.Closed);

// Also read-only when the detail itself is shipped
salesOrderDetail.Quantity.ReadOnly()
    .If(d => d.IsShipped);

Checking read-only state in business logic You can check if a property is read-only before attempting to modify it:

var quantityState = orderDetail._State.Quantity.IsReadOnly;
if (!quantityState.IsReadOnly)
{
    orderDetail.Quantity = newValue;
}

Error handling

  • Attempting to set a read-only property throws InvalidOperationException with a message that guides the developer to check _State.<Property>.IsReadOnly before writing
  • The property value remains unchanged when a write is attempted and the exception is thrown

Use cases:

  • Prevent modifications to finalized or closed documents
  • Lock fields after shipment or invoicing
  • Enforce business rules like "can't change quantity after items are shipped"

Default Event

DefaultEvent provides logic for setting default values for entity properties.

NOTE: Do NOT use the Default event to default literal static values. Subscribers are for setting dynamic values based on other data or the environment. Use the DefaultValue attribute for static default values.

Example:

salesOrderDetail.Description.Default()
    .OnChange(d => d.Product)
    .From(d => d.Product?.SalesDescription ?? string.Empty);

Entity events

  • Added: Runs logic when a new entity is created in the data context.
  • Deleting: Runs logic when an entity is deleted from the data context.
  • PreSave: Runs logic just before an entity is saved.
  • Validate: Validates the entire entity before saving.

Added Event

AddedEvent runs logic when a new entity is created in the data context. It is typically used to set default values or initialize properties.

NOTE: Do NOT use the Added event to default literal static values. Subscribers are for setting dynamic values based on other data or the environment. Use the DefaultValue attribute for static default values.

    customer.OnAdded().Do((c, args) =>
    {
        var context = args.GetService<EventContext>();
        var welcomeEmail = new AEmail(context)
        {
            From = "noreply@benevia.software",
            To = $"{c?.Guid.ToString()}@gmail.com",
        };
    });

Deleting Event

DeletingEvent runs logic when an entity is deleted from the data context. It is used to...

  • determine if an entity may be deleted.
  • conditionally delete related entities along with this entity.

NOTE: Do NOT use the Deleting event to delete related entities when there is no associated logic. Instead, use the DeleteAction on the ReferenceProperty attribute to specify how related entities should be handled.

    productUom.OnDeleting()
        .AbortIf((u, a) => u.Operation == UomOperation.Main && a.OriginalTriggeringEntity != u.Product)
    .WithMessage("Main UOM can not be deleted");

For conditionally deleting related entities, use the DeleteAdditional method to return a list of entities to delete along with this entity.

    productUom.OnDeleting()
        .DeleteAdditional((u, a) =>
        {
            if (u.Operation == UomOperation.Main && a.OriginalTriggeringEntity != u.Product)
            {
                var mainUom = u.Product?.Uoms.FirstOrDefault(u => u.Operation == UomOperation.Main && u.Guid != u.Guid);
                if (mainUom != null)
                {
                    return [mainUom];
                }
            }
            return [];
        })

If any entity may not be deleted, the entire delete operation is aborted.

Deleted Event

DeletedEvent runs logic after an entity has been deleted from the data context. It is typically used for logging or cleanup operations. It is not possible to cancel the deletion at this point.

NOTE: Do NOT use the Deleted event to delete related entities. Use OnDeleting's DeleteAddtional method.

    productUom.OnDeleted()
        .Do((c) =>
        {
             if (c.Entity.SellableOption == SellableOption.DefaultSellingUnit)
             {
                 var mainUom = c.Entity.Product?.Uoms.FirstOrDefault(u => u.Operation == UomOperation.Main);
                 if (mainUom != null)
                 {
                     mainUom.SellableOption = SellableOption.DefaultSellingUnit;
                 }
             }
        });

PreSave Event

PreSaveEvent runs logic just before an entity is saved. It is typically used to ensure required properties are set or to apply final transformations.

Example:

student.FullName.PreSave()
    .WhenEmpty()
    .Set(s => `${s.FirstName} ${s.LastName}`.Trim());

This ensures FullName is set with appropriate values before saving.

Entity Validate Event

EntityValidateEvent (see EntityValidator) validates entities before saving, checking required properties and applying custom validation logic. It adds error messages for missing or invalid data.

Example:

 student.Validate().WhenChanged(s => new { s.FirstName, s.LastName })
            .If(s => string.IsNullOrWhiteSpace(s.FullName))
            .Message("Full name can not be empty when first name or last name is changed.");

This validates FullName when on saving if FirstName or LastName changed.

DataType Events

DataType events provide specialized business logic for properties with specific data types, such as email, phone number, or currency. These events enable you to enforce type-specific validation, formatting, and correction rules in a reusable way.

DataType Validate Event

The DataTypeValidateEvent allows you to define validation rules for properties based on their data type. This event is triggered automatically when a property value changes.

Example:

 public void StudentFullName_Validate(StartsWithUpperDT.Logic dataType)
 {
     dataType.Validate()
        .RejectIf(x => !IsFirstCharUpper(x!))
        .WithMessage("The first character must be uppercase.");
 }

DataType AutoCorrect Event

The DataTypeAutoCorrectEvent enables you to automatically correct or transform property values based on their data type. This is useful for normalizing input, such as trimming whitespace, converting to uppercase, or applying custom formatting.

 public void StudentFirstNameAndLastName_Autocorrect(CapitalizeFirstCharDT.Logic dataType)
 {
     dataType.AutoCorrect().Transform(val => char.ToUpper(val[0]) + val.Substring(1));
 }

Writing tests

You can write unit tests for your business logic using any testing framework.

Test are normally written against the business logic class. You can create entities and set properties to test your logic. This uses an EventContext which creates a context in memory.

Recommendation: Use xunit.v3 and Xunit.DependencyInjection with a Startup.cs to configure a dependency injection container for tests. This allows for injecting services (such as EventContext) into your tests.

See the Benevia Core Demo project for examples.

public class CustomerBLTests(EventContext context)
{
    [Fact]
    public void CustomerTitleTest()
    {
        var contact = new Contact(context)
        {
            FirstName = "John",
            LastName = "Doe",
            MailingAddress = new Address(context)
            {
                Street = "5342 Main ST",
                City = "Pittsburg",
                State = "PA",
                PostalCode = "65475"
            }
        };

        var customer = new Customer(context)
        {
            Id = "DOEJOH",
            PrimaryContact = contact,
        };
        Assert.Equal("John Doe", customer.Title);

        contact!.FirstName = "Jane";
        contact!.LastName = "Smith";
        Assert.Equal("Jane Smith", customer.Title);
    }
}

Methods

Methods are defined on entities with [Method] and subscribed in business logic through a selector API.

Defining Methods

You can use:

  • normal C# scalar parameters
  • optional parameters with defaults
  • one parameter class (still supported)
  • parameterless methods
  • static methods
[ApiEntity]
public partial class SalesOrder
{
    [Method("Calculate line total", MethodType.Read)]
    public partial decimal CalculateLineTotal(decimal taxRate, int quantity, decimal discount = 0m);

    [Method("Apply discount", MethodType.Modify)]
    public partial void ApplyDiscount(DiscountParams parameters); // class style still supported

    [Method("Get default markup", MethodType.Read)]
    public partial decimal GetDefaultMarkup();

    [Method("Get all open orders", MethodType.Read)]
    public static partial IQueryable<SalesOrder> GetOpenOrders(string customerId, bool includeBlocked);
}

Implementing Method Logic

Usage:

entity.Method(e => e.CalculateLineTotal).Do((order, args) =>
{
    var subtotal = order.UnitPrice * args.quantity;
    var discounted = subtotal - (subtotal * args.discount);
    return discounted + (discounted * args.taxRate);
});

args is method-specific and inherits MethodEventArgs:

  • args.<ParameterName> for each method parameter
  • args.Context
  • args.GetService<T>()

Example with service access:

entity.Method(e => e.SetCostFromScalars).Do((order, args) =>
{
    var dataContext = args.GetService<IDataContext>();
    order.Cost = args.newCost + args.adjustment;
    dataContext.SaveChanges();
});

Static Methods

Static methods are supported with the same [Method] attribute.

Subscriber style for static methods:

entity.Method(e => SalesOrder.GetOpenOrders).Do(args =>
{
    return args.GetEntities<SalesOrder>()
        .Where(o => o.CustomerId == args.customerId);
});

Calling Methods via OData

Methods are exposed on /api/{Entity}({id})/{Method} and parameters are query-based.

Read (GET):

GET /api/SalesOrder({orderId})/CalculateLineTotal?TaxRate=0.08&Quantity=5

Modify (POST):

POST /api/SalesOrder({orderId})/SetCostFromScalars?NewCost=20.00&Adjustment=2.50

Also supported route variant:

GET /api/SalesOrder({orderId})/Default.CalculateLineTotal?TaxRate=0.08&Quantity=5

Static read method routes:

GET /api/SalesOrder/GetOpenOrders?customerId=C100&includeBlocked=false
GET /api/SalesOrder/Default.GetOpenOrders?customerId=C100&includeBlocked=false

Required vs Optional

  • Required: non-nullable and no explicit default.
  • Optional: nullable or has default value.

Missing required parameters return 400 BadRequest with a clear message naming the parameter.

Parameterless Methods

Parameterless methods are supported and use the same selector subscription pattern:

entity.Method(e => e.GetDefaultMarkup).Do((product, args) => 1.5m);
entity.Method(e => e.ResetToDefaultCost).Do((product, args) => product.Cost = 10m);

Note: The () is only required for GET requests (Read methods) to indicate it's a function call. POST requests (Modify methods) work as actions and don't need the parentheses.

Creating custom data types

You can create your own data types by inheriting from a base data type and adding any additional data annotations.

[DataAnnotations("Text is unlimited and has many lines",
    ["MaxLength"]
)]
public partial class MultilineText : Text
{
}

Inheritance

When you inherit from a base data type, the data annotations are cumulative. For example, if you create a Percent data type that inherits from Decimal, it will have all the data annotations of Decimal plus any you add to Percent such as formatting. You must define the class as partial.

Note on logic: At this point logic does not inherit but it is something we have considered.

[DataAnnotations("Percentage displays a decimal as a percent",
    ["DisplayFormat(DataFormatString = \"{0:P2}\")"]
)]
public partial class Percent : Decimal
{
}

You can override attributes. For example, if you inherit from Text which has a MaxLength of 100 and then add a MaxLength(20) annotation your derived data type, the effective MaxLength will be 80.

More Info

For more information about the architecture of Benevia.Core.Events, see ./ARCHITECTURE.md.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (6)

Showing the top 5 NuGet packages that depend on Benevia.Core.Events:

Package Downloads
Benevia.Core.Postgres

Benevia Core PostgreSQL library with Entity Framework Core integration for database access.

Benevia.Core.Events.DataTypes

Benevia Core Events DataTypes library with source generators for event-based data models.

Benevia.Core.Blobs

Benevia Core Blobs library with Azure Blob Storage integration for file and blob management.

Benevia.Core.API.Workflows

Benevia Core API Workflows library for workflow and business process automation.

Benevia.Core.DataGenerator.LargeData

Benevia Core large data generator helpers for demo and stress data creation.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.8-ci.48 0 4/6/2026
0.8.7 123 4/2/2026
0.8.7-ci.46 43 4/2/2026
0.8.7-ci.45 41 4/2/2026
0.8.7-ci.44 37 4/1/2026
0.8.7-ci.43 42 3/31/2026
0.8.7-ci.42 40 3/31/2026
0.8.6 231 3/25/2026
0.8.6-ci.40 49 3/25/2026
0.8.5 162 3/25/2026
0.8.5-ci.38 45 3/25/2026
0.8.5-ci.37 41 3/25/2026
0.8.5-ci.36 43 3/24/2026
0.8.4 278 3/23/2026
0.8.4-ci.34 47 3/23/2026
0.8.4-ci.33 43 3/23/2026
0.8.3 197 3/19/2026
0.8.3-ci.31 47 3/19/2026
0.8.3-ci.30 61 3/19/2026
0.8.3-ci.29 47 3/19/2026
Loading failed