lastminute.com logo

Technology

How We Enforce Architecture Boundaries at Scale on our App

antonino_gitto
antonino gitto
timothy_russo
timothy russo

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? emoji-sweat_smile

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.


TL;DR / Key Points

  • We use dependency-cruiser to statically analyse import graphs and fail the build when architectural rules are violated.
  • We use danger.js to turn those violations into actionable comments directly on Merge Requests, no hunting through CI logs.
  • We integrate with SonarQube to complete our quality gate picture (coverage, duplication, code smells, security hotspots).
  • Our secret sauce: rules are autogenerated and applied incrementally, feature by feature, by dropping a single file into a module’s directory.
  • Migrating a codebase module-by-module is far safer and more practical than a “big bang” enforcement PR.

The Challenge: Architecture Drift at Scale

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.


The Tooling: dependency-cruiser

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.


The Secret Sauce: Incremental, Autogenerated Rules

“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.

The “Flag File” Pattern

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),
];

The Reverse Boundaries Trick

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.


Developer Experience: danger.js on Every MR

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? emoji-smirk

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:

emoji-x 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.


Completing the Quality Gate: SonarQube

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 emoji-white_check_mark Passed
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.


The CI Pipeline Wiring

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 week

A 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.

The North Star: Workspaces and the Monorepo Vision

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.

Why You Can’t Skip the Lint Phase

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.


Conclusion: Incremental Architecture as a Practice

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:

  1. Opt-in over mandate. Dropping architecture.rules.js into a module is a team decision, made when the team is ready. There’s no central decree.
  2. Machines enforce, humans design. Engineers spend their code review energy on logic, not on policing import paths.
  3. Feedback at the source. Violations surface on the MR where the context is fresh, the author is present, and fixing it takes minutes, not a Jira ticket.
  4. Contracts are permanent. Once a module opts in, it can’t opt back out. The architecture envelope only ever gets smaller.
  5. Linting rules are temporary. Every 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.


About antonino gitto

antonino_gitto
Software Engineer

Antonino is an experienced mobile developer specializing in building high-quality, scalable apps. Passionate about innovation, he excels in creating seamless user experiences and solving complex challenges in mobile development.

About timothy russo

timothy_russo
Software Engineer

Timothy is a Mobile Product Engineer specialising in cross-platform mobile development. With a background in eCommerce, quality assurance, and project management, he is passionate about clean architecture, developer tooling, and building performant, scalable mobile experiences for iOS and Android.


Read next

Beyond Compliance: How We Built a Phishing-Aware Culture Across 1,700 Employees

Beyond Compliance: How We Built a Phishing-Aware Culture Across 1,700 Employees

marco_boniardi
marco boniardi

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. [...]

Creating Stunning Gradients in React Native with Skia

Creating Stunning Gradients in React Native with Skia

fabrizio_duroni
fabrizio duroni
antonino_gitto
antonino gitto

Learn how to create stunning text with React Native using React Native Skia. In this post, we’ll explore how to leverage Skia’s powerful features to bring vibrant gradients to life. [...]