Mert Özen Further With Every Line
Software Architecture Introduction to Software Architecture — #09

Keeping Architecture Healthy Over Time: Architecture Erosion and Fitness Functions

Mert Özen Nov 7, 2026 13 min 6 views
Keeping Architecture Healthy Over Time: Architecture Erosion and Fitness Functions

Architectural decisions are protected not by code review but by fitness functions that run on every PR: a closed-layer rule with ArchUnitNET, a coupling threshold with a ratchet and a performance test that counts queries catch a PR that passes the build but breaks the architecture. Erosion and drift, kinds of fitness functions, a GitHub Actions step and a roadmap for the category.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

On the Friday before campaign week, a PR (pull request) with two small changes arrives at Bookstore. Marketing urgently wants a "top customers" report; the developer writing it connects the endpoint directly to the DbContext instead of adding a new service in the Business layer:

using Bookstore.Data;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Web;

// An endpoint that skips Business and goes straight to DbContext because "the report is urgent"
public static class ReportEndpoints
{
    public static void MapReports(this WebApplication app) =>
        app.MapGet("/reports/top-customers", async (BookstoreDbContext db) =>
            await db.Orders.GroupBy(o => o.CustomerId)
                .Select(g => new { CustomerId = g.Key, Orders = g.Count() })
                .OrderByDescending(x => x.Orders).Take(10).ToListAsync());
}

In the same PR, the method that calculates totals for the "recent orders" page has been rewritten "to be more readable":

using Bookstore.Data;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Business;

public sealed record OrderSummary(int OrderId, string CustomerId, int ItemCount, decimal Total);

public sealed class OrderSummaryService(BookstoreDbContext db)
{
    // PR: "let's compute the totals in C#, it reads better"
    public async Task<List<OrderSummary>> GetRecentAsync(int take, CancellationToken ct = default)
    {
        List<Order> orders = await db.Orders.OrderByDescending(o => o.Id).Take(take).ToListAsync(ct);
        List<OrderSummary> result = [];
        foreach (Order order in orders)
        {
            List<OrderItem> items = await db.OrderItems.Where(i => i.OrderId == order.Id).ToListAsync(ct);
            result.Add(new OrderSummary(order.Id, order.CustomerId, items.Sum(i => i.Quantity), items.Sum(i => i.UnitPrice * i.Quantity)));
        }
        return result;
    }
}

The build passes, the existing unit tests pass, the pages show the right results. Two people wrote "LGTM (looks good to me)" in code review. Does this PR contain anything against the architecture? If so, how many things, and which automated check could catch them? And think about this: where will the effect of this PR show up, not today, but six months from now?

Architecture Erosion and Drift

Dewayne Perry and Alexander Wolf, in "Foundations for the Study of Software Architecture" published in 1992, separated the decay of architecture over time into two concepts. Erosion is the direct violation of architectural decisions: skipping a layer that should be closed, a forbidden dependency. Drift is insensitivity to the architecture: the decisions are not violated, but because nobody knows or cares about them, the system slowly slides in another direction. The two feed each other; the ambiguity drift creates makes the next erosion easier, "since there is no rule anyway".

The opening PR is an example of both categories. ReportEndpoints is direct erosion: in the fifth post we defined Business as a closed layer, and the endpoint skips it. The change in OrderSummaryService breaks not an architectural decision but a characteristic: a page that used to run one query now runs 21 queries for 20 orders. If you set latency targets for checkout and search as in the third post, this kind of change silently eats those targets on campaign day, under load.

Why does code review miss these? Because the diff is small and every line is reasonable on its own. The layer violation hides in a using line; the N+1 query only shows up at run time. The human eye is good at judging intent but bad at applying the same rule with the same care across hundreds of PRs. The rules need a machine to check them on every PR.

Fitness Functions: Tests That Measure a Characteristic

