Skip to content

What to Check

Find your pain point, copy the rule. A gallery of real rules organized by what they enforce.

Structural rules: Import Dependencies · Layer Ordering · Cycle Detection · Naming Conventions · Class Structure · Containment · Inheritance · DecoratorsBehavioral rules: Body Analysis · Type Safety · Call Matching · Function Signatures · Complexity & SizeJSX & framework: JSX Element Rules · GraphQL RulesCross-cutting: Pattern Templates · Standard Rules · Smell Detection · Cross-Layer Consistency · Custom RulesAdoption: Gradual Adoption · Exclusions · Rich MessagesCustomizable recipes — one-liners you adjust to your ORM / folders / domain terms.

Rule file or test file?

Snippets here end in .check() (the test-file form). In a CLI rule file (arch.rules.ts), drop .check() and spread the bare builder into export default [...] — a .check() inside a rule-file array is silently skipped. Use .asSeverity('warn') for warnings.

Import Dependencies

Control which modules can import from which, preventing forbidden cross-boundary dependencies.

typescript
// Domain must not import from infrastructure
modules(p)
  .that()
  .resideInFolder('**/domain/**')
  .should()
  .onlyImportFrom('**/domain/**', '**/shared/**')
  .check()

// Controllers must not access repositories directly
modules(p)
  .that()
  .resideInFolder('**/controllers/**')
  .should()
  .notImportFrom('**/repositories/**')
  .check()

// Shared package must not depend on app code
modules(p)
  .that()
  .resideInFolder('**/shared/**')
  .should()
  .notImportFrom('**/controllers/**', '**/services/**')
  .check()

// Server must depend on security middleware (import { dependOn } from '@nielspeter/ts-archunit')
modules(p).that().resideInFile('**/server.ts').should().satisfy(dependOn('**/security/**')).check()

// Imports from repositories must be type-only
modules(p)
  .that()
  .resideInFolder('**/routes/**')
  .should()
  .onlyHaveTypeImportsFrom('**/repositories/**')
  .check()

Layer Ordering

Enforce that dependencies between architectural layers only flow in one direction.

typescript
// Dependencies flow inward
slices(p)
  .assignedFrom({
    controllers: '**/src/controllers/**',
    services: '**/src/services/**',
    repositories: '**/src/repositories/**',
    domain: '**/src/domain/**',
  })
  .should()
  .respectLayerOrder('controllers', 'services', 'repositories', 'domain')
  .check()

Cycle Detection

Detect circular dependencies between modules or feature slices that make code hard to refactor.

typescript
// No circular dependencies between feature modules
slices(p).matching('src/features/*/').should().beFreeOfCycles().check()

// Feature modules must not depend on legacy
slices(p).matching('src/features/*/').should().notDependOn('legacy', 'deprecated').check()

Naming Conventions

Enforce consistent naming patterns so classes, services, and functions are discoverable by convention.

Filenames vs code names

These rules match code element names. For filename rules, the split matters: pure casing (kebab-case, no .spec.ts) belongs in your linter (eslint or Biome), while filename↔element correspondence (a Controller must live in *-controller.ts) is ts-archunit's — see How It Fits.

typescript
// Controllers end with Controller
classes(p)
  .that()
  .resideInFolder('**/controllers/**')
  .should()
  .haveNameMatching(/Controller$/)
  .check()

// Services must be exported
classes(p).that().haveNameEndingWith('Service').should().beExported().check()

// No copy-pasted parsers (function name smell)
functions(p)
  .that()
  .haveNameMatching(/^parse\w+Order$/)
  .and()
  .resideInFolder('**/routes/**')
  .should()
  .notExist()
  .check()

Function Signatures

Constrain function parameter counts, types, and shapes to keep APIs explicit and consistent.

typescript
// No rest parameters in route handlers (forces explicit typing)
functions(p)
  .that()
  .resideInFolder('**/routes/**')
  .and()
  .haveRestParameter()
  .should()
  .notExist()
  .check()

// Event handlers must accept exactly one Event parameter
functions(p)
  .that()
  .haveNameMatching(/^handle/)
  .and()
  .haveParameterOfType(0, matching(/Event$/))
  .and()
  .haveParameterCountGreaterThan(1)
  .should()
  .notExist()
  .because('event handlers should accept exactly one Event parameter')
  .check()

