Skip to content

Architecture Presets ​

Presets are parameterized architecture rule bundles. One function call generates multiple coordinated rules. Use presets as the starting point for new projects — they encode proven patterns from real production codebases.

typescript
import {
  layeredArchitecture,
  strictBoundaries,
  dataLayerIsolation,
} from '@nielspeter/ts-archunit/presets'

Every preset returns an array of rules (RuleBuilderLike[]) — one uniform model. Spread it into a CLI rule file, or run it in a test with checkAll:

typescript
// arch.rules.ts (CLI)
export default [...layeredArchitecture(p, { layers }), ...recommended(p)]

// or in a vitest/jest test
checkAll(layeredArchitecture(p, { layers }))

The examples below show each preset's options; wire the call in with one of those two forms.

layeredArchitecture ​

The most universal architecture pattern. Nearly every backend project has layers — routes/controllers at the top, services in the middle, repositories/data access at the bottom. The rule is simple: dependencies flow downward, never upward. A repository must never import from a route. A service must never reach into the HTTP layer.

layeredArchitecture enforces this with a single function call. You define your layers in order (top to bottom) and it generates 5 coordinated rules: dependency direction, cycle freedom, innermost isolation, type-import enforcement, and package restrictions.

typescript
layeredArchitecture(p, {
  layers: {
    routes: '**/src/routes/**',
    services: '**/src/services/**',
    repositories: '**/src/repositories/**',
  },
  shared: ['**/src/shared/**', '**/src/utils/**'],
  strict: true,
})

Layer order matters — the first layer depends on the second, the second on the third, etc. In this example: routes → services → repositories. A repository importing from routes is a violation.

Generated rules ​

Each generated rule has a stable ID (for overrides) and a default severity. The preset runs all rules and aggregates violations — you see every problem in one error, not one rule at a time.

Rule IDWhat it enforcesDefault
preset/layered/layer-orderDependencies flow inward onlyerror
preset/layered/no-cyclesNo circular dependencies between layerserror
preset/layered/innermost-isolationInnermost layer imports only from itself + shared (strict mode)error
preset/layered/type-imports-onlyCross-layer type imports allowed, value imports forbiddenwarn
preset/layered/restricted-packagesOnly specified layers may import restricted packageserror

strict mode ​

When strict: true, the innermost layer (last in the layers object) is fully isolated — it can only import from itself and the shared folders. This prevents repositories from reaching into services or routes.

typeImportsAllowed ​

Some layers need to reference types from other layers without taking a runtime dependency. typeImportsAllowed specifies which layers may use import type across layer boundaries:

typescript
layeredArchitecture(p, {
  layers: { ... },
  typeImportsAllowed: ['**/src/services/**'],
  // Services can `import type { User } from '../repositories/user-repo.js'`
  // but not `import { findUser } from '../repositories/user-repo.js'`
})

restrictedPackages ​

Enforce that certain npm packages are only imported by specific layers. The key is the layer that IS allowed — all other modules in the project are forbidden:

typescript
layeredArchitecture(p, {
  layers: { ... },
  restrictedPackages: {
    '**/src/repositories/**': ['knex', 'prisma'],
    '**/src/infra/**': ['@aws-sdk/*'],
  },
})

This generates: "all modules NOT in src/repositories/** must not import knex or prisma". If multiple layers list the same package, the union of those layers may import it.

importOptions — aligning the type-import question ​

Since 0.59.0. Applies to layeredArchitecture and strictBoundaries.

These two presets construct conditions that disagree by default, on purpose:

  • beFreeOfCycles() ignores type-only imports. It asks whether the module is evaluated, and an import type is erased at compile time, so it cannot contribute to an initialization cycle.
  • respectLayerOrder() and the isolation conditions count them. They ask whether the code is coupled, and a shared type is coupling.

Holding a builder you choose per condition, where the distinction is visible. Through a preset it is invisible — so importOptions is one bag meaning this project's answer to "is a type-only edge a dependency?", applied to every rule the preset constructs whose condition takes one.

Two rules are excluded, because the bag has no answer to give them: preset/layered/type-imports-only (onlyHaveTypeImportsFrom asks about type imports as its subject) and preset/boundaries/no-duplicate-bodies (it compares function bodies, not imports). Every other rule in both presets is covered.

Passing it moves exactly one side, and which side depends on the value:

you passthe cycle rulethe layer / isolation rules
(nothing)ignores type-only edgescounts them
{ ignoreTypeImports: true }unchangedstops counting type coupling
{ ignoreTypeImports: false }starts counting type edgesunchanged
ts
layeredArchitecture(project, {
  layers: { routes: '**/routes/**', services: '**/services/**' },
  // Our team treats a shared type as a real dependency everywhere.
  importOptions: { ignoreTypeImports: false },
})

dataLayerIsolation ​

Companion to layeredArchitecture. Enforces repository pattern conventions that layer ordering alone cannot catch: base class extension and typed error throwing.