Neal Ford, Rebecca Parsons and Patrick Kua's book Building Evolutionary Architectures (O'Reilly, 2017; second edition with Pramod Sadalage, 2022) gives this need a name: the architectural fitness function. A fitness function is any mechanism that objectively assesses whether one or more architecture characteristics are still being kept. It can be a unit test, an architecture test, a load test threshold, a monitoring alert in production, even a manual check done regularly. The idea of evolutionary architecture rests on it: architecture is not built right in one go, it changes in small steps, and at every step the fitness functions tell you whether the direction is being kept.

The book classifies fitness functions along several axes. The three most useful, with Bookstore examples:

AxisOptionsBookstore example
ScopeAtomic: measures one characteristic in one context. Holistic: measures how several characteristics behave together.Atomic: "Web does not depend directly on Data". Holistic: checkout p95 and error rate together under campaign load.
When it runsTriggered: on an event like a build, PR or deploy. Continuous: constantly in production.Triggered: an architecture test on the PR. Continuous: an alert watching checkout availability.
ResultStatic: a fixed threshold, pass or fail. Dynamic: the threshold changes with context.Static: "at most 1 SQL (Structured Query Language) query". Dynamic: a latency threshold that scales with traffic.

In this post we focus on triggered, atomic and static ones, because those can run on every PR in CI (Continuous Integration) and give a definite result. Continuous and holistic ones live in production; together with concepts like SLO (Service Level Objective) and error budgets they are the subject of the SRE (Site Reliability Engineering) series in the DevOps category.

Turning the Layer Rule into a Test: ArchUnitNET

ArchUnitNET is the .NET counterpart of ArchUnit in Java: it reads compiled assemblies, builds a model from the dependencies between types, and lets you write rules on that model in a fluent language. We set up the test project with xUnit v3 and ArchUnitNET's xUnit v3 integration:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <OutputType>Exe</OutputType>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="4.0.2" />
    <PackageReference Include="TngTech.ArchUnitNET.xUnitV3" Version="0.13.5" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\Bookstore.Web\Bookstore.Web.csproj" />
  </ItemGroup>

</Project>

Version 4.x of xUnit v3 runs on Microsoft.Testing.Platform. For dotnet test in the .NET 10 SDK (Software Development Kit) to use that platform, the global.json at the solution root needs one line; without it, dotnet test stops with an error saying the VSTest target is no longer supported:

{
  "sdk": { "version": "10.0.402", "rollForward": "latestFeature" },
  "test": { "runner": "Microsoft.Testing.Platform" }
}

The rules encode the Bookstore.Web → Bookstore.Business → Bookstore.Data structure from the fifth post:

using ArchUnitNET.Domain;
using ArchUnitNET.Fluent;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnitV3;
using Bookstore.Business;
using Bookstore.Data;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

namespace Bookstore.ArchitectureTests;

[Trait("Category", "Architecture")]
public sealed class LayerTests
{
    // Assemblies are loaded once; every rule uses the same model
    private static readonly Architecture Architecture = new ArchLoader()
        .LoadAssemblies(typeof(Program).Assembly, typeof(OrderSummaryService).Assembly, typeof(BookstoreDbContext).Assembly)
        .Build();

    private static readonly IObjectProvider<IType> Web =
        Types().That().ResideInAssembly(typeof(Program).Assembly).As("Web layer");
    private static readonly IObjectProvider<IType> Business =
        Types().That().ResideInAssembly(typeof(OrderSummaryService).Assembly).As("Business layer");
    private static readonly IObjectProvider<IType> Data =
        Types().That().ResideInAssembly(typeof(BookstoreDbContext).Assembly).As("Data layer");

    [Fact]
    public void Data_layer_does_not_depend_on_upper_layers() =>
        Types().That().Are(Data).Should().NotDependOnAny(Web).AndShould().NotDependOnAny(Business)
            .Because("Data is the bottom layer").Check(Architecture);

    [Fact]
    public void Web_layer_does_not_use_data_layer_directly() =>
        Types().That().Are(Web).Should().NotDependOnAny(Data)
            .Because("Business is a closed layer; Web goes through it").Check(Architecture);
}

