Architectural Fitness Functions

·9 min read
Architectural Fitness Functions

“An evolutionary architecture supports guided, incremental change as the first principle across multiple dimensions.” – Pat Kua, Building Evolutionary Architectures

When you work on a codebase long enough, certain patterns start to emerge – repetitive tasks that drain cognitive bandwidth, subtle structural drift that compounds over months, or violations of architectural principles that slip through review.

At some point, you realise manual enforcement isn’t scaling. In a world where LLMs can generate code faster than humans, ensuring that enforcement scales becomes even more important.

That’s where architectural fitness functions come in.

What Are Fitness Functions?

Fitness functions are automated checks that verify whether your architecture is evolving according to defined principles. Think of them as continuous quality gates that run alongside your build pipeline, inspecting code structure, dependencies, naming conventions, and more.

They sit somewhere between linters and integration tests – more abstract than unit tests, but more rigorous than human code review alone.

Common Characteristics

AspectDescription
SpeedFast execution (typically seconds)
ScopeEntire codebase or subsystem
TypeStructural analysis via reflection/static analysis
FrequencyPull requests and CI/CD pipelines

Why Use Them?

The “three strikes” rule applies here: the first time you do something manually, fine. The second time, wince at the repetition. The third time, automate it.

Fitness functions serve several purposes:

  1. Consistency: Enforce uniformity across APIs, layers, and projects
  2. Guardrails: Prevent accidental architectural degradation
  3. Visibility: Make implicit standards explicit and measurable
  4. Scalability: Enable teams to grow without proportional increases in review overhead

Setting Up the Foundation

Before writing individual fitness functions, you’ll want a helper that discovers the types you care about. Here’s a typical starting point using .NET reflection:

[TestFixture]
public abstract class FitnessFunctionTests
{
    private static readonly Assembly[] AssembliesToInspect = LoadAssemblies();

    protected static IEnumerable<Type> AllTypes(params Assembly[] assemblies)
    {
        return assemblies.SelectMany(a => a.GetTypes());
    }

    protected static IEnumerable<Type> ApiModels =>
        AllTypes(AssembliesToInspect)
            .Where(t => t.Namespace?.StartsWith("MyApp.Api.Models") == true);

    protected static IEnumerable<Type> ServiceClients =>
        AllTypes(AssembliesToInspect)
            .Where(t => t.Name.EndsWith("ServiceClient"));

    protected static IEnumerable<Type> UnitTests =>
        AllTypes(AssembliesToInspect)
            .Where(t => t.GetCustomAttribute<TestFixtureAttribute>() != null);
}

With this foundation in place, individual fitness functions become concise, focused assertions.

Practical Examples in .NET/C#

1. JSON Serialization Hygiene

If you’ve ever debugged a production issue caused by a silently renamed property during serialization, you’ll appreciate this one. Require JsonPropertyAttribute with an explicit PropertyName on every API model:

[TestFixture]
public class ApiModelJsonSerializationTests : FitnessFunctionTests
{
    [Test]
    public void AllApiModelsMustSpecifyJsonPropertyNames()
    {
        var violations = ApiModels
            .SelectMany(t => t.GetProperties())
            .Where(p => p.GetCustomAttribute<JsonPropertyAttribute>() == null)
            .ToList();

        Assert.That(violations, Is.Empty,
            "Public API model properties must declare [JsonProperty(PropertyName = ...)]. " +
            $"Violations: {string.Join(", ", violations.Select(p => $"{p.DeclaringType}.{p.Name}"))}");
    }

    [Test]
    public void JsonPropertyMustIncludeExplicitPropertyName()
    {
        var violations = ApiModels
            .SelectMany(t => t.GetProperties())
            .Select(p => p.GetCustomAttribute<JsonPropertyAttribute>())
            .Where(attr => attr != null && string.IsNullOrEmpty(attr.PropertyName))
            .ToList();

        Assert.That(violations, Is.Empty,
            "[JsonProperty] without a PropertyName relies on default resolution-" +
            "rename-refactors silently break API contracts.");
    }
}

Note the assertion message: always tell the developer which types failed and why the rule exists. A fitness function that fails with an opaque message is worse than none at all.

2. Marker Interface Enforcement

Marker interfaces like IServiceClient are only useful if they actually mean something. Without enforcement, they degrade into decoration:

[TestFixture]
public class MarkerInterfaceTests : FitnessFunctionTests
{
    private static readonly HashSet<Type> AllowedImplementors = new()
    {
        typeof(IOrderServiceClient),
        typeof(ICustomerServiceClient),
        // ... registered service client contracts
    };

    [Test]
    public void OnlyRegisteredTypesMayImplementIServiceClient()
    {
        var rogueImplementors = AllTypes(AssembliesToInspect)
            .Where(t => typeof(IServiceClient).IsAssignableFrom(t)
                     && t.IsInterface
                     && !AllowedImplementors.Contains(t))
            .ToList();

        Assert.That(rogueImplementors, Is.Empty,
            "New IServiceClient implementations must be added to AllowedImplementors-" +
            "this prevents bypassing the service registration layer.");
    }

    [Test]
    public void ServiceAdaptersMustNotBeUsedOutsideTheirModule()
    {
        var violations = AllTypes(AssembliesToInspect)
            .Where(t => t.Namespace?.StartsWith("MyApp.Web") == true)
            .Where(t => ReferencesType(t, typeof(IServiceAdapter)))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Controllers must depend on IServiceClient abstractions, not adapters.");
    }
}

3. Dependency Direction Rules

The classic layered architecture constraint – UI depends on services, services depend on repositories, never the reverse:

[TestFixture]
public class LayerDependencyTests : FitnessFunctionTests
{
    [Test]
    public void DomainMustNotReferenceInfrastructure()
    {
        var domainAssembly = typeof(DomainMarker).Assembly;
        var forbiddenReferences = domainAssembly.GetReferencedAssemblies()
            .Where(name => name.Name == "MyApp.Infrastructure")
            .ToList();

        Assert.That(forbiddenReferences, Is.Empty,
            "Domain layer must remain persistence-ignorant.");
    }

    [Test]
    public void ControllersMustOnlyReachServiceLayer()
    {
        var violations = ControllerTypes()
            .Where(c => c.GetMethods()
                .Any(m => m.ReturnType.Namespace?.Contains("Repository") == true))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Controllers returning repository types bypass the service layer.");
    }
}

4. API Contract Conformance

Real API design mistakes, captured as executable rules:

[TestFixture]
public class EndpointConformanceTests : FitnessFunctionTests
{
    [Test]
    public void ProducesResponseCodesMustBeUniquePerEndpoint()
    {
        var violations = AllTypes(AssembliesToInspect)
            .SelectMany(t => t.GetMethods())
            .Select(m => new
            {
                Method = m,
                Codes = m.GetCustomAttributes<ProducesResponseTypeAttribute>()
                    .Select(a => a.StatusCode)
            })
            .Where(x => x.Codes.GroupBy(c => c).Any(g => g.Count() > 1))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Duplicate ProducesResponseType status codes create ambiguous OpenAPI documentation.");
    }

    [Test]
    public void PathParametersMustNotBeOptional()
    {
        // Optional path parameters are illegal in the OpenAPI spec
        var violations = AllTypes(AssembliesToInspect)
            .SelectMany(t => t.GetMethods())
            .SelectMany(m => m.GetParameters())
            .Where(p => p.GetCustomAttribute<FromRouteAttribute>() != null && p.IsOptional)
            .ToList();

        Assert.That(violations, Is.Empty,
            "Optional path parameters produce invalid OpenAPI specifications.");
    }

    [Test]
    public void GetAndDeleteEndpointsMustNotAcceptComplexArguments()
    {
        var violations = AllTypes(AssembliesToInspect)
            .SelectMany(t => t.GetMethods())
            .Where(IsHttpGetOrDelete)
            .SelectMany(m => m.GetParameters())
            .Where(p => !p.ParameterType.IsPrimitive && p.ParameterType != typeof(string))
            .ToList();

        Assert.That(violations, Is.Empty,
            "GET/DELETE with complex argument types breaks model binding and caching.");
    }
}

5. Test Suite Discipline

Your tests deserve structural scrutiny too:

[TestFixture]
public class TestStructureTests : FitnessFunctionTests
{
    [Test]
    public void UnitTestsMustUseTestFixtureSetUpNaming()
    {
        var violations = UnitTests
            .SelectMany(t => t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic))
            .Where(m => m.GetCustomAttribute<SetUpAttribute>() != null && m.Name != "SetUp")
            .ToList();

