Skip to content

Adding a Feature

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

This is the path a contributor actually walks to add something to forge-api, in the order the dependencies force. Skipping a step usually does not fail at compile time — it fails at boot, in a ratchet test, or on somebody else's install six months later. Each step below names the trap that catches people.

Read the hub's Contributing for the branch and PR model, and Developer Setup for getting a stack running first.

Step 0 — Does this need a schema change?

If yes, it starts in forge-db, not here. There are no EF Core migrations in this repository. dotnet ef migrations add will produce something the project cannot use, and the resulting file will be reverted.

The flow is: edit the desired-state SQL tree in forge-db → regenerate the assembled artifact into forge.data/Schema/forge-schema.sql with the forge-db CLI's assemble command → commit the regenerated file in the same commit as your entity change. The Schema drift check workflow re-assembles forge-db and diffs it against the committed file.

Three traps here:

  • Renames need a pre-migrate script in forge-db. The diffing tool compares states and has no concept of a rename, so editing the table definition alone plans a DROP plus a CREATE, which deletes every row on a populated install. forge-db has a premigrate/ phase for exactly this.
  • SchemaBootstrapper will not fix your local database. It applies the schema to a fresh database and is a no-op on an existing one. A dev container that crash-loops on relation "..." does not exist has a stale volume; reconcile it with the forge-db harness or recreate it.
  • Reaching a real install is a separate, gated step in forge-deploy, not something this repo does. See the hub's Upgrades and Rollback.

Step 1 — The entity

New entity classes go in forge.core/Entities/, one type per file, as plain POCOs.

Derive from BaseAuditableEntity, not BaseEntity. This is the single most consequential trap in the repo, and the root CLAUDE.md currently describes it backwards. In the code, BaseEntity carries only Id. BaseAuditableEntity is what carries CreatedAt, UpdatedAt, DeletedAt and DeletedBy — and it is BaseAuditableEntity that the DbContext keys all of its automatic behaviour off: the soft-delete global query filter, timestamp stamping, the DeletedBy audit principal, and automatic activity/audit-log capture. An entity that derives from plain BaseEntity silently gets none of it. (There is also no CreatedBy property on either base class, despite what that doc says.)

Timestamps are DateTimeOffset, and the database columns are timestamptz. Everything is UTC.

Then:

  • Add a DbSet<T> to AppDbContext.
  • Add an IEntityTypeConfiguration<T> in forge.data/Configuration/. Configurations are picked up by assembly scan, so there is nothing to register. Put precision, indexes, relationships and delete behaviour here. This is where mapping lives in practice — the entity classes carry almost no data annotations, whatever CLAUDE.md says about preferring them.
  • Do not name tables or columns. Snake-case naming is applied automatically to tables, columns, keys, foreign keys and indexes, and forge-db's naming is the authority anyway.
  • Index every foreign key explicitly.

Step 2 — Request and response models

Models go in forge.core/Models/, as records. The naming rule is *RequestModel and *ResponseModel; "DTO" is not a suffix used anywhere in this codebase and a new one will stand out.

Handlers overwhelmingly construct response records inline — either projected in the LINQ Select or built by hand after the save. Mapperly is referenced and there are a handful of [Mapper] classes, but see Conventions before you reach for one; the situation is messier than the stack list suggests.

Step 3 — The MediatR handler

One file per operation in forge.api/Features/<Area>/, holding the request record, the validator and the handler.

public record CreateThingCommand(string Name, int OwnerId) : IRequest<ThingResponseModel>;

public class CreateThingValidator : AbstractValidator<CreateThingCommand>
{
    public CreateThingValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
    }
}

public class CreateThingHandler(AppDbContext db, IClock clock)
    : IRequestHandler<CreateThingCommand, ThingResponseModel>
{
    public async Task<ThingResponseModel> Handle(CreateThingCommand request, CancellationToken ct)
    {
        // …
    }
}

Validators are registered by assembly scan and run in a MediatR pipeline behavior, which throws ValidationException — the exception middleware turns that into a 400 ValidationProblemDetails. Never validate by returning a result object; the envelope comes from the exception path.

Points that bite:

  • Inject IClock, never call DateTime.UtcNow. A ratchet test enforces this for Features/, Services/ and Jobs/. IClock.UtcNow returns a DateTimeOffset.
  • Read-only queries take AsNoTracking(), and a Where inside a Select projection is an N+1 waiting to happen. Pre-load or pre-group first.
  • Business refusals throw InvalidOperationException with a user-readable message — the middleware maps that to 409 and puts your message in detail. Write the message accordingly; it is user-facing.
  • Activity logging is partly automatic and partly yours. SaveChangesAsync already synthesises an activity row and an audit row for added, modified and deleted BaseAuditableEntity instances. What you add by hand is the domain-meaningful row: db.LogActivityAt(action, description, ("Part", partId), ("Vendor", vendorId)) when the change belongs under more than one entity, or when the verb matters (preferred-vendor-changed, price-tier-added). One row per multi-field update summarising the changed fields — not one row per field.
  • The synchronous SaveChanges() overload does timestamps but skips audit capture entirely. Use SaveChangesAsync.