Why is the compiler not enough? Since project references do not allow cycles, Data cannot reference Web; in this structure the first rule is already protected by the compiler, but a layered application in a single project (separated by folders and namespaces) has no protection at all. The second rule is something the compiler never catches: in SDK-style projects, project references are transitive, so Web sees Data through Business and can use BookstoreDbContext directly. Running the opening PR against these tests:

failed Bookstore.ArchitectureTests.LayerTests.Web_layer_does_not_use_data_layer_directly (167ms)
  ArchUnitNET.xUnitV3.FailedArchRuleException : "Types that are Web layer should not depend on any Data layer because Business is a closed layer; Web goes through it" failed:
  	Bookstore.Web.ReportEndpoints does depend on "Bookstore.Data.BookstoreDbContext" and "Bookstore.Data.Order"

The message states the rule, its reason (Because) and the offending type in one place. Writing the reason matters: if the person who sees the test go red six months from now does not know why we set the rule, the shortest path is deleting the test. The long form of the reason belongs in an ADR (Architecture Decision Record); the test is that decision's automated guard. The "From ADR to Automated Check" post in the upcoming Architecture Decisions series covers that link in detail.

Also, test the test itself. Something caught our attention in the experiment: Program.cs calls AddDbContext<BookstoreDbContext> with top-level statements, so Web's composition root depends on Data. The rule never reported that line. When we moved the same call into a method of a named class, the rule turned red. In the model ArchUnitNET 0.13.5 loaded, the Program type appeared with no Bookstore dependencies at all. In this example the outcome happens to suit us, because the composition root legitimately knows about Data; but learning that by accident is not good. Deliberately violate every new rule at least once and watch it turn red, and write the exceptions (like the composition root) into the rule explicitly.

Measuring Coupling and the Ratchet

Not every rule can start at "zero violations". In Bookstore, the Business layer uses EF (Entity Framework) Core directly today; the team decided to move queries gradually into query objects in the Data layer, but cannot do it in one sprint. The ratchet pattern helps in situations like this: write today's value down as a ceiling, and let the number go down but never up.

using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using Bookstore.Business;
using Bookstore.Data;

namespace Bookstore.ArchitectureTests;

[Trait("Category", "Architecture")]
public sealed class CouplingTests(ITestOutputHelper output)
{
    private static readonly Architecture Architecture = new ArchLoader()
        .LoadAssemblies(typeof(Program).Assembly, typeof(OrderSummaryService).Assembly, typeof(BookstoreDbContext).Assembly)
        .Build();

    // Ratchet: today's value is the ceiling; the number may go down, never up
    private const int MaxBusinessTypesUsingEfCore = 1;

    [Fact]
    public void Business_types_using_ef_core_do_not_increase()
    {
        string[] offenders = Architecture.Types
            .Where(t => t.Namespace.FullName.StartsWith("Bookstore.Business"))
            .Where(t => t.Dependencies.Any(d => d.Target.FullName.StartsWith("Microsoft.EntityFrameworkCore")))
            .Select(t => t.Name)
            .Distinct()
            .Order()
            .ToArray();

        output.WriteLine($"Business types using EF Core: {offenders.Length} [{string.Join(", ", offenders)}]");
        Assert.True(offenders.Length <= MaxBusinessTypesUsingEfCore,
            $"{offenders.Length} Business types use EF Core (max {MaxBusinessTypesUsingEfCore}): {string.Join(", ", offenders)}");
    }
}
passed Bookstore.ArchitectureTests.CouplingTests.Business_types_using_ef_core_do_not_increase (150ms)
  Standard output
    Business types using EF Core: 1 [OrderSummaryService]

This is a coupling metric: the efferent coupling (outgoing dependency) from Business to EF Core, measured as a number of types. From the same model you can also calculate afferent and efferent coupling, instability, and the distance from the main sequence described in the "Component Principles" post of the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series. What matters is using the number not as a one-off report but as a threshold checked on every PR. The ratchet has one rule: when someone lowers the number, lower the constant too. Otherwise the ground you gained is lost again in the next urgent job.

