Create a new vertical-slice feature under src/backend/Source/Features (Core + Endpoints + Feature.cs + permission and entitlement providers), or work across existing features. Use when adding a whole capability rather than a single endpoint, and whenever cross-feature dependency, [AllowOutside] or [NoDirectUse] questions come up.
Install
npx skillscat add ma496/easy-for-net/backend-feature Install via the SkillsCat registry.
Creating a backend feature
A feature is a vertical slice. Everything it owns — entities, EF configuration, services,
settings, permissions, entitlements, endpoints — lives under src/backend/Source/Features/<Feature>/.Identity, Tenancy, Notifications and FileManagement are the worked examples.
Layout
Features/<Feature>/
<Feature>Feature.cs # IFeature.AddServices — the slice's DI module
Core/
<Feature>PermissionsProvider.cs # IPermissionDefinitionProvider (only if it owns permissions)
<Feature>FeaturesProvider.cs # IFeatureDefinitionProvider (only if it owns entitlements)
<Feature>SettingsProvider.cs # ISettingDefinitionProvider (only if it owns run-time settings)
<Name>Service.cs # interface + implementation in one file
<Name>Query.cs # optional narrow read contract for other features
<Entity>ValidationRules.cs # optional FluentValidation rules shared by several endpoints
<Feature>Dtos.cs # optional DTOs the published services answer in
<Feature>Constants.cs
Enums.cs # every enum the slice declares, and nothing else
<Name>Setting.cs # options bound from configuration
Entities/
<Entity>.cs
Configuration/<Entity>Configuration.cs
Endpoints/
<Area>/
<Area>Group.cs
<Entity><Action>Endpoint.cs1. The feature module
using Backend.Features.Notifications.Core;
namespace Backend.Features.Notifications;
/// <summary>
/// Feature module that registers the notifications services with the DI container.
/// </summary>
[BypassNoDirectUse]
public class NotificationsFeature : IFeature
{
public static void AddServices(IServiceCollection services, ConfigurationManager configuration)
{
services.AddScoped<INotificationService, NotificationService>();
}
}Helper.AddFeatures reflects over the assembly at startup and invokes every IFeature.AddServices
— never register a feature's services by hand in Program.cs. [BypassNoDirectUse] is needed
because this class references concrete implementations on purpose. Register services scoped:
they depend on the scoped AppDbContext and ITenantContext, and a request and a background job
must each get their own tenant scope.
Options are bound here too, with validation and ValidateOnStart():
services.AddOptions<AuthSetting>()
.Bind(configuration.GetRequiredSection("Auth"))
.Validate(setting => setting.AccessTokenValidity > 0 && setting.RefreshTokenValidity > 0,
"Authentication token lifetimes must be positive.")
.ValidateOnStart();Add the section to src/backend/Source/appsettings.json with a safe default. The per-environmentappsettings.*.json files are git-ignored and the hooks refuse to touch them — ask the user to
copy any real value across; a default the Testing host needs belongs in Program.cs.
Options are fixed per deployment. A value the platform or a tenant should change at run time is a
setting instead — see step 4 and the settings skill.
2. Services
One file per service, interface first, implementation below, both documented:
/// <summary>Defines lookup and lifecycle operations for <see cref="Edition"/>…</summary>
[AllowOutside] // only when another feature consumes it
public interface IEditionService
{
IQueryable<Edition> Editions(); // composable; the slice's endpoints narrow it
Task<bool> NameExistsAsync(string name, Guid? excludingId = null, CancellationToken cancellationToken = default);
}
/// <summary>EF Core-backed implementation of <see cref="IEditionService"/>…</summary>
[NoDirectUse]
public class EditionService(AppDbContext dbContext, IFeatureValueStore featureValueStore) : IEditionService { … }[NoDirectUse]makesTests/Architect/NoDirectUseTestsfail if any other type depends on the
concrete class instead of the interface.[BypassNoDirectUse]is for the rare type that
legitimately needs the concrete type (feature modules, composition roots).- Services answer facts (
NameExistsAsync); the endpoint turns a refusal into anErrorCodesconstant (this.ThrowError(x => x.Name, ErrorCodes.EditionNameAlreadyExists)), so
every caller reports the same code and the message comes from the localized resources — never an
English message constant. - Async members take
CancellationToken cancellationToken = defaultas the last parameter. - A tenant-aware service injects
ITenantContext(published byTenancy); it never takes a tenant
id from a caller's payload. See themulti-tenancyskill.
Shared validation rules. When several endpoints (or features) validate the same field, declare
the rule once as rule-builder extensions — Core/TenantValidationRules.cs,Core/EditionValidationRules.cs:
[AllowOutside]
static class EditionValidationRules
{
public const int NameMinLength = 2;
public const int NameMaxLength = 128;
public static IRuleBuilderOptions<T, string> EditionName<T>(this IRuleBuilder<T, string> ruleBuilder)
=> ruleBuilder
.Must(name => string.IsNullOrWhiteSpace(name)
|| (name.Trim().Length >= NameMinLength && name.Trim().Length <= NameMaxLength))
.WithMessage($"The name must be between {NameMinLength} and {NameMaxLength} characters.");
}Each rule passes a blank value through and is paired with NotEmpty() at the call site
(RuleFor(x => x.Name).NotEmpty().EditionName();), so an empty value raises one failure, not two.
3. Entities and DbSets
Entities live in Core/Entities, their EF configuration in Core/Entities/Configuration.
Configurations are applied automatically, but the DbSet is added by hand tosrc/backend/Source/ShareData/AppDbContext.cs under a comment naming the feature. Every entity must
be tenant-scoped (IMayHaveTenant / IHaveTenant) or explicitly exempted inTests/Architect/TenantScopingTests. See the backend-entity skill.
4. Permissions, entitlements and settings
If the feature guards anything, add Core/<Feature>PermissionsProvider.cs implementingIPermissionDefinitionProvider, and give each permission the right PermissionScope (Tenant,Platform or Both). Providers are discovered by reflection in Program.cs. See the permissions
skill for the full checklist.
If the capability should depend on the tenant's plan, add Core/<Feature>FeaturesProvider.cs
implementing IFeatureDefinitionProvider (discovered by TenancyFeature) — see thefeature-management skill.
If an administrator should be able to change how it behaves — for the platform or per tenant — addCore/<Feature>SettingsProvider.cs implementing ISettingDefinitionProvider (discovered bySettingsFeature) and read the value through ISettingProvider — see the settings skill. All three
providers are separate from <Feature>Feature.cs, the DI module.
5. Endpoints
One folder per area under Endpoints/, with a Group that owns the route prefix. See thebackend-endpoint skill.
Feature isolation — the rule that breaks builds
Tests/Architect/FeatureDependencyTests.Features_Should_Not_Have_Unwanted_Dependencies scans the
compiled assembly (inheritance, fields, properties, parameters, return types and IL bodies) and
fails if a type under Backend.Features.X touches a type under Backend.Features.Y.
The only escape hatch is [AllowOutside] on the depended-upon type (class, interface, struct or
enum):
/// <summary>…</summary>
[AllowOutside]
public interface ITenantContext { … }So when a new feature needs something from another:
- Prefer duplicating the small thing locally, or moving it to a shared root namespace that is not
a feature (Base,ShareData,Extensions,Permissions,Exceptions,Attributes). - If it genuinely belongs to the other feature and is meant to be shared, mark that type
[AllowOutside]and keep the surface minimal (interface + DTO, not the implementation).
Entities are never published this way. A feature's entities stay behind its services: publish an
interface that answers in ids, DTOs or enums (TenancyDtos.cs holds [AllowOutside] DTOs such asTenantMemberDto), and mark those answer types [AllowOutside] too. When another feature needs a
composable query rather than a materialized answer, return IQueryable<Guid> so the caller can fold
it into its own query without naming the row type — ITenantMembershipQuery.MemberUserIds(tenantId).
Watch the container while you do it. If the service you would publish depends on the feature that
wants to call it, taking it there closes a DI cycle and the container refuses to build. Publish a
second, narrower contract instead — one whose implementation takes AppDbContext and nothing else
(TenantMembershipQuery beside TenantMembershipService) — rather than merging the read into the
service that administers the thing.
Tests/Architect/Features/FeatureA and FeatureB are fixtures that prove the rule works — they
are not real features, so do not model new code on them or "fix" their intentional violations.
Checklist
-
Features/<Feature>/<Feature>Feature.cswithAddServices, services registered scoped - Settings bound + validated, section with safe defaults added to
appsettings.json - Entities (tenant-scoped or exempted) + configurations,
DbSetadded, migration created - Permission provider + constants in
Permissions/Allow.cs+ mirror insrc/frontend/web/allow.ts - Features provider +
FeatureNames.cs+src/frontend/web/feature-names.ts, if plan-gated - Settings provider + setting class + validator, if administrators change its behaviour at run time
- Endpoints with a group, permissions and validators
- Tests under
src/backend/Tests/Features/<Feature>/… -
dotnet build EasyForNet.slnxanddotnet test src/backend/Tests/Backend.Tests.csprojpass
(the architecture tests — feature dependency, no-direct-use, tenant scoping — are part of that run)