If the operation can be reached from a Hangfire job or a hub as well as a controller, put [RequiresCapability] on the request record too — the MediatR pipeline behavior reads it there, and that is the only gate on a non-HTTP path.

Step 4 — The controller

One controller per aggregate root, routed under the literal api/v1 prefix, thin.

[ApiController]
[Route("api/v1/things")]
[Authorize(Roles = "Admin,Manager")]
[RequiresCapability("CAP-MD-THINGS")]
public class ThingsController(IMediator mediator) : ControllerBase
  • Plural nouns for collections; verbs only for genuinely RPC-shaped sub-routes (/{id}/set-default, /{id}/archive).
  • POST returns 201 with a Location header; a no-body PUT or DELETE returns 204.
  • No try/catch. A ratchet test enforces it. The exception middleware owns the error contract.
  • If the endpoint accepts a genuinely large upload, [DisableRequestSizeLimit] alone is not enough — the multipart reader has its own limit and will kill the upload partway through. Add [RequestFormLimits(MultipartBodyLengthLimit = …)] as well. There is no global override configured.

Step 5 — The capability attribute

Every controller must carry [RequiresCapability("CAP-…")] or [CapabilityBootstrap], at class level or on every single HTTP action. ControllerCapabilityGateTests enforces this by reflection and its legacy-exemption register is empty, so a new ungated controller fails the build. An ungated controller is fail-open: its endpoints can never be switched off per install, which silently breaks the preset and discovery model that the hub's Capability Gating page describes.

Two mechanics you need before you attribute a mixed controller:

  • Class-level [CapabilityBootstrap] beats action-level [RequiresCapability]. The middleware checks for the bootstrap marker first and returns immediately, so a controller with some exempt actions and some gated ones must be attributed action by action, with no class-level bootstrap marker at all.
  • For competing [RequiresCapability] attributes, the endpoint's own metadata wins — the action-level attribute is what fires.

Reuse an existing capability if one fits. If it does not:

  1. Add a CapabilityDefinition row to forge.api/Capabilities/CapabilityCatalog.cs. A code is permanent once shipped — it is written into installs' capabilities rows and into attributes. Pick the area prefix carefully and write a description that says what disabling it costs a shop, because that description is what an admin reads in the toggle UI.
  2. Decide IsDefaultOn deliberately. Default-on means every fresh install gets it.
  3. If it depends on another capability or excludes one, add the edge to CapabilityCatalogRelations.cs. Dependency edges are what stop an install disabling something out from under a feature that needs it.
  4. If it belongs in a user-facing module bundle, add it to ModuleCatalog.cs.
  5. Add training content, or accept a red build. TrainingCoverageRatchetTests requires every catalog capability to be claimed by a training seeder in forge.api/Data/TrainingContent/, with a shrink-only baseline of the capabilities that were untaught when the rule landed. A new capability with no seeder is a new gap, and new gaps fail. Write a *Training.cs seeder claiming it (or claim it from an existing one) in the same commit — and note that TrainingContentShapeTests will also check that every module has app routes starting with / and a quiz with a correct option.

Step 6 — The UI contract

If the SPA is going to call this, the route you just wrote is a contract with forge-ui. Nothing in the build checks it: the SPA composes URLs as strings against environment.apiUrl (/api/v1 in production builds, an absolute localhost address in the dev environment file), so a renamed route compiles green on both sides and 404s in the browser. If you change or remove a route, grep forge-ui for it in the same change.

The same applies to the capability code: the SPA hides surfaces with a capability directive and a route guard, so a new gated endpoint usually needs the matching UI gating or the user gets a button that returns 403.

Step 7 — Tests, then the gates

Write handler tests for the logic and endpoint tests for the wiring. If the behaviour depends on a filtered unique index, a set-based update, a pgvector column or a database trigger, it needs the Postgres-backed collection — the in-memory provider models none of those. Testing covers the choice.

Before pushing, run what CI runs:

dotnet build --configuration Release -warnaserror
dotnet test

Warnings fail the build. If a ratchet test reports RATCHET DOWN or STALE ENTRY because you cleaned something up on the way past, regenerate the baseline and commit it in the same commit — Testing has the exact command and the reason it is not optional.