Enforcing Architectural Boundaries in TypeScript

SitePoint TeamPublished inJavaScript·Programming·
September 8, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Architectural boundary violations in TypeScript monorepos rarely announce themselves. They accumulate silently as cross-domain imports appear one shortcut at a time, until the dependency graph becomes circular and opaque, blocking test isolation and safe refactoring.
How to Enforce Architectural Boundaries in TypeScript
- Define boundary rules as a typed configuration object declaring layer order, allowed cross-domain dependencies, and internal path globs.
- Bootstrap a
ts.Programfrom yourtsconfig.jsonusing the TypeScript Compiler API so path aliases and barrel re-exports resolve correctly. - Walk each source file’s AST to extract static imports, re-exports, and dynamic
import()expressions into a list of import edges. - Build an adjacency-list import graph from the collected edges, mapping each file to its owning domain and layer.
- Detect cross-layer violations (lower layer importing higher), internal-access violations (external domain reaching into private paths), and disallowed cross-domain dependencies.
- Run Tarjan’s strongly connected components algorithm on the domain-level graph to surface circular dependencies between bounded contexts.
- Wire the lint script into CI as a single step that exits with code 1 on any violation, blocking the merge.
Table of Contents
Prerequisites
This article assumes the following environment. All code samples were developed and tested against these versions:
- Node.js ≥ 18
- TypeScript ≥ 5.0
- minimatch ^8.0.0 (pinned — v9+ is ESM-only and incompatible with CommonJS TypeScript projects without additional configuration)
- tsx ≥ 4.0 (or ts-node ≥ 10.9; if using
ts-nodein an ESM project, pass the--esmflag)
npminstall --save-dev typescript@5 minimatch@8 tsx@4Allructure (e.g., src/payments/application/chargeUser.ts). Files outside this structure return null from the domain/layer resolver and are skipped with a warning. The script must be run from the repository root because it uses process.cwd() for path resolution
A bundler cannot tree-shake payments if it transitively imports all of users. Enforcing these boundaries requires more than naming conventions or linter rules. It demands a programmatic, graph-aware mechanism that understands import resolution at the AST level. This article presents a complete, reusable lint script built on the TypeScript Compiler API that constructs an import graph, validates it against declarative boundary rules, and fails CI when violations occur.
The Problem: Why Monorepo Boundaries Rot
How Cross-Domain Imports Accumulate
Consider a payments module that needs a user’s email address for receipt generation. A developer, or more commonly an AI codegen tool like GitHub Copilot, Cursor, orctly into users/internal/userRepository rather than importing through the public barrel export at users/index. The code compiles. The tests pass. The boundary violation goes unnoticed
AI code assistants accelerate this pattern because they optimize for the nearest matching symbol, not for architectural correctness.
AI code assistants accelerate this pattern because they optimize for the nearest matching symbol, not for architectural correctness. They have no awareness of which imports cross domain boundaries or violate layer constraints. Over weeks and months, these violations compound until circular dependencies emerge between bounded contexts. Tree-shaking breaks because bundlers cannot statically eliminate modules entangled across domain lines. Test suites become impossible to isolate, requiring the entire dependency graph to initialize even a single unit test.
Why Linters and paths Aliases Aren’t Enough
TSConfig paths aliases control how module specifiers resolve to file paths. They do not control the direction of dependencies. A paths alias can make @users/service resolve correctly without preventing @payments/handler from importing it when that dependency direction is architecturally forbidden.
ESLint’s import/no-restricted-paths rule gets closer, but it requires manual maintenance of every restricted zone, and it operates on individual import statements without modeling graph-level concerns. As of v2.29 (the latest stable release at time of writing), it cannot detect cycles that span multiple domains. Note that the tool built in this article also flags only direct edge violations, not transitive chains, unless you extend the graph walk with a BFS/DFS over the full transitive closure. What is needed is a programmatic, graph-aware enforcement mechanism that treats the full import graph as a first-class data structure.
Modeling Architectural Boundaries as a Directed Acyclic Graph
Defining Layers and Domains
Most TypeScript monorepos follow some variation of layered architecture. A common ordering runs from domain (pure business logic with zero dependencies) through application (use cases, orchestration) to infrastructure (database, HTTP clients, external services) and finally presentation (API routes, UI components). Dependencies flow in one direction only: higher layers may depend on lower layers, never the reverse.
Orthogonal to layers, domain-driven design introduces bounded contexts. Each context, whether orders, users, or billing, exposes a public API surface. Internal modules within a context are implementation details that no other context should reference. Together, layer ordering and domain encapsulation form a directed acyclic graph of allowed dependencies. Any edge that violates this DAG is an architectural boundary violation.
The Boundary Rule Schema
The first step is declaring these rules as data rather than embedding them in linter configuration scattered across multiple files. A typed configuration object captures allowed dependency directions, internal path patterns, and layer ordering in a single
exportinterfaceBoundaryRule{module:string;allowedDependencies:readonlystring[];internalPaths:readonlystring[];}exportinterfaceBoundaryConfig{layerOrder:readonlystring[];domains:readonly BoundaryRule[];}exportconst boundaryConfig: BoundaryConfig = Object.freeze({layerOrder: Object.freeze(['domain','application','infrastructure','presentation']asconst),domains: Object.freeze([Object.freeze({module:'users',allowedDependencies: Object.freeze(['shared']),internalPaths: Object.freeze(['src/users/internal/**']),}),Object.freeze({module:'payments',allowedDependencies: Object.freeze(['users','shared']),internalPaths: Object.freeze(['src/payments/internal/**']),}),Object.freeze({module:'orders',allowedDependencies: Object.freeze(['users','payments','shared']),internalPaths: Object.freeze(['src/orders/internal/**']),}),Object.freeze({module:'shared',allowedDependencies: Object.freeze([]),internalPaths: Object.freeze(['src/shared/internal/**']),}),]),});In the layerOrder array, index 0 is the lowest layer (domain) and index 3 is the highest (presentation). A violation occurs when a lower-indexed layer imports from a higher-indexed layer — i.e., fromIdx < toIdx. This catches domain (0) importing from infrastructure (2), a forbidden upward dependency. The internalPaths globs mark file paths that only the owning domain may access. The allowedDependencies array specifies which other domains a given domain may import from at all. The configuration object is deeply frozen to prevent accidental mutation, which is especially important for test isolation when multiple tests run in the same process.
Building the Import Graph with the TypeScript Compiler API
Bootstrapping a ts.Program from tsconfig.json
The TypeScript Compiler API provides ts.createProgram, which loads the full project from a tsconfig.json, resolves all path aliases, handles index barrel files, and understands export * re-exports. Regex-based import scanning fails in specific, predictable ways: it cannot resolve paths aliases to their actual file targets, it misses barrel re-exports (export * from './internal'), and it ignores .js extension mapping that TypeScript uses in ESM mode. ts.createProgram handles all three because it runs the same resolution algorithm as tsc itself.
import*as ts from'typescript';import*as path from'path';exportfunctioncreateProgramFromConfig(tsconfigPath:string): ts.Program {const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);if(configFile.error){const message = ts.flattenDiagnosticMessageText(configFile.error.messageText, '');thrownewError(`Failed to read tsconfig:${message}`);}const parsedConfig = ts.parseJsonConfigFileContent(configFile.config,ts.sys,path.dirname(tsconfigPath));return ts.createProgram(parsedConfig.fileNames,parsedConfig.options);}exportfunctiongetProjectSourceFiles(program: ts.Program, rootDir:string): ts.SourceFile[]{return program.getSourceFiles().filter((sf)=>sf.fileName.startsWith(rootDir)&&!sf.fileName.includes('node_modules')&&!sf.fileName.endsWith('.d.ts'));}The getProjectSourceFiles function filters out declaration files from node_modules and external libraries, as well as .d.ts ambient declaration files, scoping analysis to the project’s own implementation source. This filtering matters because the TypeScript Compiler API loads all referenced declaration files by default, which would pollute the import graph with irrelevant edges. Excluding .d.ts files prevents spurious violations caused by type-only resolved paths that may not match the src/<domain>/<layer>/ structure.
Walking the AST to Extract Import Declarations
With the program initialized, the visitor function walks eaches three syntactic forms: static import declarations, export declarations that re-export from other modules, and dynamic import() expressions used for code splitting or lazy loading
import*as ts from'typescript';import*as path from'path';exportinterfaceImportEdge{fromFile:string;toFile:string;line:number;}exportfunctionextractImports(sourceFile: ts.SourceFile,program: ts.Program,moduleResolutionCache: ts.ModuleResolutionCache): ImportEdge[]{const edges: ImportEdge[]=[];const compilerOptions = program.getCompilerOptions();functionresolveSpecifier(specifier:string):string|null{const resolved = ts.resolveModuleName(specifier,sourceFile.fileName,compilerOptions,ts.sys,moduleResolutionCache);return resolved.resolvedModule?.resolvedFileName ??null;}functionvisit(node: ts.Node):void{if(ts.isImportDeclaration(node)&&node.moduleSpecifier &&ts.isStringLiteral(node.moduleSpecifier)){const resolved =resolveSpecifier(node.moduleSpecifier.text);if(resolved){const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line +1;edges.push({ fromFile: sourceFile.fileName, toFile: resolved, line });}}if(ts.isExportDeclaration(node)&&node.moduleSpecifier &&ts.isStringLiteral(node.moduleSpecifier)){const resolved =resolveSpecifier(node.moduleSpecifier.text);if(resolved){const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line +1;edges.push({ fromFile: sourceFile.fileName, toFile: resolved, line });}}if(ts.isImportExpression(node)){const arg = node.arguments[0];if(arg && ts.isStringLiteral(arg)){const resolved =resolveSpecifier(arg.text);if(resolved){const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line +1;edges.push({ fromFile: sourceFile.fileName, toFile: resolved, line });}}}ts.forEachChild(node, visit);}visit(sourceFile);return edges;}The key function here is ts.resolveModuleName, which applies the same resolution algorithm TypeScript itself uses during compilation. It correctly handles paths aliases, baseUrl, barrel index.ts files, and .js/.ts extension resolution. The caller passes a shared ModuleResolutionCache to avoid redundant resolution work across all calls, eliminating an entire category of false positives and negatives that plague regex-based approaches.
Constructing the Adjacency List
The buildGraph function collapses raw ImportEdge arrays into an adjacency list, and resolveFileDomain normalizes each file path to its owning domain so that boundary rules can be applied.
import*as path from'path';import{ ImportEdge }from'./import-extractor';import{ BoundaryConfig }from'./boundary-rules';import{ minimatch }from'minimatch';exporttypeImportGraph= Map<string, Set<string>>;exportfunctionbuildGraph(edges: ImportEdge[]): ImportGraph {const graph: ImportGraph =newMap();for(const edge of edges){if(!graph.has(edge.fromFile)) graph.set(edge.fromFile,newSet());graph.get(edge.fromFile)!.add(edge.toFile);}return graph;}exportfunctionresolveFileDomain(filePath:string, config: BoundaryConfig):string|null{const relative = path.relative(process.cwd(), filePath).replace(/\/g,'/');const sortedDomains =[...config.domains].sort((a, b)=> b.module.length - a.module.length);for(const domain of sortedDomains){if(relative.startsWith(`src/${domain.module}/`))return domain.module;}returnnull;}exportfunctionresolveFileLayer(filePath:string):string|null{const relative = path.relative(process.cwd(), filePath).replace(/\/g,'/');const segments = relative.split('/');return segments.length >=3? segments[2]:null;}The resolveFileDomain function maps any file path back to its owning domain by matching the path prefix against configured domain names. Domains are sorted by descending name length before matching to ensure that a domain like users-admin is matched before users, preventing prefix collisions. The resolveFileLayer function extracts the layer from the expected directory structure (src/<domain>/<layer>/...). Both functions normalize Windows backslashes to forward slashes for cross-platform consistency.
Detecting Violations: Cross-Layer, Cross-Domain, and Cycles
Cross-Layer Dependency Violations
Walking each edge in the graph, the detection engine checks whether the importing file’s layer index is lower than the imported file’s layer index. In layerOrder, index 0 is the lowest layer (domain). A violation occurs when a lower-indexed layer imports from a higher-indexed layer — i.e., fromIdx < toIdx. This catches domain (0) importing from infrastructure (2), a forbidden upward dependency.
Internal Module Access Violations
When a file imports a target that matches an internalPaths glob pattern belonging to a different domain, the detector flags it as an internal access violation. The minimatch library handles glob matching against the internalPaths patterns defined in the boundary config. The { dot: true } option is passed to ensure dotfiles within ** globs are matched correctly.
Circular Dependency Detection
File-level cycle detection is noisy. A domain re-exporting from its own internal modules creates file-level cycles that are architecturally harmless. The detector collapses files into domains and runs Tarjan’s strongly connected components (SCC) algorithm on the resulting domain graph. Any SCC with more than one domain indicates a circular dependency between bounded contexts.
Enforcing these boundaries requires more than naming conventions or linter rules. It demands a programmatic, graph-aware mechanism that understands import resolution at the AST level.
import{ ImportEdge }from'./import-extractor';import{ BoundaryConfig }from'./boundary-rules';import{ resolveFileDomain, resolveFileLayer }from'./graph';import{ minimatch }from'minimatch';import*as path from'path';exportinterfaceBoundaryViolation{type:'cross-layer'|'internal-access'|'disallowed-dependency'|'circular';fromFile:string;toFile:string;line:number;message:string;}exportfunctiondetectViolations(edges: ImportEdge[], config: BoundaryConfig): BoundaryViolation[]{const violations: BoundaryViolation[]=[];const domainConfigMap =newMap(config.domains.map((d)=>[d.module, d]));const domainGraph =newMap<string, Set<string>>();for(const edge of edges){const fromDomain =resolveFileDomain(edge.fromFile, config);const toDomain =resolveFileDomain(edge.toFile, config);if(!fromDomain ||!toDomain){if(!fromDomain){console.warn(`[boundary] Skipping unrecognized source path:${edge.fromFile}`);}if(!toDomain){console.warn(`[boundary] Skipping unresolved import target:${edge.toFile}(imported from${edge.fromFile}:${edge.line})`);}continue;}if(fromDomain !== toDomain){if(!domainGraph.has(fromDomain)) domainGraph.set(fromDomain,newSet());domainGraph.get(fromDomain)!.add(toDomain);}const fromLayer =resolveFileLayer(edge.fromFile);const toLayer =resolveFileLayer(edge.toFile);if(fromLayer && toLayer){const fromIdx = config.layerOrder.indexOf(fromLayer);const toIdx = config.layerOrder.indexOf(toLayer);if(fromIdx !==-1&& toIdx !==-1&& fromIdx < toIdx){violations.push({type:'cross-layer',fromFile: edge.fromFile,toFile: edge.toFile,line: edge.line,message:`Layer "${fromLayer}" must not import from "${toLayer}" (dependency flows upward).`,});}}if(fromDomain !== toDomain){const toDomainConfig = domainConfigMap.get(toDomain);const relative = path.relative(process.cwd(), edge.toFile).replace(/\/g,'/');if(toDomainConfig?.internalPaths.some((glob)=>minimatch(relative, glob,{ dot:true}))){violations.push({type:'internal-access',fromFile: edge.fromFile,toFile: edge.toFile,line: edge.line,message:`"${fromDomain}" accesses internal module of "${toDomain}". Use the public API.`,});}const fromDomainConfig = domainConfigMap.get(fromDomain);if(fromDomainConfig &&!fromDomainConfig.allowedDependencies.includes(toDomain)){violations.push({type:'disallowed-dependency',fromFile: edge.fromFile,toFile: edge.toFile,line: edge.line,message:`"${fromDomain}" is not allowed to depend on "${toDomain}".`,});}}}for(const targets of domainGraph.values()){for(const t of targets){if(!domainGraph.has(t)) domainGraph.set(t,newSet());}}const sccs =tarjanSCC(domainGraph);for(const scc of sccs){if(scc.length >1){violations.push({type:'circular',fromFile:'',toFile:'',line:0,message:`Circular dependency between domains:${scc.join(' ↔ ')}`,});}}return violations;}functiontarjanSCC(graph: Map<string, Set<string>>):string[][]{let clock =0;const stack:string[]=[];const onStack =newSet<string>();const indices =newMap<string,number>();const lowlinks =newMap<string,number>();const result:string[][]=[];for(const root of graph.keys()){if(indices.has(root))continue;typeFrame={ v:string; iter: IterableIterator<string>; initialized:boolean};const callStack: Frame[]=[{ v: root, iter:(graph.get(root)??newSet()).values(), initialized:false},];while(callStack.length >0){const frame = callStack[callStack.length -1];const{ v }= frame;if(!frame.initialized){indices.set(v, clock);lowlinks.set(v, clock);clock++;stack.push(v);onStack.add(v);frame.initialized =true;}const next = frame.iter.next();if(!next.done){const w = next.value;if(!indices.has(w)){callStack.push({v: w,iter:(graph.get(w)??newSet()).values(),initialized:false,});}elseif(onStack.has(w)){lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!));}}else{callStack.pop();if(callStack.length >0){const parent = callStack[callStack.length -1].v;lowlinks.set(parent, Math.min(lowlinks.get(parent)!, lowlinks.get(v)!));}if(lowlinks.get(v)=== indices.get(v)){const scc:string[]=[];let w:string|undefined;do{w = stack.pop();if(w ===undefined)break;onStack.delete(w);scc.push(w);}while(w !== v);result.push(scc);}}}}return result;}The violation detector returns structured objects rather than printing directly, so the CLI layer can format output however it needs and integrate with other reporting tools.
The Reusable Lint Script: Putting It All Together
CLI Entry Point and Reporter
The individual modules compose into a single executable script that loads the boundary configuration, builds the import graph, runs violation detection, and exits with code 1 if any violations are found.
import*as ts from'typescript';import*as path from'path';import{ createProgramFromConfig, getProjectSourceFiles }from'./graph-builder';import{ extractImports }from'./import-extractor';import{ detectViolations }from'./violation-detector';import{ boundaryConfig }from'./boundary-rules';functionmain(){const tsconfigPath = path.resolve(process.cwd(),'tsconfig.json');const rootDir = path.resolve(process.cwd(),'src');let program: ts.Program;try{program =createProgramFromConfig(tsconfigPath);}catch(err){console.error((err as Error).message);process.exit(1);}const sourceFiles =getProjectSourceFiles(program, rootDir);const compilerOptions = program.getCompilerOptions();const sharedCache = ts.createModuleResolutionCache(process.cwd(),(f)=> f,compilerOptions);const allEdges = sourceFiles.flatMap((sf)=>extractImports(sf, program, sharedCache));const violations =detectViolations(allEdges, boundaryConfig);if(violations.length ===0){console.log('✅ No architectural boundary violations detected.');process.exit(0);}console.error(`❌ Found${violations.length}boundary violation(s):`);for(const v of violations){const from = v.fromFile ? path.relative(process.cwd(), v.fromFile):'(graph-level)';const to = v.toFile ? path.relative(process.cwd(), v.toFile):'';console.error(`[${v.type.toUpperCase()}]${from}${v.line ?`:${v.line}`:''}`);if(to)console.error(`→${to}`);console.error(`${v.message}`);}process.exit(1);}main();This script is runnablearies.ts (for CommonJS projects; add the –esm flag for ESM projects when using ts-node)
Sample Output
A typical run that detects violations produces output like this:
❌ Found 3 boundary violation(s):[INTERNAL-ACCESS] src/payments/application/chargeUser.ts:7→ src/users/internal/userRepository.ts"payments" accesses internal module of "users". Use the public API.[CROSS-LAYER] src/orders/domain/orderEntity.ts:3→ src/orders/infrastructure/database.tsLayer "domain" must not import from "infrastructure"(dependency flows upward).[CIRCULAR](graph-level)Circular dependency between domains: payments ↔ ordersEach violation includes the file, line number, violation type, and an actionable message indicating what went wrong and how to fix it.
CI/CD Integration
GitHub Actions Workflow Step
Adding boundary enforcement to a CI pipeline requires a single job step. Caching node_modules avoids reinstalling dependencies on every run. Pin tsx to a specific major version (or install it as a devDependency) to avoid non-deterministic behavior from version drift.
name: Boundary Enforcementon:[pull_request]jobs:check-boundaries:runs-on: ubuntu-lateststeps:-uses: actions/checkout@v4-uses: actions/setup-node@v4with:node-version:20cache:'npm'-run: npm ci-name: Enforce architectural boundariesrun: npx tsx@4 enforce-boundaries.tsPre-Commit Hook
For local developer feedback, adding the script to a Husky pre-commit hook catches violations before they reach CI. Performance can be improved by scoping analysis to changed files only. Running git diff --name-only --cached | grep '.ts$' and passing those files to a modified version of the script that only analyzes the affected import subgraph avoids re-analyzing the entire project on every commit. On a 2023 MacBook Pro with roughly 200 .ts files and a warm OS file cache, full analysis completes in 1-3 seconds. For larger repositories, the selective-subgraph optimization is recommended.
Scaling and Alternatives
When to Graduate to Nx, Turborepo, or Sheriff
This script covers the core use case of boundary enforcement without external framework dependencies. For teams already using Nx, the @nx/enforce-module-boundaries ESLint rule provides similar enforcement with tighter integration into Nx’s project graph and affected-command infrastructure. Nx enforces at the project boundary level rather than file-level AST, which means it may not catch internal-path violations within a single project. The eslint-plugin-boundaries package offers an ESLint-native approach with element-type classification (same direct-edge limitation applies). The sheriff library provides module boundaries based on directory structure conventions.
The DIY approach is preferable when the domain semantics do not map cleanly to any framework’s conventions, when avoiding lock-in to Nx or Turborepo is a priority, or when the boundary rules require custom logic such as environment-specific constraints or conditional access policies.
The DIY approach is preferable when the domain semantics do not map cleanly to any framework’s conventions, when avoiding lock-in to Nx or Turborepo is a priority, or when the boundary rules require custom logic such as environment-specific constraints or conditional access policies. When full analysis takes longer than your CI step budget, enable incremental compilation via incremental: true in compilerOptions and specify a tsBuildInfoFile path. TypeScript will then persist and reuse build information between runs, reducing analysis time significantly. Measure with time npx tsx enforce-boundaries.ts to determine whether you have hit that threshold.
Key Takeaways
Declare architectural boundaries as data in a typed configuration, not as scattered linter rules. The TypeScript Compiler API gives you an accurate import graph that respects path aliases and barrel re-exports, which regex-based scanning cannot match. With the graph in hand, you can detect cross-layer violations, internal access violations, and domain-level cycles programmatically. Wire the script into CI as a single step that exits non-zero on violations. Finally, version the boundary configuration in source control and update it alongside architectural decision records (ADRs) when boundaries change.
Sharing our passion for building incredible internet things.