Turning Performance into a Fitness Function

Writing a test in CI like "this endpoint stays under 200 ms" is tempting but unreliable: the load on the shared CI machine, warm-up time and noise break the test at random, and after a few weeks the team learns to ignore it. A deterministic proxy measure works better. For Bookstore's "recent orders" page, that proxy is the number of SQL queries executed. An EF Core interceptor counts every command, and a SQLite in-memory database keeps the test fast and independent:

using System.Data.Common;
using Bookstore.Business;
using Bookstore.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Bookstore.ArchitectureTests;

[Trait("Category", "Fitness")]
public sealed class QueryBudgetTests
{
    [Fact]
    public async Task Recent_orders_page_uses_a_single_query()
    {
        await using SqliteConnection connection = new("DataSource=:memory:");
        await connection.OpenAsync(TestContext.Current.CancellationToken);
        CommandCounter counter = new();
        DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
            .UseSqlite(connection).AddInterceptors(counter).Options;

        await using (BookstoreDbContext seed = new(options))
        {
            await seed.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
            seed.Orders.AddRange(Enumerable.Range(1, 20).Select(n => new Order
            {
                CustomerId = $"customer-{n % 7}",
                Items = [new() { Isbn = "978-0441013593", UnitPrice = 12.50m, Quantity = 1 },
                         new() { Isbn = "978-0141439518", UnitPrice = 6.00m, Quantity = 2 }]
            }));
            await seed.SaveChangesAsync(TestContext.Current.CancellationToken);
        }

        await using BookstoreDbContext db = new(options);
        counter.Reset();
        List<OrderSummary> page = await new OrderSummaryService(db).GetRecentAsync(20, TestContext.Current.CancellationToken);

        Assert.Equal(20, page.Count);
        Assert.True(counter.Count <= 1, $"Recent orders page ran {counter.Count} SQL commands (budget: 1)");
    }

    // Counts every DbCommand that runs
    private sealed class CommandCounter : DbCommandInterceptor
    {
        public int Count { get; private set; }
        public void Reset() => Count = 0;

        public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
            DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
        {
            Count++;
            return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
        }
    }
}

The real implementation is a single projection query that computes the totals in the database:

// One query: the projection computes totals in the database
public Task<List<OrderSummary>> GetRecentAsync(int take, CancellationToken ct = default) =>
    db.Orders
        .OrderByDescending(o => o.Id)
        .Take(take)
        .Select(o => new OrderSummary(o.Id, o.CustomerId, o.Items.Sum(i => i.Quantity), o.Items.Sum(i => i.UnitPrice * i.Quantity)))
        .ToListAsync(ct);

With the looping version from the opening PR, the test says:

failed Bookstore.ArchitectureTests.QueryBudgetTests.Recent_orders_page_uses_a_single_query (462ms)
  Recent orders page ran 21 SQL commands (budget: 1)

21 queries: one for the orders, and one more for each order. The classic N+1 described in the "EF Core Performance Basics: N+1, Projection, AsNoTracking and Compiled Queries" post of the EF Core series. Because the test measures how many times it goes to the database rather than how long it takes, it gives the same result on every machine. Holistic fitness functions that measure real latency are still needed; like the p95 scenarios in the third post, they should run under a load close to production.

Adding It to the CI Pipeline

All fitness functions are in the same test project, split into categories with traits. dotnet test running on Microsoft.Testing.Platform accepts xUnit v3's --filter-trait option directly:

dotnet test --no-build --filter-trait "Category=Architecture"
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  skipped: 0

These are the steps to add to Bookstore's GitHub Actions workflow. Running architecture tests in a separate step makes the red mark on a PR read as "an architectural decision was violated" rather than "a test broke":

name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet build -c Debug
      # Architectural decisions: fast, deterministic, on every PR
      - name: Architecture fitness functions
        run: dotnet test --no-build --filter-trait "Category=Architecture"
      - name: All tests
        run: dotnet test --no-build

