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
Featuresblock gets every feature.Excludeon its own keeps that default and simply removes a few. - Names cover their children.
Salesalso matchesSales.Returns.Sales,Sales.*, andSales*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" ]withExclude: [ "Sales.Returns" ]keeps all ofSaleson exceptSales.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
Includeturns on a feature but leaves out a feature it needs, Core turns on the missing feature and logs it. - If
Excludeturns 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
Financialson a shared interface stays silent for tenants withoutFinancials, 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
IncludeandExclude→ 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.
Related
- Features — declare features and control registration order
- Feature in a box — organize code as one folder per feature
- Configure your APP — set up tenants and services
- Role-based access control — permissions, applied after features