Property groups

A server property group applies one rule to several properties. For example, posting an Order makes Description and Amount read-only.

Group Order members Rule
PostedFields Description, Amount Read-only while posted.
InternalFields InternalNote Hidden while archived.
SharedFields Amount, Note Copy to matching Invoice fields.

Server groups set rules. Client graph groups arrange fields and buttons. Define their properties separately.

The complete Order example contains the models, logic, and working page. See the method summary for the API reference.

Declare a group

Create a class that implements IPropertyGroup. This class identifies the group. Add [PropertyGroup<TGroup>] to each model that uses it:

public sealed class PostedFields : IPropertyGroup;
public sealed class InternalFields : IPropertyGroup;
public sealed class SharedFields : IPropertyGroup;

[ApiEntity]
[PropertyGroup<PostedFields>]
[PropertyGroup<InternalFields>]
[PropertyGroup<SharedFields>]
public partial class DocsOrder;

[ApiEntity]
[PropertyGroup<SharedFields>]
public partial class Invoice;

An entity or interface can have several groups. A group can be empty.

Add properties

Choose members inside [RegisterLogic] methods:

order.PropertyGroup<PostedFields>().Add(o => new { o.Description, o.Amount });
order.PropertyGroup<InternalFields>().Add(o => o.InternalNote);
order.PropertyGroup<SharedFields>().Add(o => new { o.Amount, o.Note });

Add the same properties in the Invoice's Logic class:

invoice.PropertyGroup<SharedFields>().Add(i => new { i.Amount, i.Note });

Use Add(o => o.Amount) for one property. Adding it twice has no effect. Use properties on the model itself. Nested paths and method calls are not allowed. New model properties do not join a group automatically.

At startup, Core collects and locks the group's properties and rules. Record values can still change. See startup details.

Make members read-only or hidden

order.PropertyGroup<PostedFields>().ReadOnly().If(o => o.IsPosted);
order.PropertyGroup<InternalFields>().Hidden().If(o => o.IsArchived);

IsPosted remains outside the group so Undo post can change it.

Before posting: Description and Amount are editable.

Order fields before posting

After posting: both fields are read-only.

Description and Amount locked after posting

Active Order: Internal note is visible.

Internal note visible

Archived Order: Internal note is hidden. Note stays visible.

Internal note hidden after archiving

  • A property is read-only or hidden if any of its rules says so.
  • Both rules block changes from server logic and API calls.
  • Hidden also hides values in API responses. Server logic can still read them. Use permissions to control access.
  • Hidden conditions must work in SQL. You cannot hide Guid, Title, or natural-key properties.
  • ReadOnly skips properties without setters. It also accepts event arguments: args => args.Entity.IsPosted.

See ReadOnly and Hidden for details.

React to changes and copy one member

When a user edits Amount, copy only Amount to the linked Invoice:

using Benevia.Core.Events.LogicContainers;

order.PropertyGroup<SharedFields>().Changed().Do((o, args) =>
{
    if (args.ChangedBy == ChangedBy.Direct && o.LinkedInvoice is { } invoice)
        args.CopyTo(invoice);
});

Editing Note copies only Note. Description and document numbers are outside the shared group. The Invoice must already exist in the same EventContext. CopyTo does not save. The code that made the edit must save both records.

Order: edit Amount to $240.00.

Order with Amount 240 and Note Deliver before noon

Linked Invoice: Amount becomes $240.00. Note stays unchanged. Refresh the Invoice to see the saved value.

Linked Invoice with Amount 240 and its unchanged Invoice note

ChangedBy.Direct copies direct edits, such as API updates. It skips handler changes (LogicSubscriber) to prevent copying loops. This also skips Apply note, which changes Note in a handler. See ERP header synchronization to copy handler changes too.

Changed event details

Changed handles property changes, dirty notifications, and LoadAll collection changes. It also accepts Do(entity => ...). args identifies the entity, property, cause, and old/new values.

Each event keeps its values after correction and validation, including during nested changes. For dirty properties, OldValue is cached. NewValue computes once without firing Changed again.

CollectionChangedArgs gives the collection action and changed items. Clear reports Reset. OldValue, NewValue, and CopyTo throw for collection events. For scalar and reference changes, CollectionChangedArgs is null. Scalar list properties follow value rules.

