1 The Crisis of Traditional Architecture Governance
1.1 Introduction: Beyond the Ivory Tower
For decades, the role of a software architect was painted in broad, almost romantic strokes. The architect sat above the fray of daily coding, producing carefully crafted diagrams, technical specifications, and architectural vision documents. In many organizations, this position acted as a gatekeeper—the person who signed off on designs before implementation began. The architect’s “blueprints” were handed down to development teams who were expected to follow them faithfully.
In the pre-agile era, this made sense. Waterfall-style project delivery assumed long, predictable phases: analysis, design, build, and deploy. Governance could be enforced via checkpoints—milestones where the architecture was reviewed against static documents. The architect’s role was less about active engagement and more about creating guardrails up front.
But the realities of modern software delivery have made this model brittle. Agile, DevOps, and continuous delivery mean that software evolves daily, sometimes hourly. The system you designed six months ago may be almost unrecognizable today. New frameworks, new integrations, urgent bug fixes, and customer-driven features can all push the architecture away from the original design. Manual governance, based on infrequent reviews and static documentation, simply can’t keep pace.
Three major pitfalls have emerged in this context:
- Slow feedback loops. By the time an architectural violation is detected in a periodic review, it may already be deeply embedded, costly to undo, or part of multiple deployed releases.
- Architectural drift. Without constant reinforcement, small deviations accumulate into major divergences from the intended architecture.
- The “PowerPoint architect” anti-pattern. Architects risk becoming disconnected from the reality of the codebase—approving designs in theory but unable to verify whether implementation matches intention.
In an environment where speed, adaptability, and incremental delivery are non-negotiable, architecture must evolve from a static plan to a living, continuously validated system. Governance cannot be a phase-gate; it must be an ongoing, automated, and collaborative activity.
Core thesis: Modern architecture governance requires embedding its rules into the development and delivery process itself—automating checks and feedback so that architecture remains intentional as the system evolves.
1.2 What is Architectural Drift and Why Should You Care?
Architectural drift is the quiet, gradual erosion of a system’s intended design over time. It rarely happens in dramatic leaps; instead, it creeps in through a series of small, seemingly harmless changes—an extra dependency here, a shortcut to meet a deadline there.
Formal definition: Architectural drift occurs when the implemented architecture diverges from the planned or intended architecture, without a deliberate design decision to justify the change.
While some changes represent architectural evolution—intentional, guided adaptation—drift is unplanned and often unnoticed until it becomes a problem.
Consequences of unchecked drift
- Increased technical debt. The cost of making changes rises as the codebase becomes less aligned with its intended modular structure.
- Decreased modularity. Layers and boundaries start to blur, making the system harder to reason about and test.
- Performance bottlenecks. Dependencies form in ways that slow critical code paths or increase resource contention.
- Security vulnerabilities. Unintended coupling or misplaced functionality can bypass security controls or expose sensitive data.
- Slower delivery. Teams spend more time understanding and working around unintended complexity, delaying feature releases.
Real-world case study (anonymized): A global financial services company built a new loan origination platform with a clean layered architecture: UI → Application → Domain → Infrastructure. Early on, delivery pressure led to “temporary” cross-layer calls from UI directly to Infrastructure for certain data access. These shortcuts weren’t caught—there was no automated architectural enforcement. Over two years, the pattern spread. By the time a performance crisis hit, refactoring would require touching hundreds of classes and rewriting core components. The system had effectively become a big ball of mud, and delivery velocity dropped by 40%. The architectural drift, invisible at first, had become a major business liability.
Key insight: Architectural drift is not just a technical nuisance; it is an organizational risk. Left unchecked, it erodes the very qualities—modularity, performance, security—that architecture is meant to preserve.
1.3 The Rise of Evolutionary Architecture
To address the realities of continuous change, the software architecture community has embraced a new paradigm: Evolutionary Architecture.
Definition: Evolutionary Architecture is an approach to building systems that supports guided, incremental change across multiple architectural dimensions, such as performance, scalability, security, and maintainability.
The goal isn’t to freeze an architecture in time. It’s to enable it to adapt safely and intentionally, guided by measurable constraints.
Core principles
- Last responsible moment. Defer irreversible decisions until you have the maximum amount of relevant information. This avoids locking into suboptimal designs too early.
- Architecting for change. Design components, boundaries, and APIs with change in mind—anticipating that parts of the system will evolve independently.
- Feedback loops. Continuously measure architectural qualities and verify that changes align with intended outcomes. This feedback must be automated, frequent, and visible to the whole team.
The role of Architectural Fitness Functions
Fitness functions are the engine of evolutionary architecture. Borrowed from evolutionary biology, a fitness function in software is an automated, objective, repeatable test that assesses a specific architectural characteristic. They are not limited to code structure—they can validate performance thresholds, security configurations, resilience patterns, and more.
By embedding these checks into the CI/CD pipeline, teams can:
- Catch violations early—before they spread.
- Provide immediate feedback to developers.
- Maintain intentional architecture even in high-change environments.
In this paradigm, the architect is not an occasional gatekeeper but an active participant in defining, refining, and evolving the set of fitness functions that encode the organization’s architectural principles.
Pro Tip: Start with one or two high-impact fitness functions that protect the most critical architectural boundaries. As the team sees value, expand coverage to other dimensions. Over time, your architecture’s “fitness” becomes as observable and measurable as your unit test coverage.
2 Foundational Concepts: Understanding Architectural Fitness Functions
2.1 What Exactly is a Fitness Function?
In evolutionary biology, a fitness function is a way of measuring how well an organism is adapted to its environment. It quantifies “fitness” in terms of survival and reproduction—the higher the score, the better the chance of thriving. In this context, the environment is not static. Climate, predators, resources, and competitors all change over time, so fitness is not a one-off judgment but an ongoing measurement.
When we borrow this idea for software, the “organism” is our system’s architecture, and the “environment” is the ever-changing business, technical, and operational context. A software fitness function becomes an automated, repeatable check that tells us whether the architecture still meets a defined criterion—be it a layering rule, a performance SLA, or a security policy.
The shift here is profound: instead of trusting that design documents and human discipline will keep the architecture healthy, we encode architectural expectations directly into executable checks. These run continuously, providing immediate feedback whenever a change threatens to degrade an important property.
Key properties of a good fitness function:
- Automated – Must run without manual intervention so it can integrate into CI/CD and run frequently.
- Objective – Should produce consistent results regardless of who runs it or when.
- Context-specific – Must reflect the architectural priorities of the system, not generic best practices alone.
- Continuous – Should be run regularly, ideally on every code change, so drift is caught early.
Example – Enforcing a maximum public API surface in a shared library:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.Fluent;
using Xunit;
public class PublicApiSurfaceTests
{
[Fact]
public void SharedLibrary_Should_Not_Expose_More_Than_50_Public_Types()
{
var architecture = new ArchLoader().LoadAssemblies(typeof(MySharedLibrary.SomeType).Assembly).Build();
var publicTypes = architecture.Types.Where(t => t.Visibility == ArchUnitNET.Domain.Visibility.Public).Count();
Assert.True(publicTypes <= 50, $"Public API surface too large: {publicTypes} public types found.");
}
}
This function doesn’t rely on someone remembering to “check the API size” during a review; it codifies the rule and executes it on every commit.
Pro Tip: Start by identifying the architectural properties that are easiest to measure and most critical to your system’s health. Automating those checks first delivers immediate value without overwhelming the team.
2.2 The Spectrum of Fitness Functions: Categories and Types
Not all fitness functions are alike. They differ by scope, execution model, and what aspect of the system they examine. Understanding this spectrum helps in choosing the right kind of function for the right job.
2.2.1 Static vs. Dynamic
-
Static (Code-level) fitness functions analyze artifacts such as source code, binaries, and configuration files without executing the system. Examples:
- Enforcing that no domain layer depends on infrastructure.
- Checking cyclomatic complexity of methods.
- Verifying absence of banned namespaces.
Example – Preventing domain layer from referencing Entity Framework:
[Fact] public void DomainLayer_Should_Not_Depend_On_EntityFramework() { var architecture = new ArchLoader().LoadAssemblies( typeof(MyDomain.SomeDomainType).Assembly, typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly).Build(); var rule = ArchRuleDefinition .Types().That().ResideInNamespace("MyApp.Domain", true) .Should().NotDependOnAny("Microsoft.EntityFrameworkCore"); rule.Check(architecture); } -
Dynamic (Runtime) fitness functions evaluate the system while it is running, often in a staging or production-like environment. Examples:
- Measuring request latency under load.
- Checking that all API endpoints require authentication.
- Verifying cache hit rates or memory usage.
Example – Verifying API latency in integration tests:
[Fact] public async Task CriticalEndpoint_Should_Respond_Within_200ms() { using var client = new HttpClient(); var stopwatch = Stopwatch.StartNew(); var response = await client.GetAsync("https://staging.api.myapp.com/critical-endpoint"); stopwatch.Stop(); Assert.True(stopwatch.ElapsedMilliseconds <= 200, $"Latency exceeded: {stopwatch.ElapsedMilliseconds} ms"); }
Pitfall: Static analysis is faster and easier to integrate into CI pipelines, but it can’t detect issues that only emerge under runtime conditions (e.g., configuration drift, slow queries). Conversely, runtime checks can catch these but are slower and more complex to execute.
2.2.2 Atomic vs. Holistic
- Atomic fitness functions test one specific property.
Example: “All controllers must reside in the
.Controllersnamespace.” - Holistic fitness functions combine several atomic checks to assess a broader characteristic. Example: “Our API’s security posture score” could combine checks for authentication coverage, input validation, and secure headers.
Trade-off: Holistic metrics can give a more realistic view of overall health but are harder to interpret when they fail—teams may need to drill into atomic results to find the cause.
2.2.3 Triggered vs. Continuous
-
Triggered fitness functions run on specific events such as:
- Pull request creation.
- Nightly builds.
- Manual execution for special audits.
-
Continuous fitness functions run persistently, monitoring live systems for violations of SLOs or critical thresholds. Example: Monitoring CPU usage across microservices to ensure no service exceeds 70% for more than 1 minute.
Note: For high-stakes properties (security, uptime), continuous monitoring is essential. For structural code rules, triggered execution in CI/CD is usually sufficient.
2.2.4 Domain-Specific Fitness Functions
Some rules are dictated not by generic architectural purity but by business or regulatory needs.
- Financial software may require segregation of duties in code—no component that processes trades may log customer account numbers.
- Healthcare systems must enforce HIPAA constraints—no protected health information (PHI) is stored in logs.
Example – Ensuring no logging of PII:
[Fact]
public void Logging_Should_Not_Contain_PII_Keywords()
{
var logFiles = Directory.GetFiles("logs", "*.log");
var piiKeywords = new[] { "SSN", "DateOfBirth", "CreditCardNumber" };
foreach (var file in logFiles)
{
var contents = File.ReadAllText(file);
foreach (var keyword in piiKeywords)
{
Assert.DoesNotContain(keyword, contents, StringComparison.OrdinalIgnoreCase);
}
}
}
Pro Tip: Domain-specific rules are often the ones that get overlooked in generic static analysis tools. Encoding them as fitness functions makes compliance auditable and transparent.
2.3 The Business Case: Why Invest in Fitness Functions?
While the technical value of fitness functions is clear to architects, winning organizational support requires framing them in terms of business outcomes.
2.3.1 Moving from Subjective to Objective
Architectural debates often degrade into subjective arguments—one developer’s “good enough” is another’s “unacceptable.” Fitness functions replace opinion with data:
- Instead of “Our API is too slow”, you have “99th percentile latency is 450ms, above the 300ms target.”
- Instead of “We should reduce dependencies”, you have “The domain layer now depends on 3 new external libraries, violating our max of 1.”
This objectivity shortens decision cycles and reduces conflict by aligning on facts.
2.3.2 Making Constraints Visible and Testable
When architectural constraints are hidden in documents or tribal knowledge, they’re easily forgotten. Encoding them as tests:
- Puts them in front of developers daily via CI failures.
- Makes them discoverable for new team members.
- Ensures that refactorings and new features don’t unintentionally break them.
Example: A new developer joins and tries to inject an EF Core DbContext directly into a domain service. The build fails immediately with a clear error message, avoiding weeks of hidden drift.
2.3.3 Reducing Risk and Improving Quality
Fitness functions reduce the probability of:
- Late-stage architectural rework.
- Performance regressions going unnoticed until production.
- Security vulnerabilities slipping past manual review.
This translates directly into lower maintenance costs, fewer outages, and faster recovery from incidents.
Trade-off: Building and maintaining a suite of fitness functions has an upfront cost. But like automated tests, the return on investment compounds over time as the system grows in complexity.
2.3.4 Enabling Faster, Safer Delivery
With fitness functions embedded in CI/CD, teams gain confidence to deliver changes rapidly:
- Developers can experiment more freely, knowing violations will be caught automatically.
- Architects can evolve constraints as business needs change, without relying on all developers memorizing them.
- Leadership can see quantifiable architectural health metrics alongside functional test coverage.
Pro Tip: Use dashboards to surface key fitness function results to stakeholders. Framing architecture in terms of “health indicators” resonates with business leaders and supports data-driven investment decisions.
3 Practical Implementation: Codifying Architecture with ArchUnit.NET
3.1 Introduction to ArchUnit.NET: Your Architectural Sentry
When you’ve defined your architectural principles, the next step is making them executable. This is where ArchUnit.NET comes in—a free, open-source library inspired by Java’s ArchUnit, designed to let you specify and enforce architectural rules in C# through a fluent, expressive API. It treats your compiled assemblies as analyzable artifacts, letting you query types, namespaces, dependencies, and more.
The real strength of ArchUnit.NET lies in its ability to fit seamlessly into your existing test suite. Architectural rules become just another set of automated tests, executed alongside your unit and integration tests. When a rule fails, you get a clear message in your CI pipeline—turning architecture governance from a periodic, manual process into a continuous, automated one.
Setting up your first project:
- Add a dedicated test project to your solution, e.g.,
MyApp.ArchitectureTests. - Install the necessary NuGet packages:
dotnet add package ArchUnitNET
dotnet add package ArchUnitNET.xUnit
- Reference the assemblies you want to analyze. Often, you’ll point to your main application projects directly.
- Start writing rules in test methods. If you’re using xUnit, they run like any other
[Fact]test.
Pro Tip: Keep architecture tests in their own project. This separation ensures they’re easy to discover and maintain, and you can run them independently when focusing solely on architectural health.
3.2 Core Concepts and Syntax in ArchUnit.NET
ArchUnit.NET works by loading your application’s assemblies into a model it can query. You define rules using the fluent API, apply them to selected elements, and then check them against the architecture model.
3.2.1 Loading the Architecture
You start by creating an architecture object using the ArchLoader:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
var architecture = new ArchLoader()
.LoadAssemblies(
typeof(MyApp.Program).Assembly,
typeof(MyApp.Domain.SomeEntity).Assembly)
.Build();
You can load multiple assemblies if your architecture spans multiple projects.
3.2.2 Defining Rules with the Fluent API
The entry point for rule creation is ArchRuleDefinition. From there, you select elements (classes, methods, etc.), define conditions, and then check them.
3.2.3 Selecting Elements
ArchUnit.NET provides selectors such as:
Classes()– All classes.Interfaces()– All interfaces.Methods()– All methods.Attributes()– Custom or framework attributes.
You can refine selection:
Classes().That().ResideInNamespace("MyApp.Domain", true)
The second parameter true means “include sub-namespaces.”
3.2.4 Applying Conditions
Once you’ve selected elements, you apply conditions:
Should()/ShouldNot()– Define expectations.Be()/Have()– Check specific properties.DependOn()/ResideInNamespace()– Enforce dependency or location rules.
Example:
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace("MyApp.Application", true)
.Should().OnlyHaveDependentClassesThat().ResideInNamespace("MyApp.Presentation", true);
3.2.5 Checking the Rules
Finally, run the check:
rule.Check(architecture);
In test frameworks like xUnit:
[Fact]
public void ApplicationLayer_Should_Not_Depend_On_PresentationLayer()
{
var rule = /* rule definition */;
rule.Check(architecture);
}
Pitfall: Don’t hardcode project names or namespaces in too many rules without centralizing them—changes in naming conventions can cause widespread breakages in your architecture tests.
3.3 Real-World Examples: From Simple to Complex
3.3.1 Enforcing Layering and Dependency Rules (The Classic Use Case)
Layered architecture is a natural starting point for fitness functions. Let’s assume the classic four layers:
- Presentation – UI controllers, views, API endpoints.
- Application – Application services, use cases.
- Domain – Entities, value objects, domain services.
- Infrastructure – Persistence, messaging, external API integrations.
Example – Domain layer should not depend on Infrastructure:
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var architecture = new ArchLoader()
.LoadAssemblies(
typeof(MyApp.Domain.SomeEntity).Assembly,
typeof(MyApp.Infrastructure.SomeRepository).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace("MyApp.Domain", true)
.Should().NotDependOnAny("MyApp.Infrastructure");
rule.Check(architecture);
}
Example – Application layer should depend only on Domain interfaces, not Infrastructure concretes:
[Fact]
public void Application_Should_Only_Depend_On_Domain()
{
var architecture = new ArchLoader()
.LoadAssemblies(
typeof(MyApp.Application.SomeService).Assembly,
typeof(MyApp.Domain.SomeEntity).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace("MyApp.Application", true)
.Should().OnlyDependOnTypesThat()
.ResideInNamespace("MyApp.Domain", true);
rule.Check(architecture);
}
Pro Tip: Define your layer namespaces in constants to avoid typos:
public static class Namespaces
{
public const string Domain = "MyApp.Domain";
public const string Application = "MyApp.Application";
public const string Infrastructure = "MyApp.Infrastructure";
}
3.3.2 Guarding Your Domain Model’s Purity
Domain models should remain free from external framework dependencies. This protects them from technology churn and makes them portable.
Example – Domain entities must not depend on ASP.NET Core or EF Core:
[Fact]
public void Domain_Should_Not_Depend_On_Frameworks()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Domain.SomeEntity).Assembly)
.Build();
var forbiddenNamespaces = new[]
{
"Microsoft.AspNetCore",
"Microsoft.EntityFrameworkCore"
};
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace(Namespaces.Domain, true)
.Should().NotDependOnAny(forbiddenNamespaces);
rule.Check(architecture);
}
Example – Aggregates must be sealed or abstract:
[Fact]
public void Aggregates_Should_Be_Sealed_Or_Abstract()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Domain.Aggregates.Order).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace("MyApp.Domain.Aggregates", true)
.Should().BeSealed().OrShould().BeAbstract();
rule.Check(architecture);
}
3.3.3 Enforcing Naming and Coding Conventions
Names are a subtle but powerful way to communicate intent. ArchUnit.NET can enforce these conventions.
Example – Repository naming:
[Fact]
public void Repositories_Should_End_With_Repository()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Infrastructure.OrderRepository).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ImplementInterface("MyApp.Domain.Repositories.IRepository")
.Should().HaveNameEndingWith("Repository");
rule.Check(architecture);
}
Example – Controllers must be in .Controllers namespace and have [ApiController]:
[Fact]
public void Controllers_Should_Have_ApiController_Attribute()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Presentation.Controllers.HomeController).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace("MyApp.Presentation.Controllers", true)
.Should().HaveAnyAttributes(typeof(Microsoft.AspNetCore.Mvc.ApiControllerAttribute));
rule.Check(architecture);
}
3.3.4 Preventing Forbidden Dependencies
Some dependencies should never be used directly, either due to security concerns or because you’ve standardized on an abstraction.
Example – Forbid System.Data.SqlClient:
[Fact]
public void No_Direct_SqlClient_Usage()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Program).Assembly)
.Build();
var rule = ArchRuleDefinition
.Types().Should().NotDependOnAny("System.Data.SqlClient");
rule.Check(architecture);
}
Example – Ban obsolete library:
[Fact]
public void No_Obsolete_Library_References()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Program).Assembly)
.Build();
var rule = ArchRuleDefinition
.Types().Should().NotDependOnAny("LegacyPaymentGateway");
rule.Check(architecture);
}
Pitfall: When banning a dependency, ensure you provide a clear migration path—otherwise, developers may feel blocked without alternatives.
3.3.5 Slices and Feature-Based Rules
For vertical slice architectures, where features are organized end-to-end, you can ensure strict independence between slices.
Example – Feature slices cannot depend on other slices’ concretes:
[Fact]
public void Features_Should_Not_Cross_Depend()
{
var architecture = new ArchLoader()
.LoadAssemblies(typeof(MyApp.Features.Ordering.OrderService).Assembly)
.Build();
var rule = ArchRuleDefinition
.Slices().Matching("MyApp.Features.(*)..")
.Should().NotDependOnEachOther();
rule.Check(architecture);
}
Pro Tip: The slice feature is powerful for large modular systems. By defining slices at the namespace level, you make sure feature teams can work independently without unintended coupling.
4 Integrating Fitness Functions into the CI/CD Pipeline
4.1 The Goal: Continuous Architecture Validation
Embedding fitness functions into your CI/CD pipeline ensures that architectural integrity is checked as part of every build, just like unit and integration tests. This is the practical implementation of “shifting left” in architecture governance—detecting problems as early as possible, ideally before code ever reaches a shared branch.
When a fitness function fails, the build itself should fail. This gives developers immediate feedback that a rule has been broken and prevents violations from merging unnoticed. It’s the same principle that makes automated testing effective: fast, consistent, and enforceable checks.
The feedback loop here is critical:
- Developer pushes code or opens a pull request.
- Pipeline executes all tests, including architecture tests.
- If a fitness function fails, the build fails with a clear, actionable message.
- Developer fixes the violation before merging.
This loop turns architecture from a slow, subjective review into a continuous, objective enforcement mechanism.
Pro Tip: Treat architectural rules as “quality gates” in the same way you treat security scans or code coverage thresholds. They are non-negotiable criteria for moving code forward.
4.2 Implementation in Azure DevOps
Azure DevOps pipelines can run your ArchUnit.NET tests as part of the standard dotnet test execution. The process is straightforward once you have your architecture tests in a dedicated test project.
Steps:
- Ensure the test project is in your solution – e.g.,
MyApp.ArchitectureTests. - Configure your pipeline in
azure-pipelines.yml. - Use the
DotNetCoreCLI@2task to restore, build, and test. - Publish test results so failures are visible in the Azure DevOps UI.
Example – azure-pipelines.yml:
trigger:
branches:
include:
- main
- develop
pr:
branches:
include:
- main
- develop
pool:
vmImage: 'windows-latest'
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build solution'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--no-restore --configuration Release'
- task: DotNetCoreCLI@2
displayName: 'Run all tests (including architecture tests)'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--no-build --configuration Release --logger trx'
- task: PublishTestResults@2
inputs:
testResultsFiles: '**/*.trx'
testRunTitle: 'Architecture & Unit Test Results'
Pitfall: If your architecture tests depend on multiple assemblies, make sure those projects are built before the test project runs—missing assemblies will cause the ArchLoader to fail.
Note: You can add conditions to run architecture tests only on pull requests or nightly builds, but for maximum protection, run them on every commit to shared branches.
4.3 Implementation in GitHub Actions
GitHub Actions offers a similar setup using workflows defined in .github/workflows.
Steps:
- Create a workflow file like
.github/workflows/architecture-tests.yml. - Set up .NET with
actions/setup-dotnet. - Build and test your solution, ensuring the architecture test project runs.
- Trigger the workflow on relevant branches and pull requests.
Example – architecture-tests.yml:
name: Architecture Tests
on:
pull_request:
branches:
- main
- develop
push:
branches:
- main
- develop
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run architecture tests
run: dotnet test --no-build --configuration Release --logger trx
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/*.trx'
Pro Tip: GitHub Actions integrates well with status checks—mark your architecture test job as “required” before a PR can be merged. This ensures no architectural violation slips in.
Trade-off: Running architecture tests on every commit provides maximum safety but can slow pipelines if they are very large. In such cases, run a fast subset on every commit and the full suite on PRs or nightly.
4.4 Handling Failures and Exceptions
A failed architecture test should not be a mystery to the developer. The failure message should make it clear what rule was broken, why it exists, and how to fix it. ArchUnit.NET’s .Because() method is invaluable for this.
Example – Adding a clear explanation:
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var architecture = new ArchLoader()
.LoadAssemblies(
typeof(MyApp.Domain.SomeEntity).Assembly,
typeof(MyApp.Infrastructure.SomeRepository).Assembly)
.Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace(Namespaces.Domain, true)
.Should().NotDependOnAny(Namespaces.Infrastructure)
.Because("it would create tight coupling and make the domain model harder to test and evolve.");
rule.Check(architecture);
}
When this fails, the CI output will include the reason, turning the violation into a learning opportunity.
Managing temporary exceptions: Sometimes a violation is known but cannot be fixed immediately—perhaps due to a larger refactoring or a dependency upgrade. In these cases:
- Track the violation in an “Architectural Scrum Board”—a backlog dedicated to architectural debt.
- Use
[Fact(Skip = "...reason...")]sparingly for temporary skips. - Add TODO comments with target removal dates.
Pitfall: Letting skipped architecture tests linger indefinitely turns them into dead rules. Regularly review and re-enable skipped tests.
Note: If you have multiple teams working in the same codebase, make exception handling visible to all stakeholders—otherwise, one team’s “temporary” skip becomes another’s new normal.
5 Beyond Dependency Checks: Advanced Fitness Function Patterns
5.1 Dynamic and Operational Fitness Functions
While static code analysis covers a large portion of architectural rules, some of the most critical architectural qualities only emerge when the system runs. These include performance, scalability, resilience, and observability. Dynamic and operational fitness functions extend the scope of architecture validation into the runtime world.
5.1.1 Performance Checks in CI/CD
Performance regressions can sneak into a system through seemingly harmless changes—a slightly heavier LINQ query, an unoptimized serialization path, or an overlooked cache miss. Instead of waiting for customer complaints, you can integrate BenchmarkDotNet into your pipeline to measure critical code paths.
Example – Benchmarking a JSON serializer:
[MemoryDiagnoser]
public class SerializationBenchmarks
{
private readonly MyObject _obj = new MyObject { Id = 1, Name = "Test" };
[Benchmark]
public string Serialize()
{
return JsonSerializer.Serialize(_obj);
}
}
To run benchmarks in CI:
- Create a benchmark project using BenchmarkDotNet.
- Execute it in the pipeline and capture results.
- Fail the build if performance deviates beyond an acceptable threshold.
Pro Tip: Keep CI benchmarks short—microbenchmarks in under a second each. Longer stress tests should run in scheduled performance test pipelines.
5.1.2 Scalability and Resilience Rules
Architectural resilience patterns—such as retries, timeouts, and circuit breakers—are often discussed in design sessions but can silently disappear during implementation. By combining .NET’s Polly library with custom tests, you can ensure these patterns are actually in place.
Example – Ensuring an HTTP client has retry policy:
[Fact]
public void HttpClient_Should_Have_RetryPolicy()
{
var service = new MyService();
var httpClientField = typeof(MyService)
.GetField("_httpClient", BindingFlags.NonPublic | BindingFlags.Instance);
var policyField = typeof(MyService)
.GetField("_retryPolicy", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(policyField?.GetValue(service));
}
You can also integrate chaos engineering tools like Chaos Mesh or Gremlin in staging environments to verify that your resilience policies trigger correctly under simulated failures.
Trade-off: Running chaos tests in CI can lengthen pipelines. Many teams run lightweight resilience assertions in CI and schedule deeper chaos experiments in staging environments.
5.1.3 Observability Checks
Observability is now an architectural quality, not just an operational concern. You can write fitness functions to ensure:
- All public APIs log incoming requests.
- Critical services expose metrics via OpenTelemetry.
- Tracing spans are present for distributed calls.
Example – Verifying all controllers use structured logging:
[Fact]
public void Controllers_Should_Log_StructuredMessages()
{
var controllers = Assembly.GetAssembly(typeof(Program))
.GetTypes()
.Where(t => t.Name.EndsWith("Controller"));
foreach (var controller in controllers)
{
var hasLogger = controller
.GetConstructors()
.Any(c => c.GetParameters()
.Any(p => p.ParameterType.Name.Contains("ILogger")));
Assert.True(hasLogger, $"{controller.Name} does not have an ILogger dependency.");
}
}
Note: Observability fitness functions are a bridge between DevOps and architecture teams—creating shared accountability for diagnosable, maintainable systems.
5.2 Security Fitness Functions
Security rules are often buried in static documents or security audit reports. By turning them into automated tests, you enforce them consistently on every change.
5.2.1 Using SAST Tools in Pipelines
Static Application Security Testing (SAST) tools like Snyk, SonarQube, or GitHub CodeQL can be integrated into pipelines to catch known vulnerabilities, insecure APIs, and outdated libraries.
Example – SonarQube in Azure DevOps:
- task: SonarQubePrepare@5
inputs:
SonarQube: 'SonarQubeServiceConnection'
scannerMode: 'MSBuild'
projectKey: 'MyApp'
projectName: 'MyApp'
- task: DotNetCoreCLI@2
inputs:
command: 'build'
projects: '**/*.csproj'
- task: SonarQubeAnalyze@5
Pro Tip: Combine SAST with dependency vulnerability scanning in the same pipeline stage for a full security snapshot.
5.2.2 Custom Security Rules
Not all security constraints are detectable by generic SAST tools. For example, in an ASP.NET Core application, you may require that all controller actions be secured with [Authorize] unless explicitly marked as [AllowAnonymous].
Example – Enforcing authorization on all controllers:
[Fact]
public void AllControllerActions_Should_BeSecured()
{
var controllers = Assembly.GetAssembly(typeof(Program))
.GetTypes()
.Where(t => t.Name.EndsWith("Controller"));
foreach (var controller in controllers)
{
var methods = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.DeclaringType == controller);
foreach (var method in methods)
{
var hasAuth = method.GetCustomAttributes(typeof(AuthorizeAttribute), true).Any()
|| controller.GetCustomAttributes(typeof(AuthorizeAttribute), true).Any();
var allowAnon = method.GetCustomAttributes(typeof(AllowAnonymousAttribute), true).Any();
Assert.True(hasAuth || allowAnon, $"{controller.Name}.{method.Name} is missing [Authorize]");
}
}
}
Pitfall: Be cautious about blanket rules—some endpoints (health checks, login endpoints) are intentionally anonymous. Build exceptions into the test where justified.
5.3 Measuring and Visualizing Architectural Characteristics
Fitness functions generate valuable metrics over time. Tracking these metrics lets you see architectural health trends and spot areas of concern before they become crises.
5.3.1 Quantifying Violations
Instead of only pass/fail, fitness functions can output counts or scores.
- Count of dependency rule violations.
- Average complexity of application services.
- Percentage of controllers meeting observability standards.
You can aggregate these metrics into a “modularity index” or “architecture health score” for leadership reporting.
Example – Counting rule violations:
[Fact]
public void Count_Domain_Violations()
{
var architecture = new ArchLoader().LoadAssemblies(typeof(MyApp.Program).Assembly).Build();
var rule = ArchRuleDefinition
.Classes().That().ResideInNamespace(Namespaces.Domain, true)
.Should().NotDependOnAny(Namespaces.Infrastructure);
var result = rule.Evaluate(architecture);
var violationCount = result.Failures.Count();
Assert.True(violationCount == 0, $"Domain layer has {violationCount} violations.");
}
5.3.2 Visualizing Architecture Models
Tools like Structurizr or PlantUML can generate C4 diagrams from code annotations. You can then write tests to compare the generated model against your intended architecture—flagging unexpected dependencies or missing components.
Pro Tip: Automate diagram generation in CI so your architecture documentation is always up to date.
5.3.3 Dashboards for Continuous Insight
Surface metrics in tools like:
- Azure DevOps Dashboards – Display test pass rates, violation counts, and trends.
- Grafana – Pull metrics from your pipeline runs and visualize over time.
- Power BI – Combine architecture metrics with business KPIs to correlate technical health with delivery outcomes.
Note: When teams see these metrics daily, architectural health becomes part of the culture—similar to how code coverage or deployment frequency is widely tracked in DevOps-oriented teams.
6 The Human and Organizational Element
6.1 Gaining Buy-In and Driving Adoption
The success of architectural fitness functions isn’t determined solely by their technical implementation—it depends on how they’re perceived and embraced within the organization. If they are introduced as policing tools, they’ll quickly be resisted. If they’re framed as enablers, they can become a natural part of the team’s culture.
The architect’s role here shifts from enforcer to coach. Rather than issuing top-down mandates, architects can position fitness functions as safety nets that help developers move faster without fear of breaking hidden rules. For example, a rule that prevents direct access from the presentation layer to the database isn’t a punishment—it’s an automatic reminder that preserves clean layering without requiring everyone to memorize the architecture diagram.
Starting small is key. Identify one high-value, high-risk architectural constraint—perhaps preventing sensitive data from being logged—and automate just that. Show how it catches a real issue early, before it becomes a production problem. That quick win builds credibility and demonstrates that these checks save time, not add bureaucracy.
Collaboration is equally important. Involve developers in defining and refining the rules. For instance, run a workshop where the team lists “architecture guardrails we wish we had.” Together, turn one into a fitness function. This shared ownership not only improves rule quality but also increases the likelihood that rules are respected rather than bypassed.
Pro Tip: Make the first few rules as visible as possible in demos, sprint reviews, or architecture guild meetings. When developers see a failing rule catch something they missed, adoption accelerates naturally.
6.2 Common Pitfalls and How to Avoid Them
6.2.1 Tooling Worship
It’s easy to get excited about ArchUnit.NET or similar tools and focus on what they can check rather than what they should check. This leads to rules that look impressive but have little architectural value.
Avoidance strategy: Start with the principle, not the tool. Ask, “What risk am I trying to mitigate?” before writing a rule. For example, “We must keep domain logic independent of persistence details” is a principle. “No domain class depends on EntityFrameworkCore” is the tool’s translation of that principle.
6.2.2 Over-constraining the Architecture
When every minor coding decision is enforced by a rule, innovation and velocity suffer. Overly prescriptive checks can create friction, leading teams to treat architecture tests as red tape rather than value.
Trade-off: A balance between freedom and structure is critical. Use rules to guard foundational boundaries, not to micromanage every class name or method length. Leave space for experimentation, especially in early iterations of a feature.
Example – Bad (over-constraining):
// This blocks any new DTOs unless they're exactly 3 properties long.
Classes().That().ResideInNamespace("MyApp.DTOs")
.Should().HaveExactlyNumberOfProperties(3);
Example – Good (principle-driven):
// This ensures DTOs have no business logic.
Classes().That().ResideInNamespace("MyApp.DTOs")
.Should().NotHaveAnyMethods();
6.2.3 Ignoring the Feedback
A failing fitness function that stays red for weeks signals that the rule is either irrelevant or unenforceable. Over time, this erodes trust in all architectural checks.
Avoidance strategy:
- Keep rules achievable; don’t release one that will fail for 200 existing violations without a remediation plan.
- Track violations in an Architectural Debt Backlog with priorities and owners.
- Regularly review skipped or failing tests, and either fix them or remove the rule if it’s no longer aligned with the architecture strategy.
Pitfall: Treating architectural rules as optional “nice-to-haves” turns them into noise in the CI output. If a rule isn’t important enough to fix promptly, it shouldn’t be in the pipeline.
7 Conclusion: The Future of Architecture is Automated
7.1 Summary of Key Takeaways
The journey from manual governance to continuous, automated architecture validation is both cultural and technical. We’ve moved from the “ivory tower” architect, who hands down static diagrams, to an embedded, collaborative approach where rules live in code and run on every change.
Fitness functions are the linchpin of this approach. They make architecture principles tangible, executable, and enforceable at scale. Tools like ArchUnit.NET turn abstract boundaries into testable rules, ensuring they are upheld in the same way we uphold functional correctness through unit tests.
By integrating these checks into CI/CD, organizations gain faster feedback loops, reduce architectural drift, and protect critical system qualities without slowing delivery.
7.2 Looking Ahead: AI and the Next Generation of Fitness Functions
The next frontier in architectural automation will likely be AI-assisted analysis. Imagine a model trained on your codebase and historical incidents, capable of:
- Detecting emerging anti-patterns before they cause harm.
- Predicting the architectural impact of a proposed pull request.
- Suggesting optimal refactoring paths based on dependency graphs.
We may even see self-healing architectures—systems that not only detect violations but can automatically remediate them. For example, a microservice that exceeds a latency threshold could auto-scale, rewrite a query, or swap to a cached data source without human intervention.
While we’re not there yet, the trajectory is clear: more automation, deeper insights, and tighter integration between architecture, operations, and development.
Note: As AI-driven checks emerge, human oversight will remain crucial. Automated suggestions must be reviewed to ensure they align with business goals and context.
7.3 Further Reading and Resources
Books
- Building Evolutionary Architectures: Support Constant Change – Neal Ford, Rebecca Parsons, Patrick Kua.
- Software Architecture: The Hard Parts – Neal Ford, Mark Richards, Pramod Sadalage, Zhamak Dehghani.
Tools and Documentation
- ArchUnit.NET GitHub Repository
- BenchmarkDotNet Documentation
- Polly Resilience Framework
- Structurizr for C4 Modeling
Talks and Articles
- Neal Ford – Evolutionary Architecture and Fitness Functions (various conference recordings).
- Mark Richards – Architecture Governance in the Age of Agile.
- ThoughtWorks Technology Radar – Regularly features trends and tools in architecture governance.