typescript
dataLayerIsolation(p, {
  repositories: '**/src/repositories/**',
  baseClass: 'BaseRepository',
  requireTypedErrors: true,
})

Generated rules ​

Rule IDWhat it enforcesDefault
preset/data/extend-baseAll classes in repositories extend the base classerror
preset/data/typed-errorsNo new Error() in repositories — use typed errorserror

Both rules are optional — omit baseClass to skip the extension check, omit requireTypedErrors to skip the error check. Omitting both is different from skipping one: with neither flag set this call constructs zero rules, and reports a preset/data/constructs-nothing configuration finding rather than passing silently — the same finding agentGuardrails reports below, for the same reason. Set at least one flag to enforce anything.

strictBoundaries ​

For projects with distinct feature areas (modules, bounded contexts, packages). Prevents cross-contamination between boundaries.

typescript
strictBoundaries(p, {
  folders: '**/src/features/*',
  shared: ['**/src/shared/**', '**/src/lib/**'],
  isolateTests: true,
  noCopyPaste: true,
})

Generated rules ​

Rule IDWhat it enforcesDefault
preset/boundaries/no-cyclesNo circular deps between boundary folderserror
preset/boundaries/no-cross-boundaryEach boundary imports only from itself + sharederror
preset/boundaries/shared-isolationShared folders don't import from boundarieserror
preset/boundaries/test-isolationTest files don't import from other boundaries' testserror
preset/boundaries/no-duplicate-bodiesNo copy-pasted function bodies across boundarieswarn