Interface rules run once per underlying entity collection. Paged collections, virtual collections without change events, and proxies without underlying entity collections fail startup. Loading and restoring do not fire Changed. Changes to item properties need separate rules.

Copy a group

Copy to draft copies both Amount and Note to an existing draft Invoice:

[Method("Copy to draft", MethodType.Modify, SaveOnCompletion = true)]
public partial void CopyToDraft();
order.Method(o => o.CopyToDraft)
    .DisableIf(o => o.Draft == null)
    .Do(o =>
    {
        if (o.Draft is { } draft)
            order.PropertyGroup<SharedFields>().Copy(o, draft);
    })
    .OnSuccess("Shared fields copied to draft.");

SaveOnCompletion saves after the method. Copy does not save or create records.

Source Order: copy Amount and Note.

Order with Amount 240 and Note Deliver before noon

Draft Invoice: both values now match the Order. Refresh the draft to see the saved values.

Draft Invoice with Amount 240 and Note Deliver before noon

Operation Copies
args.CopyTo(target) in Changed The property that changed. Throws for collection changes.
group.Copy(source, target) All matching properties that can be copied.

Copying matches the group type and property name. Normal setters, validation, and events still run.

  • Both records must use the same active EventContext.
  • The target property type must accept the source value. Core does not convert values.
  • Missing groups, missing properties, and equal values are skipped.
  • Copy skips collections. Both methods skip virtual properties, properties without setters, and properties from unavailable features.
  • A read-only or hidden target causes an error. Pass skipReadOnly: true to skip it.
  • An error stops copying. Earlier changes stay pending; Core does not undo them.

Leave document numbers and other business keys out of the group.

Copy order and logic reloads

Copy reads each source property's accepted value in name order. It does not copy rejected source edits. After reloading logic, get the group again before inspecting or copying it.

Recalculate a change summary

To list changed properties, declare TrackedChanges on the model and add a ChangeSummary text property:

var tracked = order.PropertyGroup<TrackedChanges>()
    .Add(o => new { o.Description, o.Amount, o.Note });

order.Compute(o => o.ChangeSummary)
    .From(o => string.Join(", ", tracked.GetPropertyStates(o)
        .Where(p => p.IsModified).Select(p => p.PropertyName)))
    .DirtyByGroup<TrackedChanges>();

DirtyByGroup watches current and later group properties, except the computed property itself. The summary updates on the next read or save. It supports LoadAll collection changes, but rejects paged collections. Child properties need DirtyWithRelation(...).DirtyBy(...).

GetPropertyStates returns current states by name, including inherited and derived properties. It skips unavailable features without reading values or loading references. IsModified tracks accepted writes and collection changes, including equal and changed-back values. See state rules.

Inheritance and startup

  • Derived models inherit groups and rules once. Their additions do not change base or sibling models.
  • Documents and their details have separate groups.
  • Group rules run in registration order, after existing property rules. They also cover properties added later during startup.
  • Rules run only for enabled features. All companies and features share the group's property list.
  • Adding properties or rules after startup throws, even through saved builders. Record values remain editable unless a rule blocks changes.
  • Repeated lookup returns the same group for that model Logic. Undeclared groups throw; reloading logic creates new groups.

Read the result

string description = order.PropertyGroup<PostedFields>().Describe();

The report lists properties, rules, registration sources, and lock state, including empty groups.

ERP adoption examples

Posting rules and copying between ERP documents

Finalized orders and their details

SalesOrderBL gives orders and details separate FinalizedOrderLock groups. DocNumber uses a separate rule so finalization can fill an empty number.

Shared posting locks

IFinancialDoc declares PostedLock. DocumentPostingBL defines the rule in Company, so it works without Financials. Invoice logic and JournalBL add properties to it. Keep IsPosted outside the group to allow unposting.

Details use separate groups; see ShipmentBL. Other rules may keep fields read-only after unposting.

ERP header synchronization

SalesOrderInvoiceSyncBL uses OrderInvoiceHeaderSync to copy selected Order and Invoice properties. It validates targets and calls args.CopyTo(target, skipReadOnly: true) to skip read-only fields.

ERP copies handler changes too, so ChangedBy.Direct is not enough. DocSync tracks each (source, target, property) copy to prevent loops. Only the first handler sends a notification. A finally block clears tracking.