        Assert.That(violations, Is.Empty,
            "Setup method must be named 'SetUp' for consistency across the suite.");
    }

    [Test]
    public void AssertClassMustNotBeUsedInUnitTests()
    {
        // MSTest's Assert bleeds into NUnit suites-keep them separated
        var violations = UnitTests
            .Where(t => t.GetMethods()
                .Any(m => m.GetMethodBody().ReferencesType(typeof(Microsoft.VisualStudio.TestTools.UnitTesting.Assert))))
            .ToList();

        Assert.That(violations, Is.Empty,
            "MSTest assertions mixed with NUnit create confusing failure semantics.");
    }

    [Test]
    public void AsyncTestMethodNamesMustEndWithAsync()
    {
        var violations = UnitTests
            .SelectMany(t => t.GetMethods())
            .Where(m => m.IsAsync() && !m.Name.EndsWith("Async"))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Async test methods must end with 'Async' to make async-void mistakes obvious.");
    }
}

6. Privacy and Security Guards

This is where fitness functions earn their keep-catching classes of bugs that reviewers miss because they’re looking at logic, not leakage:

[TestFixture]
public class PrivacyGuardTests : FitnessFunctionTests
{
    private static readonly string[] SensitiveTerms =
    {
        "EmailAddress", "PhoneNumber", "DateOfBirth",
        "TaxFileNumber", "StreetAddress", "BankAccount"
    };

    [Test]
    public void SensitivePropertiesMustNotBeLoggedDirectly()
    {
        var violations = AllTypes(AssembliesToInspect)
            .SelectMany(t => t.GetMethods())
            .Where(m => IsLoggingCall(m))
            .Where(m => LogStatementReferencesSensitiveProperty(m))
            .ToList();

        Assert.That(violations, Is.Empty,
            "PII must not appear in log statements - use redaction extensions instead.");
    }

    [Test]
    public void ApiModelPropertiesMustUseSecureTypes()
    {
        var violations = ApiModels
            .SelectMany(t => t.GetProperties())
            .Where(p => SensitiveTerms.Any(term =>
                p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)))
            .Where(p => p.PropertyType == typeof(string))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Sensitive fields should use wrapper types that prevent accidental serialization or ToString leakage.");
    }

    [Test]
    public void FromUriAttributeIsProhibited()
    {
        var violations = AllTypes(AssembliesToInspect)
            .SelectMany(t => t.GetMethods())
            .SelectMany(m => m.GetParameters())
            .Where(p => p.GetCustomAttribute<FromUriAttribute>() != null)
            .ToList();

        Assert.That(violations, Is.Empty,
            "[FromUri] invites query-string injection - use [FromQuery] with explicitly bound models instead.");
    }
}

7. Convention Consistency

Naming rules feel pedantic until you’re grepping a codebase and can rely on patterns:

[TestFixture]
public class NamingConventionTests : FitnessFunctionTests
{
    [Test]
    public void AutoMapperProfilesMustBeSuffixedCorrectly()
    {
        var misnamedProfiles = AllTypes(AssembliesToInspect)
            .Where(t => typeof(Profile).IsAssignableFrom(t))
            .Where(t => !t.Name.EndsWith("Profile"))
            .ToList();

        Assert.That(misnamedProfiles, Is.Empty,
            "AutoMapper profiles must end with 'Profile' so registration scans can find them.");
    }

    [Test]
    public void ValueResolverNamesMustFollowPattern()
    {
        var violations = AllTypes(AssembliesToInspect)
            .Where(t => typeof(IValueResolver<,,>).IsAssignableFrom(t))
            .Where(t => !Regex.IsMatch(t.Name, @"^\w+Resolver$"))
            .ToList();

        Assert.That(violations, Is.Empty,
            "Value resolvers must be named '<Entity><Field>Resolver'.");
    }

    [Test]
    public void FeatureFlagKeysMustMatchConfiguration()
    {
        var declaredFlags = AllConstantsFrom(typeof(FeatureFlags));
        var configuredKeys = LoadAppSettingsKeys("FeatureToggle");

        var mismatches = declaredFlags
            .Where(flag => !configuredKeys.Contains(flag.Value))
            .ToList();

        Assert.That(mismatches, Is.Empty,
            "Declared feature flags without matching configuration cause silent fallthrough to default behaviour.");
    }
}

