-
Notifications
You must be signed in to change notification settings - Fork 0
Adding a Feature
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.
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
DROPplus aCREATE, which deletes every row on a populated install. forge-db has apremigrate/phase for exactly this. -
SchemaBootstrapperwill 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 onrelation "..." does not existhas 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.
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>toAppDbContext. - Add an
IEntityTypeConfiguration<T>inforge.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, whateverCLAUDE.mdsays 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.
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.
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 callDateTime.UtcNow. A ratchet test enforces this forFeatures/,Services/andJobs/.IClock.UtcNowreturns aDateTimeOffset. -
Read-only queries take
AsNoTracking(), and aWhereinside aSelectprojection is an N+1 waiting to happen. Pre-load or pre-group first. -
Business refusals throw
InvalidOperationExceptionwith a user-readable message — the middleware maps that to 409 and puts your message indetail. Write the message accordingly; it is user-facing. -
Activity logging is partly automatic and partly yours.
SaveChangesAsyncalready synthesises an activity row and an audit row for added, modified and deletedBaseAuditableEntityinstances. 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. UseSaveChangesAsync.
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.
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
Locationheader; 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.
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:
- Add a
CapabilityDefinitionrow toforge.api/Capabilities/CapabilityCatalog.cs. A code is permanent once shipped — it is written into installs'capabilitiesrows 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. - Decide
IsDefaultOndeliberately. Default-on means every fresh install gets it. - 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. - If it belongs in a user-facing module bundle, add it to
ModuleCatalog.cs. -
Add training content, or accept a red build.
TrainingCoverageRatchetTestsrequires every catalog capability to be claimed by a training seeder inforge.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.csseeder claiming it (or claim it from an existing one) in the same commit — and note thatTrainingContentShapeTestswill also check that every module has app routes starting with/and a quiz with a correct option.
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.
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 testWarnings 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.
forge-api · Apache 2.0 · built by Armory Works — product-level docs live on the Forge hub wiki; this wiki covers the .NET backend only.
Forge hub wiki — the product
This repo
On the hub
Peer repos
- forge-db — the schema
- forge-ui — the SPA
- forge-deploy — build and ship
- forge-test
- forge-voice