// Optional parameters on a public API are a versioning hazard: each one is a
// shape callers may or may not pass, and they accumulate silently.
functions(p)
  .that()
  .areExported()
  .and()
  .haveOptionalParameter()
  .should()
  .notExist()
  .because('prefer an options object over accumulating optional parameters')
  .check()

Class Structure

Require classes to extend specific base classes or implement required methods.

typescript
// Repositories must extend BaseRepository
classes(p)
  .that()
  .haveNameEndingWith('Repository')
  .and()
  .resideInFolder('**/repositories/**')
  .should()
  .extend('BaseRepository')
  .check()

// Services must have a findById method
classes(p).that().extend('BaseService').should().haveMethodNamed('findById').check()

Containment

Ensure classes live in the correct folders based on their name or role.

typescript
// Controllers must live in the controllers folder
classes(p)
  .that()
  .haveNameEndingWith('Controller')
  .should()
  .resideInFile('**/controllers/**')
  .check()

// DTOs must reside in dto folder
classes(p)
  .that()
  .haveNameMatching(/Request$|Response$|DTO$/)
  .should()
  .resideInFile('**/dto/**')
  .check()

Inheritance

Enforce that classes extending a base or implementing an interface follow naming and structural conventions.

typescript
// Classes extending BaseRepository must end with Repository
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .haveNameMatching(/Repository$/)
  .check()

// Classes implementing EventHandler must end with Handler
classes(p)
  .that()
  .implement('EventHandler')
  .should()
  .haveNameMatching(/Handler$/)
  .check()

Decorators

Constrain where decorated classes may live and which combinations of decorators are allowed.

typescript
// @Controller classes must be in controllers folder
classes(p).that().haveDecorator('Controller').should().resideInFile('**/controllers/**').check()

// Abstract classes must not have @Controller
classes(p).that().areAbstract().and().haveDecorator('Controller').should().notExist().check()

Body Analysis

Ban specific function calls, constructors, or property accesses inside method and function bodies.

typescript
// No raw parseInt — use shared helper
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .useInsteadOf(call('parseInt'), call('this.extractCount'))
  .check()

// No generic Error — use typed domain errors
classes(p).that().extend('BaseService').should().notContain(newExpr('Error')).check()

// No eval()
classes(p).should().notContain(call('eval')).check()

// No console.log in production
functions(p).that().resideInFolder('**/src/**').should().notContain(call('console.log')).check()

// No direct process.env in domain
functions(p)
  .that()
  .resideInFolder('**/domain/**')
  .should()
  .notContain(access('process.env'))
  .check()

Type Safety

Catch weak or unsafe types like any, bare string, and type assertions at the architectural level.

typescript
// orderBy must be typed union, not bare string
types(p)
  .that()
  .haveProperty('orderBy')
  .should()
  .havePropertyType('orderBy', not(isString()))
  .check()

// No any-typed properties
import { noAnyProperties } from '@nielspeter/ts-archunit/rules/typescript'
classes(p).that().areExported().should().satisfy(noAnyProperties()).check()

// No type assertions (as casts)
import { noTypeAssertions } from '@nielspeter/ts-archunit/rules/typescript'
classes(p).should().satisfy(noTypeAssertions()).check()

Call Matching (Framework-Agnostic)

Inspect method calls on specific objects to enforce patterns like authentication or error handling in route registrations.

typescript
// All route handlers must have error handling
calls(p)
  .that()
  .onObject('app')
  .and()
  .withMethod(/^(get|post|put|delete)$/)
  .should()
  .haveCallbackContaining(call('handleError'))
  .check()

// Route registrations with specific path pattern
calls(p)
  .that()
  .onObject('router')
  .and()
  .withStringArg(0, '/api/v1/**')
  .should()
  .haveCallbackContaining(call('authenticate'))
  .check()

// No additionalProperties: true in route schemas (defeats validation)
calls(p)
  .that()
  .onObject('app')
  .and()
  .withMethod(/^(get|post|put|delete)$/)
  .should()
  .notHaveArgumentContaining(property('additionalProperties', true))
  .check()

JSX Element Rules

Enforce design system compliance, accessibility, and structural conventions on JSX elements in .tsx files.

typescript
import { jsxElements, jsxElement, modules, STANDARD_HTML_TAGS } from '@nielspeter/ts-archunit'

// No raw <button> — use design system components
jsxElements(p)
  .that()
  .areHtmlElements('button', 'input', 'select')
  .should()
  .notExist()
  .because('use design system components')
  .check()

