Feature management

One server. Many tenants (companies). Each tenant chooses which features are ON. A feature that is OFF disappears for that tenant only — no tables, no rules, hidden from the API — and turning it back ON brings everything back. Nothing is deleted.

Two things are called "Feature" in Core — don't mix them up. This page is about turning features on and off per tenant (configuration). Features is about declaring a feature and its registration order (IFeature, [FeatureDependsOn]). Both build on the "one folder = one feature" idea.

flowchart LR
    S["One server<br/>every feature built in"]
    S --> A["Company A<br/>Financials: ON"]
    S --> B["Company B<br/>Financials: OFF"]
    A --> A1["has Financials tables<br/>runs Financials rules<br/>sees it in the API"]
    B --> B1["no Financials tables<br/>skips Financials rules<br/>can't see it at all"]

A feature is a folder

The folder (namespace) a class lives in is its feature. You don't tag classes with a feature attribute.

Financials/
├── Invoice.cs        ← feature "Financials"
└── Ledger.cs         ← feature "Financials"
Sales/
└── Order.cs          ← feature "Sales"

Features nest with their namespaces: Sales.Returns is a child of Sales, so turning off Sales turns off everything under it too.

See Features for declaring a feature class and controlling its order, and Feature in a box for organizing code this way.


How it works

At startup Core discovers every feature in the application's assemblies — taken from its dependency closure (deps.json), not from whatever the runtime has loaded so far, so every server of a build computes the same feature catalog and the same feature hash for a tenant. For each tenant, its configuration becomes a simple set of on/off choices. Every request then runs against only that tenant's ON features:

flowchart LR
    Req["Request comes in"] --> Who["Which tenant?"]
    Who --> Set["its ON/OFF choices"]
    Set --> Model["database model<br/>(OFF tables left out)"]
    Set --> Meta["API schema<br/>(OFF entities left out)"]
    Set --> Run["business rules<br/>(OFF rules skipped)"]

Tenants that made the same choices share the same built model and schema, so that work happens once and is reused — 100 companies with 3 different feature choices means 3 builds, not 100. And a tenant with every feature ON behaves exactly like a server that never heard of features: there is no extra cost for using all of it.


Register the host

Keep AddCoreApi, AddCorePostgres, and UseCorePostgres(tenants) for API hosts using PostgreSQL upgrades. API supplies tenant feature resolution and disabled schema detection. Standalone PostgreSQL hosts can provide IFeatureResolver and IDisabledSchemaProvider; defaults enable every feature. Register AddPostgresFullTextSearch() separately when using OData $search.

Configure which features exist

The global Features section holds the platform-wide settings.

{
  "Features": {
    "NamespaceTrimRoots": [ "Benevia.ERP.Model" ],
    "AlwaysEnabled": [ "Platform" ]
  }
}
Setting Purpose
NamespaceTrimRoots Prefixes stripped from namespaces to produce short feature names. With Benevia.ERP.Model trimmed, the namespace Benevia.ERP.Model.Financials becomes the feature Financials.
AlwaysEnabled Features every tenant needs. Include cannot leave them out, and Exclude cannot turn them off.

Configure features per tenant

Each tenant turns features on or off under Tenants:<id>:Features, using two lists:

{
  "Tenants": {
    "acme":     { "Features": { "Include": [ "Financials", "Sales" ] } },
    "hillside": { "Features": { "Exclude": [ "Financials" ] } }
  }
}
List Meaning
Exclude A block list. Everything stays on except what you list.
Include An allow list. The moment you use Include, everything is off except what you list (plus always-on features and anything they depend on).

How a feature name is matched:

  • Default is ON. A tenant with no Features block gets every feature. Exclude on its own keeps that default and simply removes a few.
  • Names cover their children. Sales also matches Sales.Returns. Sales, Sales.*, and Sales* all mean the same thing.
  • Most specific wins. A feature is matched from its own name up through its parents, and the first list it appears in decides. So Include: [ "Sales" ] with Exclude: [ "Sales.Returns" ] keeps all of Sales on except Sales.Returns.
  • The same name in both lists fails startup with a clear error — remove it from one list.

Feature names use the trimmed path (Financials), not the full namespace.


Depend on other features

A feature can require another. Declare it once on the feature class:

[FeatureDependsOn(typeof(Products.Feature), typeof(Customers.Feature))]
public class Feature : IFeature { /* ... */ }

Core keeps required features together:

  • If Include turns on a feature but leaves out a feature it needs, Core turns on the missing feature and logs it.
  • If Exclude turns off a feature, Core also turns off every feature that needs it and logs each one. This also works across a chain of dependencies.

