Learn how we automatically enforce architectural boundaries at scale in a large React Native codebase, with an incremental, opt-in approach that keeps teams unblocked while keeping the architecture clean.
Every React Native app starts out tidy. Then life happens: deadlines get tight, teams grow, features multiply, and before long that clean folder structure becomes a labyrinth of cross-cutting imports. Sound familiar?
At lastminute.com, we’ve been building our mobile app for years. It’s a large, multi-team codebase that’s grown organically and like any living system, architectural drift is a constant threat. In this post, we’ll share how we automatically enforce architectural boundaries using dependency-cruiser, surface violations directly on Merge Requests with danger.js, and crucially, how we pull this off incrementally, without grinding the whole team to a halt.
dependency-cruiser to statically analyse import graphs and fail the build when architectural rules are violated.danger.js to turn those violations into actionable comments directly on Merge Requests, no hunting through CI logs.In a multi-team codebase, the real danger isn’t someone writing bad code, it’s someone writing good code in the wrong place. A feature team under pressure reaches across module boundaries to reuse some logic. A shortcut import bypasses the public API of a service. Before long, every module knows about every other module, and your clean architecture diagram is a lie.
The traditional answer is code review. But code review is human, async, and inconsistent. We needed machines to guard our boundaries.
Our app is structured around the principle of Clean Architecture: each feature module has three inner layers, domain, data, and ui, and communicates with the outside world exclusively through a single index.ts barrel file. The goal: a module should be replaceable, testable, and ignorant of its neighbors’ internals.
Features/
FeatureA/
index.ts ← public API (the only door in and out)
architecture.rules.js ← opt-in boundary enforcement
domain/
data/
ui/Defining this structure is easy. Enforcing it on dozens of modules, across many squads, continuously, is the hard part.
dependency-cruiser is a Node.js tool that analyses your import graph and lets you define forbidden rules, patterns of dependency that should never exist. If a rule is violated, the tool exits with a non-zero code, failing your CI pipeline.
Here’s the simplest form of a rule: “no code outside FeatureA may bypass its index.ts“:
// enforce-index-exports: all external imports must go through index.ts
{
name: 'enforce-index-exports-features-featurea',
severity: 'error',
from: {
path: '^app/source',
pathNot: '^app/source/Features/FeatureA',
},
to: {
path: '^app/source/Features/FeatureA/',
pathNot: '^app/source/Features/FeatureA/index\\.ts$',
},
}If any file from outside the module imports from anywhere inside FeatureA other than its index.ts, the build fails.
We also enforce the mirror rule, internal files must also import other modules only through their public index.ts:
// enforce-index-imports: internal files can't bypass other modules' public API
{
name: 'enforce-index-imports-features-featurea',
severity: 'error',
from: {
path: '^app/source/Features/FeatureA/',
},
to: {
path: '^app/source/',
pathNot: [
'^app/source/Features/FeatureA/',
'^app/source/.*/index\\.ts$', // only index files are allowed
],
},
}And we enforce the inner layer hierarchy. domain is king, it must not know about data or ui. data must not know about ui. ui can only touch the DI composition files and domain models:
// domain-boundaries: domain must not depend on data or ui
{
name: 'domain-boundaries-features-featurea',
severity: 'error',
from: { path: '^app/source/Features/FeatureA/domain' },
to: { path: ['^app/source/Features/FeatureA/data', '^app/source/Features/FeatureA/ui'] },
},Finally, we enforce cross-feature boundaries, i.e., which features are allowed to import from each other. Each module ships with its own architecture.rules.js that explicitly whitelists its permitted dependencies:
// app/source/Features/FeatureA/architecture.rules.js
const FeatureAStrictBoundaries = {
name: "feature-a-strict-boundaries",
comment: "FeatureA should only import from necessary packages",
severity: "error",
from: {
path: "app/source/Features/FeatureA",
},
to: {
path: "app/source/Features",
pathNot: [
"app/source/Features/FeatureA", // self-reference always allowed
"app/source/Features/FeatureB", // explicitly allowed dependency
"app/source/Features/FeatureC",
],
},
};
module.exports = FeatureAStrictBoundaries;Any import to a Features path not in the pathNot allowlist is a violation. This is the module’s dependency contract, written explicitly, reviewed explicitly, enforced automatically.
“Enforcing strict architecture on a large, enterprise app in a single ‘big bang’ PR is a recipe for disaster.”
This is where architectural seniority matters. You could write a config file with hundreds of rules for your entire codebase. You could open that PR. And you could watch it become an unresolvable mess of violations, conflicts, and team pushback.
We chose a different approach: opt-in, module-by-module enforcement. The mechanism is elegantly simple, it’s a file called architecture.rules.js.
Our findNewArchitectureModules.js script recursively walks the codebase looking for any folder that contains an architecture.rules.js file:
function findNewArchitectureModules(dir = SOURCE_DIR) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findNewArchitectureModules(fullPath));
} else if (entry.name === "architecture.rules.js") {
const relativeFolderPath = path
.relative(SOURCE_DIR, dir)
.replace(/\\/g, "/");
results.push(relativeFolderPath);
}
}
return results;
}The presence of architecture.rules.js in a folder is the opt-in flag. No central registry, no configuration file to maintain, the module declares its own intent to be architecture-enforced.
The top-level dependency-cruiser configuration then dynamically assembles the full rule set at analysis time:
// .dependency-cruiser.js
const generateAllRules = require("./scripts/architecture/test/generate-architecture-rules.js");
module.exports = {
forbidden: [...generateAllRules],
options: {
tsPreCompilationDeps: true,
skipAnalysisNotInRules: true, // ← key: only analyse files covered by rules
// ...
},
};// generate-architecture-rules.js
const { findNewArchitectureModules } = require("./findNewArchitectureModules");
const modules = findNewArchitectureModules();
module.exports = [
...generateEnforceIndexExportsRules(modules),
...generateEnforceIndexImportsRules(modules),
...generateEnforceInnerLayersBoundariesRules(modules),
...generateNoCircularRules(modules),
...generateReverseBoundariesRules(modules),
];One subtle detail worth calling out: generateReverseBoundariesRules derives inverse enforcement from each module’s architecture.rules.js. If FeatureA declares that it allows imports from FeatureB and FeatureC, then the generator automatically creates rules to ensure that only those exact modules are the ones importing from it, preventing undeclared consumers from sneaking in:
// Auto-generated: featureb-reverse-boundaries-featurea
{
name: 'featureb-reverse-boundaries-featurea',
comment: 'FeatureB should not import from FeatureA',
severity: 'error',
from: { path: 'app/source/Features/FeatureB' },
to: { path: ['app/source/Features/FeatureA'] },
}This means the architecture.rules.js file is both a forward declaration (what FeatureA is allowed to import) and a source of truth for the access control matrix of the whole graph. One file, two enforcement directions.
Running depcruise in CI is great. Getting a green or red pipeline job is fine. But it’s not great DX, developers have to click into job logs, parse command output, and figure out which rule was violated where.
At this point, we are ready to enforce our rules, or are we?
We layer danger.js on top. danger.js runs as part of the CI pipeline and posts structured, human-readable comments directly on the Merge Request. Our dangerfile.architecture.ts reads the JSON report produced by depcruise and formats it:
// dangerfile.architecture.ts (simplified)
const report: ICruiseResult = JSON.parse(
fs.readFileSync("coverage/architecture-report.json", "utf8"),
);
const ruleMap: Record<string, { severity: string; count: number }> = {};
for (const v of report.summary.violations) {
if (!ruleMap[v.rule.name]) {
ruleMap[v.rule.name] = { severity: v.rule.severity, count: 0 };
}
ruleMap[v.rule.name].count += 1;
}
const lines = Object.entries(ruleMap)
.sort((a, b) => b[1].count - a[1].count)
.map(
([name, { severity, count }]) =>
`- **[${severity}]** \`${name}\`: ${count} violation(s)`,
);
fail(
`**Architecture Check**\n\n` +
`${errorCount} error(s) and ${warnCount} warning(s) detected.\n\n` +
`${lines.join("\n")}\n\n` +
`[View full HTML report](${reportUrl})`,
);The result is an MR comment like this:
Architecture Check
Architecture tests failed: 3 error(s) and 0 warning(s) detected.
- [error]
enforce-index-exports-features-featurea: 2 violation(s)- [error]
domain-boundaries-features-featureb: 1 violation(s)[View full HTML report]
A developer sees this on their MR and knows exactly which rule broke, how many times, and has a direct link to the visual HTML report. No log hunting. No guessing.
We also add a protective guard: if a developer accidentally deletes an architecture.rules.js file, danger.js blocks the MR immediately:
const removedRuleFiles = danger.git.deleted_files.filter((f) =>
f.endsWith("architecture.rules.js"),
);
if (removedRuleFiles.length > 0) {
fail(
`**Architecture Rules Protection**\n\n` +
`These files must not be removed once created:\n\n` +
`${removedRuleFiles.map((f) => `- \`${f}\``).join("\n")}\n\n` +
`Architecture boundary definitions are permanent once established.`,
);
}Architecture contracts are append-only. Once a module opts in, it stays in.
Architecture isn’t the only dimension of quality we care about. Our CI pipeline also runs SonarQube analysis on every MR, and a second danger.js dangerfile (dangerfile.quality.ts) posts a structured summary of the quality gate result directly on the MR:
| Metric | Value |
|---|---|
| Quality Gate | |
| New Bugs | 0 |
| New Vulnerabilities | 0 |
| New Security Hotspots | 1 |
| New Code Smells | 3 |
| Coverage (new code) | 82.4% |
| Duplication (new code) | 2.1% |
Same principle: no log hunting, no separate browser tabs. The gate result comes to the developer, not the other way around.
Together, dependency-cruiser + danger.js + SonarQube form our three-layer quality shield: structural architecture correctness, static analysis, and test coverage all surfaced directly in the MR.
Here’s how this all ties together in our .gitlab-ci.yml:
test:architecture:
stage: test
script:
- ARCH_EXIT=0
- npm run test:arch || ARCH_EXIT=$?
# Only generate reports if there are violations (keeps CI fast)
- if [ $ARCH_EXIT -ne 0 ]; then
npm run test:arch:report &&
npm run test:arch:report:json;
fi
# Post danger.js comment only on MR pipelines
- if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
npx danger ci --dangerfile scripts/danger/dangerfile.architecture.ts --id architecture || true;
fi
- exit $ARCH_EXIT
artifacts:
when: always
paths:
- coverage/architecture-report.html
expire_in: 1 weekA few implementation choices worth noting:
|| ARCH_EXIT=$?: we capture the exit code instead of failing immediately, so that danger.js can always run and post its comment before the job fails. The developer gets the feedback in the MR, not just a red dot.|| true on danger: danger itself failing (e.g., a GitLab API rate limit) should never block a legitimate fix. Architecture violations fail the job through exit $ARCH_EXIT, not through danger.skipAnalysisNotInRules: true: this dependency-cruiser option means we only analyse files that are actually covered by at least one rule. On a large codebase, this is a significant performance win.At this point you might be wondering: why go through all this effort with a linting tool when the package ecosystem already has a native enforcement mechanism? And you’d be right to ask.
The ultimate goal of everything we’ve described is to migrate our app toward a monorepo with workspaces, a structure where each module becomes its own first-class package, and inter-module dependencies are declared explicitly in package.json. Tools like Turborepo or Nx can then orchestrate build pipelines, compute dependency graphs, and cache task outputs across packages.
In that world, the architecture.rules.js cross-feature boundary rule:
// Today: a linting constraint
to: {
path: 'app/source/Features',
pathNot: [
'app/source/Features/FeatureA',
'app/source/Features/FeatureB', // ← explicitly allowed
],
},…becomes a hard build constraint expressed in package.json:
// Tomorrow: a workspace package declaration
{
"name": "@app/feature-a",
"dependencies": {
"@app/feature-b": "workspace:*"
}
}If FeatureA tries to import FeatureC without declaring it as a dependency, the module resolver simply won’t find it. No linter needed, the runtime enforces it. The pathNot allowlist and the dependencies field are, semantically, the same thing.
Here’s the insight that drives our entire strategy: you cannot safely extract a feature into a workspace package until its import graph is already clean.
If circular dependencies exist when you attempt the extraction, they become hard build failures, packages that cannot be built in any deterministic order. If undeclared cross-module imports exist, they become missing dependencies that break the build entirely. What was a soft linting warning is now a showstopper.
The dependency-cruiser phase is the staging ground. It lets us prove, module by module, that a feature’s boundaries are clean enough to be extracted, before we do the extraction. By the time we’re ready to move FeatureA to its own workspace package, its architecture.rules.js is the specification for its future package.json dependencies. The migration becomes mostly mechanical.
There’s a tendency in software engineering to treat “fixing architecture” as a project with a start date, an end date, and a big PR. In reality, sustainable architectural improvement is a practice, something you do continuously, incrementally, and in a way that doesn’t disrupt the people building the product.
Our approach crystallises a few ideas we believe strongly in:
architecture.rules.js into a module is a team decision, made when the team is ready. There’s no central decree.architecture.rules.js constraint is a future package.json dependency waiting to be born. The end goal is to not need the linting rules at all.We’re still on this journey. Not every module has its architecture.rules.js yet. But each sprint, a few more do and the codebase gets measurably cleaner, one flag file at a time.
Compliance checkboxes don't change behaviour. Here's how we used RIOT's AI-powered platform to run real phishing simulations, deliver contextual micro-training via Slack, and cut our vulnerability index by more than half across 1,700 employees. [...]