We ran the workflow's commands locally; we did not try a run on GitHub for this post. Because ArchUnitNET reads compiled binaries, the tests have to run on top of the build output. The details of protecting the Dependency Rule with rules specific to Clean Architecture are in the "Testing and Protecting Clean Architecture" post of the upcoming Clean, Onion and Hexagonal Architecture series; protecting module boundaries is the subject of the Modular Monolith series.

Common Pitfalls

Trusting a rule without ever making it fail. As we saw with the top-level Program, the tool may not see some dependencies, or the rule may be written with the wrong selector. Try a deliberate violation for every rule.

Rules without a reason. A rule with no Because, or not tied to any decision, gets deleted at the first annoying red. A rule should be tied to an ADR, or at least to a one-sentence reason.

Breaking CI with millisecond thresholds. Noisy measurements produce flaky tests and the team learns to ignore red. Use deterministic proxies in CI (query count, allocation count, payload size); measure time in an environment close to production.

Too many, too detailed rules. Hundreds of rules testing every class name and every folder punish refactoring. A rule should protect decisions that are expensive to change: layer direction, module boundaries, dependencies that reach the outside world.

Never tightening the ratchet. If the ceiling never goes down, the ratchet is just a "for now" list. Update the constant every time the number drops.

Silencing a red test with Skip. If the rule no longer holds, change the decision, update the ADR and delete the test deliberately. A skipped test is a decision nobody owns.

Category Roadmap: Which Question Leads to Which Series?

The Introduction to Architecture series ends here. Over nine posts we talked about what architecture is, how to define and measure characteristics, trade-offs, the first styles, team structure and protecting decisions. The rest of the category takes each of the questions this series opened and goes deeper:

  • "Why did we make this decision, and how do I write it down?" → the Architecture Decisions series (ADRs, trade-off analysis, C4 (Context, Container, Component, Code) diagrams and arc42).
  • "The dependency direction in a layered structure is holding me back." → the Clean, Onion and Hexagonal Architecture series.
  • "I want to organize code by feature, not by layer." → the Vertical Slice Architecture series.
  • "How do I split a monolith into modules and protect the boundaries?" → the Modular Monolith series.
  • "How do I model the domain, and where should boundaries run?" → the Domain-Driven Design series.
  • "The read and write sides have different needs." → the CQRS (Command Query Responsibility Segregation) and Event Sourcing series.
  • "Let the parts talk without waiting for each other." → the Messaging and Event-Driven Architecture series.
  • "Is it time to split into services, and how do I split?" → the Microservices series.
  • "Why are the network, clocks, replication and consistency so hard?" → the Distributed Systems Fundamentals series.
  • "How do I handle this load, and how do I calculate capacity?" → the System Design series.
  • "MVC (Model-View-Controller) or MVVM (Model-View-ViewModel) on the Angular side, and where should state live?" → the UI (User Interface) Architecture Patterns series.

A Small Experiment

★☆☆ Adapt this post's LayerTests to your own layered project and run it. How many violations came up? For each one, ask "is this a decision or an accident?"

★★☆ Turn the modifiability scenario from the third post into a test: add a Payments module to the modular monolith example from the sixth post and write the rule "Payments cannot depend on Ordering" with ArchUnitNET. Then add a deliberate violation and watch the rule turn red.

★★★ Add a second fitness function to QueryBudgetTests: have it limit the number of rows the same page reads from the database, or the size of the JSON (JavaScript Object Notation) payload it returns. Then rewrite LayerTests so that it adds the composition root to the rule as an explicit exception, and move the AddDbContext call into an extension method to confirm that the rule sees it this time.

The series ends here, but Bookstore's story goes on. The next stop in the category is recording the decisions themselves that we turned into tests in this post: in the Architecture Decisions series we will cover writing ADRs, comparing options with scenarios, and keeping decisions alive together with fitness functions.