Interfaces
This is an advanced topic. Make sure you're familiar with Entity Basics, Properties, Relationships, and Events first.
Benevia Interfaces vs Standard C# Interfaces
In standard C#, interfaces define shape only: implementing classes must provide the actual members and behavior. In Benevia, interfaces are also model and behavior composition points.
What Benevia core adds:
- Logic in interfaces: Logic can be connected to interfaces, their properties, and their methods through events. This lets you define capability once on an interface and have implementing entities inherit that behavior automatically.
- Automatic property and method implementation: Interface properties and methods are automatically implemented on concrete entities through source generation, including interface-declared attributes.
- Opposite side collection variable naming: Collection property names can conflict when using
[OppositeSideCollection]in interfaces. We use placeholders to name the collections. (See Relationships) - Explicit property implementation of parent / child interfaces: This is used when creating a parent and a child interface. Example:
ISalesDocandISalesDocDetail.
This means interfaces are not just compile-time contracts; they are reusable building blocks for generated model structure and shared business logic behavior.
When to Use Interfaces
| Scenario | Example |
|---|---|
| Multiple entities share the same properties and methods | SalesOrder and Invoice both need customer, shipping, and total properties |
| Reduce duplicate code between two entities | SalesOrderDetail and SalesInvoiceDetail share the same code for adding products, calculating unit of measure, calculating prices, etc. |
| Create interfaces to add functionality to other classes (Traits) | ITag adds tagging functionality to SalesOrder, Customer, and other entities |
| Avoid when only one entity needs the properties | Add the properties directly to the entity — an interface adds indirection with no benefit |
Logic in interfaces
Logic can be attached to an interface just like a concrete entity. Any entity that implements the interface automatically inherits the behavior — no duplication required.
Define the interface with properties:
Properties are defined in the same way as entities. See Properties for details. [Virtual] marks a property as computed with no database column — see [Virtual] on Properties.
public partial interface ISalesDoc : IInventoryDoc
{
// ...
[Virtual]
[Property<DataTypes.Currency>("Subtotal")]
decimal Subtotal { get; }
[Property<DataTypes.Currency>("Shipping")]
decimal ShippingAmount { get; set; }
[Virtual]
[Property<DataTypes.Currency>("Total")]
decimal Total { get; }
}
public partial interface ISalesDocDetail : IInventoryDocDetail
{
// ...
[Property<DataTypes.Currency>("Total price")]
decimal TotalPrice { get; set; }
}
Write logic once on the interface — it applies to every implementing entity:
// Developer writes:
[Logic]
public class ISalesDocBL(ISalesDoc.Logic salesDoc, ISalesDocDetail.Logic salesDocDetail)
{
[RegisterLogic]
public void Totals()
{
salesDoc.Compute(s => s.Subtotal)
.From(doc => doc.Details.Sum(detail => detail.TotalPrice))
.DirtyWithRelation(o => o.Details)
.DirtyBy(d => new { d.TotalPrice });
salesDoc.Compute(s => s.Total)
.From(doc => doc.Subtotal + doc.ShippingAmount)
.DirtyBy(doc => new { doc.Subtotal, doc.ShippingAmount });
}
}
ISalesDoc.Logic and ISalesDocDetail.Logic are injected just like SalesOrder.Logic would be for a concrete entity. The same Totals() logic runs for SalesOrder, SalesInvoice, or any other entity that implements ISalesDoc.
[RegisterLogic] marks a method that runs once at startup to wire up event subscriptions — see Business logic classes. See Events for full documentation on compute, validate, changed, and pre-save events.
Automatic property and method implementation
Entities implement the interface like any C# interface. The source generator automatically provides the property implementations:
[ApiEntity]
[NaturalKey(nameof(Id))]
public partial class Customer : IContactAccount
{
[Required]
[Property<DataTypes.IdText>("Id")]
public partial string Id { get; set; }
// PrimaryContact is generated from IContactAccount
// No need to declare it here
}
The Customer entity gets a PrimaryContact reference property, and the Contact entity gets an Accounts collection — all generated from the interface definition.
Interfaces can extend other interfaces to build layered property contracts:
// Base: all documents have a number, description, and note
public partial interface IInventoryDoc
{
[Searchable(1, Dictionary = "simple")]
[Required]
[Property<IdText>("Number")]
string DocNumber { get; set; }
[Searchable(2)]
[MaxLength(80)]
[Property<Text>("Description")]
string Description { get; set; }
[Property<MultilineText>("Note")]
string Note { get; set; }
}
// Extended: sales documents add customer, pricing, and totals
public partial interface ISalesDoc : IInventoryDoc
{
[Required]
[SearchableNavigation]
[ReferenceProperty("Customer", DeleteAction.Restrict)]
[OppositeSideCollection("[EntityName]s", "[EntityLabel]s", CollectionLoadMode.Paged)]
Customer? SellToCustomer { get; set; }
[ReferenceProperty("Shipping method", DeleteAction.SetNull)]
ShippingMethod? ShippingMethod { get; set; }
[Required]
[DataType("Picker")]
[ReferenceProperty("Price Level", DeleteAction.Restrict)]
PriceLevel? PriceLevel { get; set; }
[Virtual]
[Property<Currency>("Subtotal")]
decimal Subtotal { get; }
[Property<Currency>("Shipping")]
decimal ShippingAmount { get; set; }
[Virtual]
[Property<Currency>("Total")]
decimal Total { get; }
}
An entity implementing ISalesDoc gets all properties from both ISalesDoc and IInventoryDoc:
[ApiEntity]
[NaturalKey(nameof(DocNumber))]
public partial class SalesOrder : ISalesDoc
{
// These properties are automatically implemented with source generation
// From IInventoryDoc: DocNumber, Description, Note.
// from ISalesDoc: SellToCustomer, ShippingMethod, PriceLevel, Subtotal, Shipping, and Total.
// Add entity-specific properties
[Property<DataTypes.DateOnly>("Order date")]
public partial DateOnly? OrderDate { get; set; }
[Property<DataTypes.Enum>("Status")]
[DefaultValue(SalesOrderStatus.Open)]
public partial SalesOrderStatus Status { get; set; }
}
Opposite side collection naming conflicts
When an interface defines [OppositeSideCollection], placeholders ensure unique collection names on the target entity for each implementing class:
| Placeholder | Replaced With | Example for SalesOrder |
|---|---|---|
[EntityName] |
Implementing entity's class name | SalesOrder |
[EntityLabel] |
Implementing entity's display label | Sales order |
Without placeholders, multiple entities implementing the same interface would generate conflicting collection names on the target. In the following example, we implement ISalesDoc in SalesOrder and SalesInvoice. Without placeholders, SalesOrder and SalesInvoice would create a conflicting collection name of Customer.SalesDocs. With placeholders, it creates Customer.SalesOrders and Customer.SalesInvoices collections.
classDiagram
class ISalesDoc {
<<interface>>
+Customer SellToCustomer
}
class SalesOrder {
+Customer SellToCustomer
}
class SalesInvoice {
+Customer SellToCustomer
}
class Customer {
+Collection~SalesOrder~ SalesOrders
+Collection~SalesInvoice~ SalesInvoices
}
ISalesDoc <|.. SalesOrder : implements
ISalesDoc <|.. SalesInvoice : implements
Customer "1" --> "*" SalesOrder
Customer "1" --> "*" SalesInvoice
// In ISalesDoc:
[OppositeSideCollection("[EntityName]s", "[EntityLabel]s", CollectionLoadMode.Paged)]
Customer? SellToCustomer { get; set; }
// Generates on Customer:
// SalesOrders collection (from SalesOrder implementing ISalesDoc)
// SalesInvoices collection (from SalesInvoice implementing ISalesDoc)
See Relationships for more information on [OppositeSideCollection].
References and collections with parent / child interfaces
ISalesDoc and ISalesDocDetail are a good example of how Benevia interfaces let you keep business logic at the interface level while still generating concrete database relationships.
classDiagram
class ISalesDoc {
<<interface>>
+Details
}
class ISalesDocDetail {
<<interface>>
+Doc
}
class SalesOrder {
<<Database table>>
+Details
+ISalesDoc.Details
}
class SalesOrderDetail {
<<Database table>>
+Doc
+ISalesDocDetail.Doc
}
ISalesDoc <|.. SalesOrder : implements
ISalesDocDetail <|.. SalesOrderDetail : implements
ISalesDocDetail "n" --> "1" ISalesDoc : Doc
SalesOrderDetail "n" --> "1" SalesOrder : Doc
ISalesDoc "1" --> "n" ISalesDocDetail : Details
SalesOrder "1" --> "n" SalesOrderDetail : Details
The last two properties in SalesOrder and SalesOrderDetail are the explicit interface implementations generated at build time. They bridge the interface-typed members (ISalesDoc.Details, ISalesDocDetail.Doc) to their concrete counterparts (Collection<SalesOrderDetail>, SalesOrder).
Interface contracts define parent/child shape:
// Developer writes:
public partial interface ISalesDoc
{
// Details - This property is created with source generation from the ISalesDocDetail [OppositeSideCollection]
}
public partial interface ISalesDocDetail
{
[Required]
[ReferenceProperty("Sales document", DeleteAction.Cascade)]
[OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll)]
ISalesDoc? Doc { get; set; }
}
Concrete entities define the actual relationship that becomes the database FK/navigation:
// Developer writes:
[ApiEntity]
public partial class SalesOrder : ISalesDoc
{
// Details - This property is created with source generation from the ISalesDocDetail [OppositeSideCollection]
}
[ApiEntity]
public partial class SalesOrderDetail : ISalesDocDetail
{
[Required]
[ReferenceProperty("Sales order", DeleteAction.Cascade)]
[OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll)]
public virtual partial SalesOrder? Doc { get; set; }
}
Technical - Source generation adds explicit interface implementations that bridge interface-level logic to concrete relationships:
// Source generator produces:
// On SalesOrder:
IObservableListProxy<ISalesDocDetail> ISalesDoc.Details
=> new ObservableListProxy<ISalesDocDetail, SalesOrderDetail>(Details, ((IEventEntity)this).GetContext());
// On SalesOrderDetail:
ISalesDoc? ISalesDocDetail.Doc
{
get => Doc;
set => Doc = (SalesOrder?)value;
}
Because of that bridge, logic can target interfaces and still operate on concrete rows. For more information on logic and events, see Events.
[Logic]
public class SalesDocDetailBL(ISalesDocDetail.Logic detail) // Logic on interface
{
public void ValidateDoc()
{
detail.Validate(d => d.Doc)
.RejectIf(doc => doc == null)
.WithMessage("A sales detail must belong to a sales document.");
}
}