Overview
Architecture
Layers, rules, policy and how validation works.
This page covers the architecture Construct enforces on target projects: layers, rules and policy. For how Construct itself is organised as code, see the repository layout.
Philosophy#
Construct is intentionally opinionated out of the box but modifiable by policy. A convention is valuable when a team can understand it, enforce it, and deliberately change it.
Feature ownership#
A feature owns behavior, state, UI, services, domain rules, types and its public API. Internal files are private. Cross-feature consumers use index.ts.
Layer contracts#
Route#
Navigation entry point. Delegates to a controller. No business logic or effects.
Controller#
Application composition boundary. Connects application behavior to presentation.
Workflow#
Application flow and state. XState is the default implementation; Stately is the visual modeling/editor layer.
Hook#
React-aware business/application logic when React context is genuinely required.
Domain#
Pure feature-specific business rules. No network, storage, DOM, or hidden effects.
Service#
External I/O, API calls, SDKs and side effects.
Page#
Props to JSX. No business logic, workflow, service, API, fetch, or domain imports.
Component#
Reusable presentation. Local UI state is allowed; application/business dependencies are not.
Frozen, externally-authored presentation#
A design tool (Subframe, Figma-to-code, a shared design system) may own some markup. Declare it under frozen: in architecture.yml (globs relative to the project root, allowed to reach outside it). The supported pattern is controller wraps frozen component: the controller imports the externally-authored component and forwards props; hooks/workflows/domain own data and state. Construct never writes into frozen paths, and PAGE-007 / COMPONENT-004 / CONTROLLER-002 (default warning) flag pages, components and controllers that re-author markup already present in a frozen source instead of importing it. Projects without frozen: are unaffected. See the README section "Wrapping frozen, externally-authored UI".
Project-local rules#
A project can add its own rules alongside Construct's built-ins, in the same catalog shape ({id, module, layers, scope, defaultSeverity, why, expected, detect}): list their file paths under localRules: in architecture.yml, one path per rule.
localRules:
- local-rules/no-console-log/rule.yml
Each rule file is plain YAML (packages/core/local-rules.mjs), never code: detect is one of a small, closed set of declarative detector kinds (forbiddenImport, forbiddenPattern, requiredPattern) — a project cannot smuggle in arbitrary JS through a rule file. Running actual project code during construct validate is a separate, bigger decision (Notice Board ) this feature does not make.
construct add-rule <id> [--module <m>] [--layers a,b] [--scope buffer|project] [--severity <s>] [--why "..."] scaffolds local-rules/<id>/rule.yml plus a violates.ts / passes.ts fixture pair that already trips/doesn't-trip the generated rule, so there's something real to construct validate and edit from before it's wired in — it never edits architecture.yml itself; add the printed path to localRules: by hand once the rule is ready.
Enforcement#
Construct combines project policy with static validation and dependency boundaries. The goal is not to create a second compiler; it is to make architectural intent executable and useful to AI agents. For the full rule table — every rule id, its default severity and what it checks, layer by layer, plus the Expression-layer design (typed templates, tracked state, composable units) — see docs/ARCHITECTURE-RULES.md.
Shared building blocks#
Construct's own implementation reuses small deterministic packages rather than re-implementing them: see docs/capabilities.md for the maintained inventory, and packages/ast/README.md for the AST package (parse, walk, extract, generate) used by both the CLI and ui/server.
The default layer graph#
Route → Controller → Workflow → Service → API
└────→ Page → Component
Workflow → Domain
Service → Domain
Hook → Workflow / Service / Domain
This layer graph — and every rule in rules: — is identical regardless of project.framework. The one thing that differs per framework is what a Route physically is and how a Controller gets wired into it; see Framework targets.
Layer folder names#
Each feature-internal layer owns one folder under features/<feature>/, by default:
| Layer | Default folder | Layer | Default folder |
|---|---|---|---|
| Controller | controllers/ |
Domain | domain/ |
| Workflow | workflows/ |
Page | pages/ |
| Hook | hooks/ |
Component | components/ |
| Service | services/ |
Expression | expressions/ (optional/additive — see EXPR-* rules) |
A project that already names these folders differently (singular, or any other convention) overrides them under layers: in architecture.yml — one entry per layer, by name, not by folder:
layers:
service:
pattern: "features/*/service/**" # was features/*/services/**
pattern fully replaces that layer's default glob; everything else about the layer (its canImport edges) is left alone unless given too. To extend (not replace) what a layer may import, use addCanImport instead of canImport:
layers:
hook:
addCanImport: [controller] # adds to hook's existing canImport, doesn't replace it
A layers: override is merged onto the framework's defaults (layersForFramework, packages/core/config.mjs) — it is never silently ignored, and both construct validate and every other command that reads architecture.yml (loadConfig) see the same, merged graph. A malformed override (an unknown canImport edge, a cycle) is rejected with a diagnostic naming the bad edge, not silently dropped. fixtures/architecture-valid-singular-layers/ is a complete, checked-in worked example with six layers renamed to singular folders.
Framework targets#
Construct's feature-internal architecture (everything from Controller down: Workflow/Service/Domain, Controller → Page → Component) never changes based on project.framework — only the route layer does, because that's the one place a real difference in how the two kinds of app actually route requests shows up.
nextjs (default) |
react-spa |
|
|---|---|---|
| Route layer pattern | app/**/page.tsx — one file per route folder (Next.js App Router file-system routing: route groups (name), dynamic segments [name]) |
src/App.tsx — one centralized react-router table for the whole app |
| How a controller gets registered as a route | A physical app/<route>/page.tsx imports the controller and renders it as the page's default export |
A <Route path="..." element={<XController />} /> entry inside src/App.tsx |
construct init [--framework ...] scaffold |
app/page.tsx |
src/main.tsx (a real bootstrap: BrowserRouter + createRoot) + src/App.tsx, with the core feature's controller already registered as a route |
construct import --route <url> entry resolution |
Walks app/ for the folder owning page.tsx, matching route groups/dynamic segments (packages/core/route-resolver.mjs) |
Parses src/App.tsx's <Route> table for the controller name registered at that URL, then locates it under features/*/controllers/ |
Everything else — every generator, every rule, the controller's own composition (it still imports and renders a same-named Page from the feature's pages/ folder either way, since that's Construct's own convention, not Next.js's) — is identical between the two targets. Set it in architecture.yml:
project:
framework: react-spa # or nextjs (default)
language: typescript
fixtures/architecture-valid-react-spa/ is a complete, real, checked-in worked example — construct validate passes against it with zero errors — showing the shape end to end: architecture.yml, src/main.tsx + src/App.tsx, and a full widget feature slice.
How construct validate works#
validate loads architecture.yml (packages/core/config.mjs), then runs a fixed list of enforcers (packages/engine/defaultEnforcers.mjs) and merges their findings:
| Enforcer | Module | Checks |
|---|---|---|
architecture |
packages/core/architecture-enforcer.mjs |
who may import whom, per layer; import resolution; frozen-markup rules |
separation-of-concerns |
packages/core/soc-enforcer.mjs |
responsibilities that do not belong together (SOC-001) |
readability |
packages/core/readability-enforcer.mjs |
naming, file length, public-API documentation (READ-*) |
public-api-drift |
packages/core/api-composer.mjs |
a feature's index.ts matching what it should export |
Every finding is a violation object built by makeViolation (packages/core/diagnostics.mjs) with a fixed shape: rule, module, severity, file, line, message, why, expected, suggestedFix and optionally docsUrl. --format json prints them as { status, violations }. The process exits with 0 (no errors), 1 (violations), 2 (usage error) or 3 (internal error). Severity comes from architecture.yml; a scoped, time-boxed exceptions: entry can silence a rule for a path.
The layer graph itself (packages/core/architecture-graph.mjs) is data, not code: which layer may import which. The enforcers and the generators both read it, so they cannot disagree.
Because the same enforcers run inside generators (packages/engine/transactionalWriter.mjs validates a shadow copy before committing), generated output is held to the rules it will later be checked against.