If Include contains QuickBooks but Exclude contains Payments, both features are off. Exclude wins. Core fails only if this would turn off an AlwaysEnabled feature. Core only uses [FeatureDependsOn] when it automatically turns off other features. Parent and child namespace rules still work as described above. See Features for declaring dependencies.


What turns off when a feature is OFF

For a tenant with the feature OFF, everything the feature owns is gone — not hidden behind a permission, actually absent:

Area Behavior when the feature is OFF
Entity endpoint GET /api/<Entity> returns 404 — it does not exist for this tenant
Writing a disabled property A POST/PATCH that sets it returns 400
Querying a disabled property $select/$filter/$orderby naming it returns 400
Database The entity's table and any disabled columns are left out of the tenant's model
API schema $metadata (OData) and entitymetadata (app) omit the entity/property; each distinct feature set gets its own ETag
Client / UI Graphs, property groups, lists, and pickers use the tenant-trimmed metadata, so disabled entities/properties do not render or get requested. Free-form ExtensionRegion UI renders only when its optional IRegionExtension.Feature is present in that metadata; no client appsetting flag is needed.
Permissions Disabled entities and properties drop out of the permissions payload
Seed / demo data Generators for disabled entities are skipped
Required fields A required field owned by an OFF feature is skipped, so a tenant is never blocked by a field it cannot see
Business rules Compute, Validate, Changed, Added, Deleting, PreSave and other subscribers owned by the feature do not run

Two behaviors worth knowing:

  • Shared logic on an interface is gated by the feature that wrote the rule, not by the entity. A rule authored in Financials on a shared interface stays silent for tenants without Financials, even on entities from other features — while a rule authored elsewhere on that same interface keeps running.
  • Compute chains fall back. If a disabled compute would have produced a value, the next enabled compute below it produces it instead. You get a real value, not a blank.

Turning a feature off is reversible

Disabling a feature never drops data. The tables and columns it owns are marked deleted in place — left where they are, with their rows — so nothing is lost (and an older server that still has the feature keeps working). Turn the feature back on and they are restored, including any rows added while it was off. A genuine model removal is marked the same way, but it also runs the deletion subscribers ([EntityDeleted]/[PropertyDeleted]) so a consuming app can migrate the data out; a reversible feature toggle never fires them.

Not to be confused with feature version upgrades, a separate mechanism for versioned one-time data changes within a feature.


Safety and validation

Core fails fast on bad configuration, so mistakes surface at startup rather than in production:

  • Unknown or misspelled feature → the host will not start, with a "did you mean Financials?" suggestion.
  • Excluding an always-on feature → the host will not start.
  • The same feature in Include and Exclude → the host will not start.
  • Bad config on a live reload → the last good configuration is kept and the error is logged.
  • Unknown or missing tenant → treated as "everything on" rather than "nothing on", so a lookup failure never silently hides data. Within a request, a mismatched tenant claim is refused rather than served another tenant's model.

FAQ

How do I create a feature? Put its classes in their own folder/namespace. Optionally add a Feature class (see Features) to name it and set its order.

What can a tenant never turn off? Anything listed in Features:AlwaysEnabled, plus core platform tables (identity, schema versioning) that are not part of any toggleable feature.

Does turning a feature off delete data? No. Its tables and columns are marked deleted in place and restored when the feature is re-enabled.

What does a user see for an off feature? Nothing — its endpoints return 404 and it is absent from the schema, as if it were never built.

How is this different from Features? That page declares a feature and its registration order. This page turns features on and off per tenant. Same feature identity (the folder), different job.

Is [FeatureVersionChanged] the same thing? No — that is a versioned one-time data upgrade within a feature. See Feature version upgrades.


Package ownership

Core owns shared feature contracts, declarations, discovery, and execution scopes. API owns FeatureCatalog, FeatureSet, and tenant configuration in Benevia.Core.API.Features. Events owns ScopedFeatureAccessor; Postgres owns FeatureGraph and FeatureLookup. These three types retain the Benevia.Core.Features namespace. Standalone hosts can supply IFeatureCatalog and IFeatureSet, or use IFeatureSet.All when all features apply.

When upgrading, rebuild consumers with the matching Core packages because the owning assemblies have changed.

Upgrade existing feature hosts

Update production code and test hosts together when changing Core package versions. The following changes apply to public contracts:

