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.