codeintelligently
Back to posts
Code Intelligence & Analysis

Architecture Fitness Functions: Testing Architecture Over Time

Vaibhav Verma
9 min read
code-intelligencearchitecturefitness-functionstestingci-cdsoftware-architecture

We had a beautiful microservices architecture. Clean service boundaries. Well-defined APIs. Proper event-driven communication. It took us 9 months to build. Within 18 months, it had degraded into a distributed monolith where 7 services were so tightly coupled that deploying one required testing all 7. Nobody made a conscious decision to couple them. It happened one PR at a time, each change perfectly reasonable in isolation, collectively destructive.

This is the problem that architecture fitness functions solve. They're automated tests for your architectural properties, and they catch drift before it becomes rot. I've been implementing them for 3 years across 4 codebases, and they've changed how I think about architecture entirely.

What Architecture Fitness Functions Actually Are

The concept comes from Neal Ford and Rebecca Parsons' work on evolutionary architecture. The idea is simple: if you can write unit tests for code behavior, you can write tests for architectural properties.

A fitness function is any automated check that validates an architectural property you care about. It runs in CI just like your unit tests. When it fails, it means your architecture has drifted from its intended design.

Examples of what fitness functions test:

  • Service dependency direction (Service A can depend on Service B, but not the reverse)
  • Maximum allowed latency for critical paths
  • Database query patterns (no N+1 queries in specific services)
  • Module boundaries (the billing module can't import from the user interface module)
  • Maximum dependency depth (no more than 3 hops between any two services for a user-facing request)

These aren't performance tests or integration tests. They're structural assertions about how your system is organized.

Why Code Review Alone Can't Protect Architecture

Here's the contrarian take: code review is terrible at catching architectural drift. I analyzed 2,400 PRs across two codebases over a 12-month period. In that time, 34 PRs introduced architectural violations (wrong dependency directions, new coupling between services that shouldn't communicate, etc.). Code reviewers caught 6 of them. That's a 17% detection rate.

The reason is human: reviewers are focused on the code in front of them. They're checking logic, error handling, naming, and test coverage. They're not mentally simulating the dependency graph to verify that this new import doesn't create a circular dependency three hops away. That's an unreasonable expectation for a human reviewer, and it's a perfect job for an automated check.

Building Your First Fitness Functions

I'll walk through the 5 fitness functions I implement first on any codebase. These cover the most common architectural drift patterns I've seen.

Fitness Function 1: Dependency Direction Enforcement

This ensures that dependencies flow in the correct direction (typically from higher-level modules toward lower-level modules, never the reverse).

typescript
// fitness/dependency-direction.test.ts
import { getImports } from "./utils/ast-parser";
import * as glob from "glob";

const DEPENDENCY_RULES = {
  "src/api/**": {
    canImportFrom: ["src/services/**", "src/models/**", "src/utils/**"],
    cannotImportFrom: ["src/infrastructure/**", "src/jobs/**"],
  },
  "src/services/**": {
    canImportFrom: ["src/models/**", "src/utils/**", "src/repositories/**"],
    cannotImportFrom: ["src/api/**", "src/jobs/**"],
  },
  "src/models/**": {
    canImportFrom: ["src/utils/**"],
    cannotImportFrom: ["src/api/**", "src/services/**", "src/repositories/**"],
  },
};

describe("Dependency Direction", () => {
  for (const [modulePattern, rules] of Object.entries(DEPENDENCY_RULES)) {
    const files = glob.sync(modulePattern);
    for (const file of files) {
      test(`${file} respects dependency direction`, () => {
        const imports = getImports(file);
        for (const imp of imports) {
          for (const forbidden of rules.cannotImportFrom) {
            expect(imp).not.toMatch(new RegExp(forbidden.replace("**", ".*")));
          }
        }
      });
    }
  }
});

We caught 12 violations in the first month after adding this. Twelve cases where a model was importing from a service, or an API handler was directly accessing the database layer. Each one was a small crack that would've become a structural problem.

Fitness Function 2: Service Communication Boundaries

For microservices or modular monoliths, this validates which services can communicate with which.

typescript
// fitness/service-boundaries.test.ts
const ALLOWED_COMMUNICATIONS = {
  "api-gateway": ["auth-service", "user-service", "product-service"],
  "order-service": ["product-service", "payment-service", "notification-service"],
  "payment-service": ["notification-service"],
  "notification-service": [], // leaf service, calls nothing
};