Boundary folders are discovered dynamically from the glob pattern. src/features/* finds all immediate subdirectories under src/features/.

A deliberately thin, universal safety floor for any TypeScript project — the handful of things dangerous regardless of project shape that fire ~never on healthy code. It is not a full architecture; shape-specific rules (layer order, cycles, delegation) are yours to add.

Like every preset, it returns severity-carrying builders, so spread it into the default export:

typescript
import { project } from '@nielspeter/ts-archunit'
import { recommended } from '@nielspeter/ts-archunit/presets'

const p = project('tsconfig.json')

export default [
  ...recommended(p),
  // ...your shape-specific rules
]

Generated rules ​

Rule IDEnforcesDefault
preset/recommended/no-evalNo eval()error
preset/recommended/no-function-constructorNo Function constructorerror
preset/recommended/no-silent-catchNo empty/silent catch blockswarn
preset/recommended/no-empty-bodiesNo empty function bodieswarn

Two error, two warn. The warn rules have known, suppressible false positives (intentional empty catches, no-op callbacks), so they surface without failing the build.

Options. include is the source glob (default '**/src/**', matched against each file's absolute path). A **/src/** glob already covers monorepos — packages/foo/src/** matches at any depth — so only projects whose source lives outside any src/ folder (e.g. lib/) need to override it:

typescript
export default [...recommended(p, { include: '**/lib/**' })]

The overrides map (below) changes individual rule severity. Codegen, templating, or serializer libraries that legitimately build functions from strings should turn off the Function-constructor rule: overrides: { 'preset/recommended/no-function-constructor': 'off' }. (eval has no comparable legitimate use, so it stays error.)

Adoption. The floor is designed to fire ~never on healthy code, so adopting it is usually a non-event. If a legacy codebase does trip the rules — an existing eval, or a wall of empty catches — snapshot them once with --baseline; the baseline captures all four severities, so only new violations surface afterward (see Baseline).

Stability. recommended is a versioned contract, not just a convenience alias: spreading ...recommended(p) means "these four rules today, and we won't break your CI on a minor bump." New rules enter at warn or off in a minor release and are only promoted to error in a major. That opt-in ladder is the reason to depend on the preset rather than hand-copy the four rules.

Overlaps agentGuardrails on empty bodies and (if you list 'eval' in its noInlineLogic) eval. Running both double-reports those locations under different rule ids. For agent-focused projects prefer agentGuardrails alone; otherwise silence the recommended copies — overrides: { 'preset/recommended/no-empty-bodies': 'off' }.

agentGuardrails ​

Targets the mistakes AI coding agents make most often — inline logic, generic errors, stub comments, empty bodies, copy-paste. Where the presets above enforce where code goes, agentGuardrails enforces how it is written. See AI Agents for the full workflow.

Like every preset, it returns severity-carrying builders, so you spread it into a rule file's default export:

typescript
import { project } from '@nielspeter/ts-archunit'
import { agentGuardrails } from '@nielspeter/ts-archunit/presets'

const p = project('tsconfig.json')

export default [
  ...agentGuardrails(p, {
    src: '**/src/**',
    noInlineLogic: ['parseInt', 'JSON.parse', 'eval'],
    noGenericErrors: true,
    noStubs: true,
    noEmptyBodies: true,
    noCopyPaste: true,
  }),
]

Generated rules ​

Rule IDEnforcesDefault
preset/agent/no-inline-logic/<api>No inline call to a banned API (one per entry)error
preset/agent/no-generic-errorsNo throw new Error() — use typed errorserror
preset/agent/no-stubsNo TODO/FIXME/"not implemented" stub commentserror
preset/agent/no-empty-bodiesNo empty function bodieserror
preset/agent/no-copy-pasteNo near-identical function bodies (≥ 0.9 similarity)warn

Uses function-variant rules, so standalone functions, arrow functions, and class methods are all covered. Each rule carries because / suggestion / imperative metadata so the agent gets an actionable fix in explain --format agent and check --format json. Accepts the same overrides map as every preset (below).

Every rule above sits behind its own optional flag — nothing is unconditional. Calling agentGuardrails(p, { src }) with none of them set constructs zero rules, and reports a preset/agent/constructs-nothing configuration finding rather than passing silently: satisfying the required src field is not the same as enforcing anything. Set at least one flag to enforce anything.

agentGuardrails overlaps a general recommended floor on empty bodies and eval. Running both double-reports those locations (different rule ids). For agent-focused projects, prefer agentGuardrails alone; otherwise override the duplicated ids to 'off' in one preset.

Overrides ​

Every preset accepts overrides to change individual rule severity:

typescript
layeredArchitecture(p, {
  layers: { ... },
  overrides: {
    'preset/layered/type-imports-only': 'off',    // disable completely
    'preset/layered/no-cycles': 'warn',            // downgrade to warning
  },
})

Three severity levels: 'error' (fails the run), 'warn' (reported but never fails a rule that works — surfaces in terminal / JSON / GitHub output and is baseline-filterable), 'off' (skipped entirely). Unrecognized override keys emit a warning — catches typos.

'warn' does not cover a rule that cannot enforce anything. A configuration finding — a dead glob, a missing assertion, or (since 0.59.0) a rule that examined zero units — is reported at error regardless of the severity you set, and overrides: { id: 'warn' } cannot downgrade it. The distinction is deliberate: 'warn' grades how strictly you want violations of a working rule treated, and a rule that enforces nothing has no violations to grade. Four shipped preset rules default to 'warn' and are affected — preset/agent/no-copy-paste, preset/boundaries/no-duplicate-bodies, preset/recommended/no-silent-catch and preset/recommended/no-empty-bodies.

Before reaching for 'off', read the next section. If the rule is red because it has nothing to check — not because you disagree with it — expectEmpty says that and expires when it stops being true. 'off' deletes the rule and never expires.

expectEmpty — declaring that a rule has nothing to check ​

Since 0.59.0. Applies to every preset.

A rule that examines nothing passes without checking anything. When you hold a builder you say so with .expectEmpty(); a preset user holds none, so presets accept the same declaration by rule id:

ts
agentGuardrails(project, {
  src: '**/src/**',
  noCopyPaste: true,
  // This package has no duplicate-body surface yet — say so, rather than
  // disabling the rule and forgetting.
  expectEmpty: ['preset/agent/no-copy-paste'],
})

Prefer it to overrides: { id: 'off' }. off deletes the rule permanently; expectEmpty states a fact about today that a later release can hold you to. Three guardrails come with it:

  • An id that names no constructed rule fails, unsuppressably — including a rule you also set to 'off', since off means it was never built and the declaration applies to nothing.
  • A dead glob is not declarable. A selector that can never match is a mistake, not a state, and no declaration hides it.
  • A false declaration fails, and does not hide what it was covering. The day the selection fills you get an unsuppressable finding naming the id — and the rule's real violations are reported underneath it, not swallowed by it.

One id can name several rules ​

strictBoundaries and layeredArchitecture construct some ids many times — no-cross-boundary once per boundary, restricted-packages once per package, test-isolation once per boundary pair. A declaration applies to every instance, so it holds only while all of them examine nothing:

ts
strictBoundaries(project, {
  folders: '**/src/features/*',
  // True only while NO boundary imports across. The day one does, this fails
  // for that boundary — and that boundary's violations are reported too.
  expectEmpty: ['preset/boundaries/no-cross-boundary'],
})

If some instances are empty and others are not, the declaration is the wrong tool: narrow the option that generates them instead.

Aggregated errors ​

A preset's rules all flow through the same runner (check, or checkAll in a test), so you see every violation across every rule in one report — not just the first failing rule. This makes fixing violations much faster: the full picture on every run.

When to use presets vs. custom rules ​

Use presets when your project follows a recognized pattern (layered architecture, feature modules, repository pattern). Use custom rules when you need project-specific constraints that presets don't cover.

Presets and custom rules compose freely — spread them into the same rule file:

typescript
// arch.rules.ts
export default [
  // Presets handle the structural rules
  ...layeredArchitecture(p, { layers: { ... } }),
  ...strictBoundaries(p, { folders: '**/src/features/*' }),

  // Custom rules handle project-specific concerns (builders, no .check())
  functions(p).that().resideInFolder('**/services/**').should().satisfy(mustCall(/Repository/)),
]

Released under the MIT License.