// Every <img> must have alt text
jsxElements(p).that().areHtmlElements('img').should().haveAttribute('alt').check()

// No inline styles
jsxElements(p).should().notHaveAttribute('style').check()

// No dangerouslySetInnerHTML
jsxElements(p).should().notHaveAttribute('dangerouslySetInnerHTML').check()

// Interactive elements must have aria-label
jsxElements(p).that().withAttribute('onClick').should().haveAttribute('aria-label').check()

// Ban all standard HTML in pages folder
jsxElements(p)
  .that()
  .areHtmlElements(...STANDARD_HTML_TAGS)
  .and()
  .resideInFolder('**/pages/**')
  .should()
  .notExist()
  .check()

// Quick body-analysis check: no <script> tags
modules(p).that().resideInFile('**/*.tsx').should().notContain(jsxElement('script')).check()

Hardcoded text (i18n)

Enforce that user-facing text goes through a translation function instead of being hardcoded as JSX children. jsxText() matches text content (<button>Save</button>) and expression-wrapped literals ({"Save"}, {`Save`}), but not dynamic values ({count}, {t("save")}) or attribute values (which are the domain of jsxElements()).

typescript
import { modules, jsxText } from '@nielspeter/ts-archunit'

// No hardcoded user-facing text in components — route it through t()
modules(p)
  .that()
  .resideInFolder('**/src/components/**')
  .should()
  .notContain(jsxText())
  .because('User-facing text must go through t()')
  .excluding('src/components/Icon.tsx') // single-glyph icons
  .check()

jsxText() takes no options and bakes in no letter filter — <div>123</div> matches. Narrow with folder/file predicates or .excluding(...). Note that text inside translation wrappers like <Trans> also matches; scope those out the same way if you use them.

Scoped Rules (within)

Narrow the scope of a rule to code inside matched call sites, such as route handler callbacks.

typescript
// Within route handlers, enforce normalizePagination
const routes = calls(p)
  .that()
  .onObject('app')
  .and()
  .withMethod(/^(get|post)$/)

within(routes).functions().should().contain(call('normalizePagination')).check()

Pattern Templates

Define reusable structural shapes (like paginated responses) that functions or types must conform to.

typescript
// List endpoints must return paginated collection shape
const paginatedCollection = definePattern('paginated-collection', {
  returnShape: { total: 'number', skip: 'number', limit: 'number', items: 'T[]' },
})

functions(p)
  .that()
  .resideInFolder('**/routes/**')
  .should()
  .followPattern(paginatedCollection)
  .check()

Standard Rules (ready-to-use)

Import pre-built rules for common concerns like type safety, security, and code quality without writing custom conditions.

typescript
// TypeScript strictness
import {
  noAnyProperties,
  noTypeAssertions,
  noNonNullAssertions,
} from '@nielspeter/ts-archunit/rules/typescript'

// Security
import { noEval, noConsoleLog, noProcessEnv } from '@nielspeter/ts-archunit/rules/security'

// Error handling
import { noGenericErrors } from '@nielspeter/ts-archunit/rules/errors'

// Code quality
import {
  requireJsDocOnPublicMethods,
  noPublicFields,
  noMagicNumbers,
} from '@nielspeter/ts-archunit/rules/code-quality'

// Apply any of them:
classes(p).should().satisfy(noAnyProperties()).check()
classes(p).should().satisfy(noEval()).check()
classes(p).should().satisfy(requireJsDocOnPublicMethods()).warn()
classes(p)
  .should()
  .satisfy(noMagicNumbers({ allowed: [0, 1, -1, 200, 404] }))
  .warn()

Smell Detection

Find copy-pasted logic and inconsistent patterns across sibling files automatically.

typescript
// Detect near-identical function bodies
smells.duplicateBodies(p).inFolder('**/src/routes/**').withMinSimilarity(0.9).minLines(10).warn()

// Flag the odd one out in a folder
smells
  .inconsistentSiblings(p)
  .inFolder('**/src/repositories/**')
  .forPattern(call('this.extractCount'))
  .warn()

GraphQL Rules

Validate GraphQL schema types and resolver implementations against architectural conventions.

typescript
import { schema, resolvers } from '@nielspeter/ts-archunit/graphql'

// Collection types must have pagination fields
schema(p, 'src/graphql/**/*.graphql')
  .that()
  .typesNamed(/Collection$/)
  .should()
  .haveFields('items', 'total', 'skip', 'limit')
  .check()

