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
| Aspect | Description |
|---|---|
| Speed | Fast execution (typically seconds) |
| Scope | Entire codebase or subsystem |
| Type | Structural analysis via reflection/static analysis |
| Frequency | Pull 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:
- Consistency: Enforce uniformity across APIs, layers, and projects
- Guardrails: Prevent accidental architectural degradation
- Visibility: Make implicit standards explicit and measurable
- 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:
| Tool | Strengths | Limitations |
|---|---|---|
| ArchUnitNET | Fluent syntax, strong ecosystem | Steep learning curve, complex configuration |
| Reflection-based custom tests | Full control, easy debugging | More boilerplate to maintain |
| StyleCop / ReSharper | Good for style, not structure | Limited architectural reach |
| Roslyn analyzers | Compile-time enforcement | Higher 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:
- Identify pain points: What repeatedly slips through review?
- Start small: One or two critical constraints initially
- Document rationale: Future developers need to understand why
- Iterate: Add, refine, and occasionally remove as circumstances evolve
- 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.