-
Notifications
You must be signed in to change notification settings - Fork 0
Project Layout
Five projects, listed in forge.slnx, all targeting the SDK version pinned in global.json. The dependency direction is one-way and worth internalising before you add a file, because "where does this go" is almost always answered by "which project is allowed to see what it needs".
forge.core entities · enums · interfaces · models · settings (no project references)
forge.data EF Core, AppDbContext, entity configuration, repos → core
forge.integrations outbound adapters, each with a mock twin → core
forge.api features, controllers, capabilities, jobs, hubs → core + data + integrations
forge.tests everything above → core + data + api
forge.core references no other project, and that is a load-bearing constraint rather than an aesthetic one: it is the shared vocabulary, so anything that lands in it becomes visible everywhere, and anything that would drag EF Core, ASP.NET or an HTTP client into it would make the constraint meaningless. It holds Entities/, Enums/, Interfaces/, Models/, Settings/, Constants/, plus two domain namespaces that are pure computation — Costing/ and Sequences/.
Two things surprise people:
-
It is not package-free. It carries
Pgvector(theVectortype appears on an entity) andRiok.Mapperly(the source generator's abstractions). Adding a third package reference here deserves a conversation, because it is the one project every other project inherits. -
It declares global usings for its own entity and interface namespaces. Files inside
forge.coretherefore compile withoutusing Forge.Core.Entities;— and the same file copied intoforge.apiwill not. That asymmetry is in the csproj, not in the editor.
Sequences/ deserves a specific note: SequenceEvaluator is a pure function over (net, instance, verdicts, now). It touches no storage, no clock and no gate source, and the storage and DI live over in forge.api/Services. Keep it that way — it is what makes the gated-sequence engine unit-testable and re-entrant.
Context/AppDbContext.cs is the centre of gravity here and is unusually behaviour-rich for a DbContext. It applies snake_case naming to every table, column, key, foreign key and index; normalises unspecified-kind timestamps to UTC; stamps CreatedAt/UpdatedAt/DeletedBy; installs the soft-delete global query filter; and synthesises activity-log and audit-log rows on save. Conventions covers the consequences.
| Folder | What belongs in it |
|---|---|
Context/ |
AppDbContext and its partials |
Configuration/ |
One IEntityTypeConfiguration<T> per entity — this, not data annotations, is where mapping actually lives in this codebase
|
Repositories/ |
Repository implementations for the aggregates that have one; the interfaces sit in forge.core/Interfaces
|
Interceptors/ |
The SaveChanges interceptor enforcing posted-ledger immutability |
Extensions/ |
ActivityLogExtensions.LogActivityAt and the bulk-operation helpers |
Schema/ |
forge-schema.sql — the assembled desired-state DDL, embedded as a resource |
SchemaBootstrapper.cs |
Applies that file to a fresh database; a no-op against an existing one |
Schema/forge-schema.sql is generated, not written. It is the assembled output of forge-db's schema/ tree — extensions, tables, foreign keys, indexes, functions, triggers, in dependency order — and includes the parts EF's model cannot express, notably the vector extension and the accounting-journal immutability triggers. Regenerate it with the forge-db CLI's assemble command; the Schema drift check workflow re-assembles forge-db and fails on any difference. See forge-db's wiki for how that tree is organised.
Every outbound dependency follows the same triple: an interface in forge.core/Interfaces, a real implementation here, and a mock twin here beside it. Program.cs registers the mock or the real one from the MockIntegrations setting, which is on in Development — which is why accounting, shipping, e-signing and AI all appear to work locally with no credentials configured. They are returning fixtures.
Where an install can run several providers of the same kind, resolution goes through a factory rather than a direct injection — accounting, e-commerce, cloud storage and form-definition building each have one. Injecting the bare interface binds whichever registration happened to resolve last, which is a bug that only appears on a multi-provider install.
The honest state of the adapter set is mixed: some are complete (QuickBooks Online, Ollama, MinIO, USPS address validation, Shopify and WooCommerce), some are interface-and-mock-only, and a few carriers and marketplaces are deliberately unregistered so the factory throws an honest NotSupportedException instead of a stub pretending to poll. The install's readiness report is the authority for a given box — see the hub's Configuration and Integrations.
This is the web host and the bulk of the repository.
| Folder | What belongs in it |
|---|---|
Features/ |
CQRS lives here. One folder per feature area, one file per operation, each holding the request record, its FluentValidation validator and its MediatR handler |
Controllers/ |
Thin HTTP edges. Route, authorise, gate, mediator.Send, shape the status code. No try/catch, no business logic
|
Capabilities/ |
CapabilityCatalog (the source of truth), the relations graph, the module catalog, the snapshot provider, the gate middleware, the attributes |
Behaviors/ |
MediatR pipeline: validation, the capability gate, domain-event exception handling |
Middleware/ |
Exception handling, security headers, idempotency, shared-device, audit context |
Services/ |
Stateful or cross-handler services that do not fit one operation — sequence evaluation, database transfer, terminology, and similar |
Jobs/ |
Hangfire job classes. Every job method takes a CancellationToken
|
Hubs/ |
The SignalR hubs, mounted outside the api/v1 prefix |
Workflows/ |
Workflow definitions, entity adapters and readiness validators — note that the i18n lint in forge-ui scans these files for server-supplied label keys |
Data/ |
Seed data (SeedData.*.cs) and TrainingContent/, one seeder class per training module |
Authentication/ · Authorization/
|
The two API-key authentication handlers; the Hangfire dashboard filter and the kiosk-terminal attribute |
Concurrency/ |
The If-Match filter and the per-type version lookup behind optimistic locking |
Validation/ |
The model-binding error factory that replaces ASP.NET's raw JSON-path text |
Bootstrap/ |
Startup hydration that is not seeding — integration mode, options hydration, identifier backfill |
The shape is deliberately flat: a controller for an aggregate root, and beside it a feature folder whose files are named for operations (CreateCompanyLocation.cs, SetDefaultCompanyLocation.cs). A controller action's whole job is to translate HTTP into a MediatR request and a result into a status code. If you find yourself wanting a private method on a controller, the logic belongs in the handler.
The one-object-per-file rule bends exactly once, and on purpose: a feature file may hold the request record, the result record, the validator and the handler together, because they are one operation. The ratchet's tripwire is five or more top-level type declarations in a file, which is what makes that grouping legal and a grab-bag illegal.
Set in Program.cs, and it matters:
forwarded headers → security headers → request logging → exception handling → CORS → rate limiter
→ routing → session → shared device → authentication → authorization
→ idempotency → capability gate → audit context → controllers/hubs
Two consequences to hold on to. Exception handling is registered before routing, so it wraps everything downstream including the capability gate's own throw path. And idempotency wraps the capability gate, so a capability-disabled 403 is stored under the caller's idempotency key like any other sub-500 response — retrying the same key after an admin enables the capability replays the stored 403 until the retention window expires. Use a fresh key.
Organised by subject rather than by test kind — Handlers/, Features/, Integration/, Persistence/, Accounting/, Workflows/, Sequences/ and so on — with Architecture/ holding the ratchet tests and Helpers/ holding the fixtures. There are no xUnit category traits, so you filter by namespace. Testing covers what needs Docker and what does not.
Note that forge.tests references forge.api, forge.core and forge.data, but not forge.integrations — it reaches the mock adapters and the clock implementations transitively through forge.api.
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