Line

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.