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".
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.
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.
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.