Skip to content

Project Layout

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

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 — the zero-project-reference core

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 (the Vector type appears on an entity) and Riok.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.core therefore compile without using Forge.Core.Entities; — and the same file copied into forge.api will 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.

forge.data — persistence and the schema artifact

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.

forge.integrations — one interface, one real, one mock

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.

forge.api — where most of the code is

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

Features/ and Controllers/ are a pair, not a hierarchy

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.

Middleware order

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.

forge.tests

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.