Previous API Replacement
Benevia.Core.Features.FeatureCatalog, FeatureRules, FeatureOptions, TenantFeatureOptions, FeatureConfigurationException, FeatureIdResolver, FeatureNamespaceNormalizer Same type names in Benevia.Core.API.Features; reference Benevia.Core.API.
FeatureCatalog(options, namespaces, declarations) FeatureCatalog(namespaces, options.NamespaceTrimRoots, declarations).
FeatureCatalog.Resolve(rules, tenantId) Configure TenantFeatureResolver and call For(tenantId); see below.
TenantFeatureSet / TenantFeatureSet.All IFeatureSet / IFeatureSet.All; use API's FeatureSet for concrete snapshots.
ITenantFeatureAccessor / ScopedTenantFeatureAccessor IFeatureAccessor / ScopedFeatureAccessor; the implementation is in the Events package.
ITenantFeatureScope / AddTenantFeatureScope() IFeatureScope / AddFeatureScope().
ITenantFeatureResolver in Benevia.Core.Features API hosts use Benevia.Core.API.Features.ITenantFeatureResolver; standalone upgrade hosts use Core's IFeatureResolver.
IDisabledSchemaProvider.For(TenantFeatureSet) IDisabledSchemaProvider.For(IFeatureSet).
IFeatureRegistrationScope / AddFeatureRegistrationScope() Same names in Benevia.Core.Events.Features.
DeferredMethodInvocations in the Core package Reference the Events package; its namespace remains Benevia.Core.UserPrompts.

FeatureGraph and FeatureLookup retain their namespace but require the Postgres package. Retained namespaces reduce source changes; package ownership is listed above.

Standalone Events registration

Hosts that call AddCoreApi receive the IFeatureCatalog registration automatically. Other hosts using feature selection must register the same catalog instance through that interface. Registering only the concrete FeatureCatalog does not enable subscriber ownership checks.

For example, a host with two independent features can select Sales while disabling Financial:

using Benevia.Core.API.Features;
using Benevia.Core.Events;
using Benevia.Core.Features;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
var catalog = new FeatureCatalog(["Sales", "Financial"]);
services.AddSingleton(catalog);
services.AddSingleton<IFeatureCatalog>(provider => provider.GetRequiredService<FeatureCatalog>());
services.AddCoreEvents(options => options.UseInMemory());
// Register the application's generated model and business logic here.

using var provider = services.BuildServiceProvider();
provider.UseBusinessLogicEvents();
var selection = FeatureSet.Create(catalog.Count,
    [FeatureCatalog.AlwaysOnIndex, catalog.IndexForPath("Sales")]);
using var features = provider.GetRequiredService<IFeatureScope>().Begin(selection);
using var request = provider.CreateScope();
// Resolve the event context and run the application's work within this scope.

Use the model and logic namespaces, plus any trim roots, when constructing a real application's catalog. Keep a regression test that opens a restricted scope and verifies excluded business rules do not run.

Compile Include/Exclude rules outside an API host

FeatureSet.Create represents a fixed selection; it does not apply dependency closure or AlwaysEnabled rules. Tests and background hosts that need these policies should use the public TenantFeatureResolver:

var resolver = new TenantFeatureResolver(catalog, tenantService, configuration, logger);
IFeatureSet selected = resolver.For(tenantId);

The host supplies ITenantService and an IConfiguration containing the global Features options. Each TenantConfig.Section supplies that tenant's Features:Include and Features:Exclude lists. Pass declarations to the catalog so the resolver can preserve their dependency rules. The setup is the same as an API host; a shared test fixture can provide in-memory configuration and tenant services. Do not replace dependency-closure assertions with hand-selected indexes during migration.

Custom API resolvers and snapshots

Register a custom ITenantFeatureResolver after AddCoreApi when overriding API tenant policy. Its For, AllFeaturesEnabled, and DistinctSets members supply the same policy to requests, authentication, model rebinding, startup validation, and database upgrades. AllFeaturesEnabled supplies the pre-authentication fallback; DistinctSets() returns the active tenant snapshots for startup work. Standalone Postgres hosts can implement the smaller IFeatureResolver contract, which exposes only For.

Custom snapshots must remain immutable and use the host catalog's feature indexes. Their Hash must stay stable across processes and identify equivalent selections within that catalog. Use one identifier scheme for all snapshots sharing caches; different selections must not deliberately reuse an identifier. Zero is reserved for the unrestricted IFeatureSet.All selection. Prefer FeatureSet.Create where the API package is available.

For custom catalogs, property-name lookup must try an exact match, then an ordinal case-insensitive match. Both forms must preserve generated property ownership overrides.

Other dependency changes

Replace value.IsNullOrEmpty() and value.IsNullOrWhiteSpace() with string.IsNullOrEmpty(value) and string.IsNullOrWhiteSpace(value) when upgrading consumers. Direct container users need an explicit Microsoft.Extensions.DependencyInjection reference; see application configuration.