8. Handling Exclusions Gracefully

Every fitness function eventually meets a legitimate exception. Hard-coded exclusion lists scattered through test code become unmaintainable-centralise them:

public class FitnessFunctionExclusions
{
    // Each exclusion records the reason and a review date-
    // debt that demands eventual repayment, not permanent amnesty
    private static readonly Dictionary<string, ExclusionReason> Exclusions = new()
    {
        ["LegacyPaymentGatewayClient"] = new(
            Reason: "Migrating to PaymentOrchestrator in Q3; flagged 2026-01",
            ReviewAfter: "2026-09-01"),
        ["ExternalVendorAdapter"] = new(
            Reason: "Contractually fixed API shape; upstream controls serialization",
            ReviewAfter: "2026-12-01")
    };

    public static IEnumerable<Type> ApplyTo(IEnumerable<Type> types) =>
        types.Where(t => !Exclusions.ContainsKey(t.Name));

    public static bool IsExpired(string typeName)
    {
        return Exclusions.TryGetValue(typeName, out var e)
            && DateTime.Parse(e.ReviewAfter) < DateTime.UtcNow;
    }
}

[TestFixture]
public class ExclusionHealthTests
{
    [Test]
    public void NoExclusionShouldOutliveItsReviewDate()
    {
        var expired = FitnessFunctionExclusions.Exclusions
            .Where(kv => FitnessFunctionExclusions.IsExpired(kv.Key))
            .ToList();

        Assert.That(expired, Is.Empty,
            "These exclusions have passed their review date-either fix the violation or consciously renew the exclusion.");
    }
}

That last test is worth dwelling on. Exclusions without expiry dates become permanent scar tissue. Making the exclusions themselves subject to a fitness function keeps the governance honest.

Finding the Right Balance

One of the trickiest aspects is calibration. Too few fitness functions and they become meaningless. Too many, and they slow development to a crawl.

Consider:

  • Goldilocks Governance: Enough constraint to prevent drift, enough flexibility for innovation
  • Progressive Enforcement: Start with critical violations, expand incrementally
  • Clear Exclusions: Some edge cases deserve opt-out mechanisms
  • Maintainability: Fitness function code itself should be reviewed and refactored

The key signals that you’re over-constraining:

  • Developers routinely requesting exceptions
  • Build times noticeably impacted
  • Workarounds becoming common practice
  • New hires overwhelmed by rules they don’t understand

Tooling Options

For .NET ecosystems, I’ve evaluated:

ToolStrengthsLimitations
ArchUnitNETFluent syntax, strong ecosystemSteep learning curve, complex configuration
Reflection-based custom testsFull control, easy debuggingMore boilerplate to maintain
StyleCop / ReSharperGood for style, not structureLimited architectural reach
Roslyn analyzersCompile-time enforcementHigher investment per rule

In my experience, ArchUnitNET works beautifully for straightforward dependency rules but falls short for the kind of attribute-level inspection shown above-custom reflection tests filled that gap. Roslyn analyzers are worth the extra investment for rules you want firing in the IDE rather than in CI, though they demand more upfront work per constraint.

Beyond the Codebase

Fitness functions can extend beyond source inspection:

  • Infrastructure-as-code validation (Terraform, ARM templates)
  • OpenAPI/Swagger specification conformance
  • Feature flag naming alignment with backend keys
  • NuGet package usage restrictions

The principle remains: codify what matters, then enforce it continuously.

Getting Started

If you’re considering introducing fitness functions:

  1. Identify pain points: What repeatedly slips through review?
  2. Start small: One or two critical constraints initially
  3. Document rationale: Future developers need to understand why
  4. Iterate: Add, refine, and occasionally remove as circumstances evolve
  5. Communicate: Ensure stakeholders understand the trade-offs

Final Thoughts

Fitness functions represent a form of “code ratcheting” – preventing regression while allowing forward progress. They transform architectural principles from documentation into executable guarantees.

Like any governance mechanism, they require ongoing attention. But when calibrated well, they free teams from tedious manual enforcement, letting humans focus on genuinely complex problems.

The architecture evolves. Make sure your guardrails evolve with it.

Related Posts