describe("Service Communication Boundaries", () => {
  test("no unauthorized service-to-service calls", async () => {
    const serviceCalls = await analyzeServiceCalls(); // parses HTTP clients, message publishers
    for (const [source, targets] of Object.entries(serviceCalls)) {
      const allowed = ALLOWED_COMMUNICATIONS[source] || [];
      for (const target of targets) {
        expect(allowed).toContain(target);
      }
    }
  });
});

Fitness Function 3: Database Access Patterns

This prevents the slow creep of direct database access from layers that shouldn't have it.

typescript
// fitness/database-access.test.ts
describe("Database Access Patterns", () => {
  test("only repository files access the database directly", () => {
    const dbAccessFiles = findFilesContaining([
      "prisma.",
      "knex(",
      ".query(",
      "SELECT ",
      "INSERT INTO",
    ]);

    for (const file of dbAccessFiles) {
      expect(file).toMatch(
        /repositories|migrations|seeds|scripts/
      );
    }
  });
});

Fitness Function 4: Critical Path Latency Budget

This is a runtime fitness function that validates your architecture's performance properties.

typescript
// fitness/latency-budget.test.ts
const LATENCY_BUDGETS = {
  "POST /api/orders": { p95: 500, p99: 1200 },
  "GET /api/products/:id": { p95: 100, p99: 300 },
  "POST /api/auth/login": { p95: 200, p99: 500 },
};

describe("Latency Budgets", () => {
  for (const [endpoint, budget] of Object.entries(LATENCY_BUDGETS)) {
    test(`${endpoint} meets latency budget`, async () => {
      const metrics = await getLatencyMetrics(endpoint, "last_24h");
      expect(metrics.p95).toBeLessThan(budget.p95);
      expect(metrics.p99).toBeLessThan(budget.p99);
    });
  }
});

Fitness Function 5: Component Size Limits

This catches the "God service" problem where one module accumulates too much responsibility.

typescript
// fitness/component-size.test.ts
const SIZE_LIMITS = {
  maxFilesPerModule: 50,
  maxLinesPerFile: 400,
  maxExportsPerModule: 30,
  maxDependenciesPerModule: 15,
};

describe("Component Size Limits", () => {
  const modules = getModules("src/");
  for (const mod of modules) {
    test(`${mod.name} stays within size limits`, () => {
      expect(mod.fileCount).toBeLessThan(SIZE_LIMITS.maxFilesPerModule);
      expect(mod.maxFileLines).toBeLessThan(SIZE_LIMITS.maxLinesPerFile);
      expect(mod.exportCount).toBeLessThan(SIZE_LIMITS.maxExportsPerModule);
      expect(mod.dependencyCount).toBeLessThan(SIZE_LIMITS.maxDependenciesPerModule);
    });
  }
});

Running Fitness Functions in CI

Fitness functions should run on every PR, just like unit tests. I split them into two categories:

Fast fitness functions (run on every PR): Dependency direction, service boundaries, database access patterns, component size. These are static analysis checks that complete in seconds.

Slow fitness functions (run nightly): Latency budgets, load test thresholds, cross-service integration properties. These require runtime data or take longer to execute.

yaml
# .github/workflows/fitness.yml
name: Architecture Fitness
on: [pull_request]
jobs:
  static-fitness:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:fitness:static
  nightly-fitness:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:fitness:runtime

The Stealable Framework: Fitness Function Adoption in 4 Sprints

Sprint 1: Audit and Define

  • Document your intended architecture (what should depend on what, which services communicate, etc.)
  • Identify the top 3 architectural properties you care about most
  • Write fitness functions for dependency direction only

Sprint 2: Expand Coverage

  • Add service boundary and database access fitness functions
  • Run against the current codebase and fix existing violations (there will be many)
  • Establish baseline metrics

Sprint 3: Runtime Properties

  • Add latency budget and component size fitness functions
  • Integrate into CI pipeline
  • Create a dashboard showing fitness function trends over time

Sprint 4: Team Adoption

  • Every new RFC must include which fitness functions the proposed change might affect
  • Add fitness function creation to the "definition of done" for new modules/services
  • Review fitness function coverage quarterly

After 3 months on this cadence, architecture drift went from "something we noticed in quarterly reviews" to "something CI catches in 10 minutes." Our architecture violation rate dropped from 34 violations per year to 3. Not because engineers stopped making mistakes, but because they got feedback within minutes instead of months.

Your architecture is either being tested or it's being eroded. There's no middle ground. Fitness functions put architecture on the same footing as code correctness: if it matters, it has a test.

$ ls ./related

Explore by topic