// Relation resolvers must use DataLoader
resolvers(p, 'src/resolvers/**')
  .that()
  .resolveFieldReturning(/^[A-Z]/)
  .should()
  .contain(call('loader.load'))
  .check()

Cross-Layer Consistency

Verify that matching counterparts exist across layers, such as a schema for every route.

typescript
// Every route must have a matching schema
crossLayer(p)
  .layer('routes', '**/src/routes/**')
  .layer('schemas', '**/src/schemas/**')
  .mapping(
    (route, schema) =>
      route.getBaseName().replace('-route', '') === schema.getBaseName().replace('-schema', ''),
  )
  .forEachPair()
  .should()
  .should(haveMatchingCounterpart())
  .check()

Gradual Adoption

Introduce rules into existing codebases without failing on legacy violations by using baselines and diff-awareness.

typescript
// Baseline — only NEW violations fail
const baseline = withBaseline('arch-baseline.json')
classes(p).should().notContain(call('parseInt')).check({ baseline })

// Diff-aware — only violations in changed files
classes(p)
  .should()
  .notContain(call('eval'))
  .check({ diff: diffAware('main') })

// GitHub annotations — auto-detected in CI
classes(p).should().notContain(call('eval')).check({ format: detectFormat() })

Exclusions (Permanent Exceptions)

Exempt specific elements from a rule when a permanent exception is justified.

typescript
// Chain-level — exclude specific elements
functions(p).should().notContain(newExpr('URLSearchParams'))
  .excluding('Asset.getImageUrl', 'Environment.sync').check()

// Inline comment — in the source code (survives refactoring)
// ts-archunit-exclude sdk/no-manual-urlsearchparams: image transform URL params
async getImageUrl() { ... }

Rich Violation Messages

Attach human-readable explanations, fix suggestions, and documentation links to rule violations.

typescript
// Every rule can explain WHY, HOW to fix, and WHERE to learn more
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .notContain(newExpr('Error'))
  .rule({
    id: 'repo/typed-errors',
    because: 'Generic Error loses context',
    suggestion: 'Replace new Error(msg) with new NotFoundError(entity, id)',
    docs: 'https://example.com/adr/011',
  })
  .check()

Complexity & Size

Set upper bounds on cyclomatic complexity, line counts, method counts, and parameter lists to prevent god classes and unreadable functions.

typescript
import {
  maxCyclomaticComplexity,
  maxClassLines,
  maxMethodLines,
  maxMethods,
  maxParameters,
  maxFunctionComplexity,
  maxFunctionParameters,
} from '@nielspeter/ts-archunit/rules/metrics'

// No method may exceed complexity 15
classes(p).should().satisfy(maxCyclomaticComplexity(15)).check()

// Classes must not exceed 300 lines
classes(p).should().satisfy(maxClassLines(300)).warn()

// Methods must be short
classes(p).should().satisfy(maxMethodLines(50)).warn()

// No god classes
classes(p).should().satisfy(maxMethods(15)).warn()

// Enforce small parameter lists
classes(p).should().satisfy(maxParameters(4)).check()

// Function-level: complexity and parameter limits
functions(p).should().satisfy(maxFunctionComplexity(15)).check()
functions(p).that().areExported().should().satisfy(maxFunctionParameters(4)).check()

Custom Rules

Write your own predicates and conditions with full AST access when built-in rules don't cover your case.

typescript
// Custom predicate — filter by any logic
const hasTooManyMethods = definePredicate('has >10 methods', (cls) => cls.getMethods().length > 10)

classes(p).that().satisfy(hasTooManyMethods).should().notExist().check()

// Custom condition — assert anything with full AST access
const haveJsDoc = defineCondition('have JSDoc', (elements, context) => {
  // ... inspect elements, return ArchViolation[]
})

classes(p).that().areExported().should().satisfy(haveJsDoc).check()

Customizable Recipes

These aren't shipped as standard rules because they need project-specific tuning — your ORM name, folder layout, or domain terms. Copy the one-liner and adjust the pattern.

Helpers used below (call, newExpr, not, resideInFolder, silent, mustCall, noDeadModules, noUnusedExports, noStubComments, functionNoJsonParse, functionNoConsole) all import from @nielspeter/ts-archunit.

Logic placement — keep operations in the right layer

