SystemForge Application Architecture¶
Status: Normative convention (v1) Applies to: every Go service built on SystemForge
SystemForge is not only a library collection — it is an opinionated way to build production Go services. This document is the convention: where code lives, how capabilities cross boundaries, how identity is modeled, and how a service is composed for deployment. Think of it as the "Rails for Go" application guide — convention over configuration, adapted to Go and to an AI-assisted, multi-service world.
The single most important property this architecture delivers:
A capability is a code boundary. A service is a deployment boundary. An application is a composition boundary. These are three different things, and deployment topology is reversible without changing business logic.
A SystemForge application starts as a modular monolith, can extract any capability into its own service when scaling or ownership demands it, and can recombine services back into one binary — all without rewriting the capabilities themselves. Monolith and microservices are not a one-time architectural commitment; they are per-deployment composition choices, and different deployments of the same codebase may make opposite choices at the same time.
Principles¶
- Convention over configuration. There is a preferred way to build a SystemForge application; follow it unless you have a concrete reason not to.
- Vertical slices over global technical layers. Product functionality
lives under
internal/feature/<slug>, not in globalhandlers/,services/,repositories/trees. - Foundation is singular; platform and feature are per-deployment. Identity/authn/authz is one shared system of record everywhere; other infrastructure and all features are instantiated per deployment.
- Contracts cross boundaries. Features and applications interact through explicit Go interfaces, never by reaching into each other's internals.
- Local-first composition. Use ordinary typed Go calls when components share a process; an API is a deployment adapter, not the default seam.
- Modular monolith first. Start with one deployable; extract a service only when operations or scaling justify it.
- Deployment topology is reversible. Splitting or recombining services must not alter core business logic.
- Secure and multi-tenant by default. Identity, authorization, tenancy, and observability come from SystemForge, not per-app reinvention.
- Product lines are separable. Data is partitioned so a product line can be cleanly separated onto its own instance — and later re-merged — without re-architecture (see Product-line separability).
- Architecture is machine-validatable. The dependency and structural rules here are intended to be lintable, not just documented.
The three layers¶
internal/
├── foundation/ identity, org/tenancy, authn, authz, sessions
│ — ALWAYS a single shared system of record (see Foundation)
├── platform/ db, observability, config, jobs, events
│ — reusable infrastructure, instantiated per deployment
└── feature/ product capability verticals (see Feature slices)
├── dashboard/
├── query/
└── notebook/
app/ public composition entrypoint (see Composition) — NOT internal
cmd/ deployment entrypoints (see Deployment topologies)
foundation differs from platform in exactly one way: a platform
capability may reasonably have one instance per deployment (each application
can run its own job queue), whereas foundation is always a single shared
identity / org system of record across every deployment. Foundation is the
one layer that never gets a "local copy per app."
The
dashboard/query/notebooknames above are illustrative capability examples used throughout this document. They are not prescribed features.
Dependency rules (normative)¶
feature → platform allowed
feature → foundation allowed (e.g. an authz check in a handler)
platform → foundation allowed
platform → feature DENIED
foundation → platform DENIED
foundation → feature DENIED
feature A → feature B only through feature B's contract.go
These rules are intended to be enforced by a dependency lint in CI, in the same spirit as the org's existing dependency-policy tooling. Until then they are enforced by review.
Feature slices¶
A feature owns its transport, application logic, domain types, and persistence. Conventional files — use the name where the responsibility exists; a small feature may have only two of them:
internal/feature/<slug>/
├── feature.go construction / registration (implements compose.Feature)
├── contract.go exported Go interface(s) other features/apps depend on
├── routes.go route registration
├── handler.go HTTP / Huma transport
├── service.go application / orchestration logic
├── repository.go persistence boundary
├── model.go feature-local domain / data types
└── client.go remote implementation of contract.go, when the
capability is consumed out-of-process
Rules:
- Files are conventional, not mandatory. Do not create empty files for
ceremony. A
healthfeature may be justfeature.go+handler.go. - Split by responsibility before splitting into subdirectories. Prefer
create.go/update.go/publish.goin one package over a prematurehandler/,service/,repository/subtree. Introduce subdirectories only when a feature grows independent sub-capabilities (e.g.dashboard/widget/,dashboard/sharing/). - Distinguish API DTOs from domain types. A handler translates the wire request into an application command; the HTTP JSON struct is not the internal model.
internal/feature/*is private. Cross-feature access goes through the other feature'scontract.go, never a deep import of its internals.
Persistence and the shared ORM client¶
repository.go is a persistence boundary, not necessarily a private
database. A generated ORM (Ent) emits a single client and a single model
package for the whole schema, so the physical data layer cannot be
partitioned one-client-per-feature. This is a deliberate, accepted carve-out
from strict per-feature encapsulation:
- The generated client is shared platform infrastructure
(
internal/platform/db), instantiated once per deployment. - Each feature defines a narrow
repository.gointerface over that shared client, exposing only the entities and queries that feature owns. Logical ownership is enforced by the repository interface and by review/lint, even though the client is physically shared. - Foundation identity entities (
Principal,Organization,Membership, …) live in the same generated client but are owned by foundation. Features read them through foundation contracts; a feature must not mutate identity tables directly. This is exactly the kind of cross-layer access the dependency lint should flag.
The rule of thumb: the client may be shared, but the queries a feature is allowed to run are scoped by its repository interface — a feature reaching into another feature's or foundation's tables directly is a violation even though the compiler permits it.
Contracts: in-process and remote¶
Every capability that another capability consumes exposes a Go interface
in contract.go. That interface is the boundary — the same one whether the
provider is in-process or across the network.
// internal/feature/query/contract.go
type Executor interface {
Execute(ctx context.Context, req Request) (Result, error)
}
A consumer depends only on the interface:
// internal/feature/dashboard/service.go
type Service struct {
query query.Executor // never *query.Service directly
}
Two implementations satisfy it. The local one is an ordinary Go type:
The remote one calls an API and implements the identical interface:
The consumer's code never changes; only construction (which implementation is injected) changes.
A Go interface contract and an API contract are related but not identical.
The Go interface expresses in-process semantics; the API/OpenAPI contract
expresses wire semantics (authn, scopes, status mapping, rate limits,
idempotency, versioning). Keep them semantically aligned, but do not
mechanically generate one from the other. The remote Client is an adapter
from the wire contract to the Go capability contract.
Rejected: a generic invocation bus. We deliberately do not adopt a
system.Invoke("query.execute", ...)-style bus. It trades Go's compile-time
checking, IDE navigation, explicit dependencies, and refactoring safety for
uniform-looking calls that are not uniform in failure mode. Every contract
stays a typed Go interface.
Composition runtime¶
SystemForge owns the composition contract so every repo implements the same seam instead of inventing its own. A small package in SystemForge defines:
// Feature is one registrable capability — a feature slice or a whole app.
type Feature interface {
Name() string // canonical slug (see Canonical slugs)
RegisterRoutes(r Registrar) error
Start(ctx context.Context) error // no-op allowed
Stop(ctx context.Context) error // no-op allowed
}
// Registrar abstracts the HTTP layer (Huma today) so features do not bind
// to a concrete server type at the contract level.
type Registrar interface { /* route-group registration surface */ }
A monolith is then just one composition of features:
app := systemforge.NewApp(cfg)
app.Register(
dashboard.New(deps),
query.New(deps),
notebook.New(deps),
)
app.Run()
Crucially, an app is itself a Feature (it aggregates its internal
features and re-exposes them under one Name()). That is what makes
composition recursive: a host registers a whole application exactly the way
an application registers one of its own features.
Horizontal and vertical applications¶
Whether an application is "horizontal" or "vertical" is a go-to-market classification, not a structural fork. Every SystemForge repo has the same internal shape. What differs is only who composes it and how.
- A horizontal capability application is a repo whose feature contracts
get embedded by other applications and which also composes its own
features into a standalone deployment via its own
cmd/. Embedded mode (its capabilities mounted inside another product) and standalone mode (its own product) are the same feature contracts consumed by two different composers — one of them being the repo's own binary. - A vertical product application is a repo that composes its own features plus imported horizontal capabilities into one product.
Some applications are offered both ways; that is a licensing/packaging decision, not a second codebase.
The public app/ package¶
internal/feature/* stays private, but each repo's composition entrypoint
must be public, because it is the seam other repos compose against:
<module>/app // NOT internal/app
func New(cfg Config) (*App, error) // *App implements compose.Feature
- A horizontal capability's own
cmd/callsapp.Newto run standalone. - A vertical product imports the horizontal capability's
apppackage (or individual feature packages plus theircontract.go) to embed those capabilities inside its own binary. - A composition repo does the same one level up: import two vertical
products'
apppackages and register both into one binary — mechanically no different from a vertical product importing a horizontal capability.
Go's internal/ visibility is exactly why the composition entrypoint lives
at app/ and not internal/app/: an external module cannot import another
module's internal/ tree. Wiring that is genuinely private to a repo
(foundation+platform+feature assembly detail) may still live under
internal/; only the composition surface is public.
Wiring mechanism (normative)¶
Composition is compile-time; selection is startup-time. Both the local implementation and the remote client may be compiled into the same binary, and the deployment's startup configuration decides which is constructed. There is no runtime re-wiring of a live process and no dynamic plugin loading.
Distinct topologies are normally expressed as distinct cmd/ entrypoints —
a monolith cmd/<app> and per-capability cmd/<capability>-service
binaries — each reading the same config shape:
{
"capabilities": {
"query": { "deployment": "local" },
"notebook": { "deployment": "remote", "endpoint": "https://notebook.internal" }
}
}
We do not (initially) make this dynamic at runtime. Compile-time composition with explicit entrypoints is simpler and safer; the architecture model understands the local/remote distinction, which is what matters.
Canonical feature slugs¶
One slug per feature drives every surface. Given the slug dashboard:
| Surface | Value |
|---|---|
| Go package | internal/feature/dashboard |
| TypeScript | web/src/features/dashboard |
| UI route | /dashboards |
| BFF API | /api/dashboards |
| REST API | /api/v1/dashboards |
| OpenAPI tag | dashboard |
| Permission prefix | dashboard.* |
| OAuth scope | dashboard:{resource}:{verb} (see authz conventions) |
| Telemetry | dashboard.* |
The identity is the singular slug (dashboard); URLs may use the
conventional plural resource form (/dashboards). Do not let each surface
independently invent names (dashboard, dashboards, dash,
dashboard-manager) — the inconsistency is expensive for humans, code
generators, the policy engine, observability, and AI agents alike.
BFF vs. public REST¶
BFF and public REST share the same capability request/response schemas and application contract by default, differing only in authentication, throttling, and exposure policy — not semantics:
Dashboard capability (one application contract)
│
┌─────────────┴─────────────┐
BFF API REST API
session cookie auth OAuth / API token
browser throttling API throttling
CSRF / origin token scopes
└─────────────┬─────────────┘
│
dashboard.Service
The BFF must not become a second backend: it handles authentication
translation, session/cookie handling, CSRF, browser policy, and rate
limiting — not duplicated business logic. The BFF may additionally expose
UI-composition endpoints (e.g. GET /api/ui/dashboard-page/123
aggregating several capabilities for one screen). Those are explicitly not
part of the public REST contract and must not pollute it.
Authentication provenance stays out of the request schema. The same application method serves a browser (session → user principal), an API client (OAuth token → API principal), and a service (workload token → service principal); the feature does not care how the principal arrived.
Foundation: the identity model¶
Foundation is the one always-shared layer. Its model is built on
SystemForge's identity package (a Principal unified root with
Human/Application/Agent/ServicePrincipal extensions,
PrincipalMembership, and Organization), extended for multi-product and
product-separability concerns.
Principal vs. Organization¶
Principalis the global identity of a person or workload. Human-type principals are global by construction (no single-org scope); org access is expressed exclusively throughPrincipalMembership.Organizationcarries anapp_idnaming the product line it belongs to — the unit that is deployed, billed, and (if ever) separated as a whole.
app_id names a product line, never a horizontal capability. When a
product embeds horizontal capabilities, there is still exactly one
Organization for that product; the embedded capabilities are entitlements
within that org, expressed through the authorization
conventions's per-app vocabularies, roles, and
scopes. A composed multi-product binary still has per-product Organizations;
the app switcher spans them.
One principal ≠ access to every product¶
A Principal existing does not imply access to every product. The UI app
switcher queries the principal's memberships, groups by Organization.app_id,
and shows only the products the principal actually has a membership in. SSO
covers authentication (one principal, one session); it never implies
authorization to a different product.
Email and identity linking¶
A principal may hold multiple email addresses (one PrincipalEmail row
each). Each address is verified or not:
- A verified email is globally unique within a foundation instance and is valid identity evidence.
- An unverified email is user-supplied text and MUST NOT be used as an identity-linking signal anywhere in SystemForge — not for merge, not for account linking, not for "is this the same user" heuristics. Anyone can claim any address they do not control.
This rule governs every account-linking flow (OAuth-provider linking, SSO, and instance merge), not only one case.
Product-line separability and data portability¶
Because every Organization/Membership row is partitioned by app_id, a
product line's identity and org data can be cleanly separated onto its own
foundation instance — a filtered export of the Principal rows referenced
by that product's memberships plus all rows for that app_id — without
re-architecting anything. This is a deliberate design goal: product lines are
data-portable and independently operable, not welded together by a shared
schema.
The separation preserves each Principal.id and stamps the new instance with
forked_from / forked_at provenance, so two instances that shared an
ancestor can later be reconciled/merged. Merge is a verified-email-only,
step-up-confirmed reconciliation — never a silent auto-merge. The data model
makes both directions possible; the export/merge tooling is built when a
concrete need for it exists.
Token profiles (target convention)¶
The following is the target SystemForge credential model. It is documented here as convention; its full implementation is progressive.
| Profile | Representation | Use |
|---|---|---|
web-session |
opaque / encrypted HttpOnly cookie | Browser ↔ BFF |
access-token |
minimal signed JWT (JWS) | BFF / API / service calls |
confidential-token |
encrypted JWT (JWE) | Claims needing confidentiality |
Rules:
- Browsers get an opaque/encrypted HttpOnly session cookie, not a reusable OAuth bearer token. OAuth begins at the BFF/API boundary. JavaScript never reads the credential.
- The BFF statelessly exchanges validated web-session claims for
short-lived, audience- and scope-restricted API tokens. Neither the browser
session nor the API token should require persistent per-token storage for
normal validation; centralized state is reserved for authorization policy,
key management, and exceptional revocation (
Principal.security_epoch). - Minimize claims. No sensitive or unnecessary information belongs in a plaintext signed JWT merely because it is signed — signing protects integrity, not confidentiality. Prefer opaque identifiers and minimal routing/validation claims; resolve richer authorization from foundation when needed.
- Use browser/device fingerprint or DPoP (RFC 9449) for adaptive risk / anti-replay, not as the primary authorization mechanism. Unverified fingerprints are a signal, never a credential.
Deployment topologies¶
The same codebase supports a progression, and different deployments may sit at different points simultaneously:
Stage 1 One binary — all features + platform (+ shared foundation)
Stage 2 Feature extraction — monolith + selected feature services
Stage 3 Platform extraction — feature services + platform services
Stage 4 Mixed topology — whatever combination makes operational sense
Stage 4 is expected to be the normal mature state, not "everything becomes a microservice." Some capabilities benefit from independent scaling or trust boundaries; many are cheaper and more reliable in-process. Foundation remains one shared identity system of record across all of them.
Frontend alignment¶
The TypeScript frontend mirrors the conceptual feature boundaries, not the exact filesystem:
web/src/
├── app/ composition, routing, providers
├── features/ product capabilities (aligned slugs with the backend)
│ ├── dashboard/ { api.ts, hooks.ts, components/, pages/, index.ts }
│ ├── query/
│ └── notebook/
├── platform/ cross-cutting frontend infra (auth, api client, telemetry)
└── design-system/ generic UI primitives
Rules:
- Aligned slugs.
features/dashboard↔internal/feature/dashboard. Frontend directories may be plural; backend Go packages are singular. - A feature owns its UI. Do not scatter one capability across global
pages/,hooks/,api/trees. - Public feature surface via
index.ts— the TypeScript analogue of Go'scontract.go. Cross-feature imports go through it, not deep paths. - Not every backend feature has a frontend feature (audit, jobs, webhooks are API-only), and some frontend features (home, admin, onboarding) orchestrate several backend capabilities.
- Frontend dependency rules mirror the backend:
app → features → platform / design-system;platform/design-systemnever importfeatures; cross-feature is default-deny.
Related conventions¶
- Authorization conventions — the
AppVocabularycontract, app-namespaced resource types,{app}:{resource}:{verb}scopes, the two-gate model, and the shared SpiceDB base schema. This architecture'sfeature → foundationauthz calls and per-app entitlement model build on it.