DB queries belong in repositories, HTTP calls in gateways, parsing in validators. Customize the regex to your libraries (avoid broad patterns like /query|execute/ — they match executeTask, queryString).

typescript
const dbPattern = /prisma|knex|drizzle/

// No DB calls outside repositories
functions(p)
  .that()
  .resideInFolder('**/services/**')
  .should()
  .notContain(call(dbPattern))
  .rule({ id: 'placement/no-db-in-services', because: 'DB access belongs in repositories' })
  .check()

// No HTTP in the domain layer
functions(p)
  .that()
  .resideInFolder('**/domain/**')
  .should()
  .notContain(call(/fetch|axios|got/))
  .because('domain must not make HTTP calls — use a gateway')
  .check()

// No date construction in business logic (inject a clock for testability)
functions(p).that().resideInFolder('**/services/**').should().notContain(newExpr('Date')).check()

Delegation — a layer must USE its dependency

The inverse of "must not contain": assert a function must call something matching a pattern.

typescript
// Services must delegate to the data layer
functions(p)
  .that()
  .resideInFolder('**/services/**')
  .should()
  .satisfy(mustCall(/Repository/))
  .because('services must delegate to the data layer')
  .check()

// Handlers must validate input first
functions(p)
  .that()
  .resideInFolder('**/handlers/**')
  .should()
  .satisfy(mustCall(/validate|parse|check/))
  .check()

Boundary control — features stay self-contained

typescript
const features = ['auth', 'billing', 'orders']
for (const f of features) {
  modules(p)
    .that()
    .resideInFolder(`**/features/${f}/**`)
    .should()
    .onlyImportFrom(`**/features/${f}/**`, '**/shared/**')
    .because(`${f} must not depend on other features`)
    .check()
}
// Or use the `strictBoundaries` preset, which does this automatically.

// Internal modules only reachable through the barrel
modules(p)
  .that()
  .resideInFile('**/internal/**/*.ts')
  .should()
  .onlyBeImportedVia('**/index.ts', '**/internal/**')
  .check()

Dead code & hygiene

typescript
// No orphaned files (nobody imports them)
modules(p)
  .that()
  .resideInFolder('**/src/**')
  .should()
  .satisfy(noDeadModules())
  .excluding('index.ts', 'main.ts', 'config.ts', /\.d\.ts$/)
  .check()

// No unused exports
modules(p).that().resideInFolder('**/src/**').should().satisfy(noUnusedExports()).check()

// No TODO/FIXME left in production
functions(p).that().resideInFolder('**/src/**').should().satisfy(noStubComments()).check()

Careful with .excluding('index.ts') in a dependency rule. element is matched as a basename, so that silences every index.ts in the project at once — including their legitimate imports. It is safe here, in a reverse-graph rule about entry points. Since v0.28.0 barrels carry dependencies (this repo's own src/index.ts went from 0 to 114), so it is the line teams reach for on their first red, in the wrong place. Use a path glob — '**/src/index.ts' — see Setup Best Practices.

For broad exclusion patterns that legitimately match nothing in some workspaces, wrap them in silent() to suppress the stale-exclusion warning: .excluding(silent(/\.d\.ts$/), 'index.ts'). See Setup & Best Practices for baseline-vs-.excluding() guidance.

Export hygiene

typescript
// Named exports only (easier to refactor and tree-shake)
modules(p).that().resideInFolder('**/src/**').should().notHaveDefaultExport().check()

// Too many exports suggests the file should be split (warn, don't fail)
modules(p).that().resideInFolder('**/src/**').should().haveMaxExports(10).warn()

Centralized parsing & logging

typescript
// Only src/parsers/ may call JSON.parse
functions(p)
  .that()
  .resideInFolder('**/src/**')
  .and()
  .satisfy(not(resideInFolder('**/parsers/**')))
  .should()
  .satisfy(functionNoJsonParse())
  .because('use the typed parsers in src/parsers/')
  .check()

// No raw console.* — use the logger abstraction
functions(p)
  .that()
  .resideInFolder('**/src/**')
  .should()
  .satisfy(functionNoConsole())
  .because('use Logger from @app/logger')
  .check()

(Design-system / JSX recipes live in JSX Element Rules above.)


For full details: Modules · Classes · Functions · Types · Body Analysis · Calls · Slices · Patterns · Smells · GraphQL · Cross-Layer · Standard Rules · Metrics · Custom Rules · Violations

Released under the MIT License.