Architecture Fitness Functions: Automated Quality Gates for SaaS Codebases
Every codebase I audit has an architecture document, and almost none of them match the code. The document says layered architecture; the controllers run SQL. The document says modules talk through interfaces; the code imports whatever it needs from wherever it happens to live. Nobody decided to break the rules. The rules had no enforcement, and deadlines did the rest.
Architecture fitness functions close that gap. The term comes from Building Evolutionary Architectures by Neal Ford, Rebecca Parsons and Patrick Kua: automated checks that verify a structural property of the system, the way a unit test verifies behavior. Your test suite answers "does it work?". A fitness function answers "does it still have the shape we agreed on?". When the answer is no, the pipeline goes red and the violation never reaches the main branch.
This is the practical version for a typical SaaS stack: five fitness functions for Symfony and Next.js codebases, the code to implement each one, and the CI wiring that makes them binding.
What a fitness function is, and what it is not
A fitness function is a test against the structure of the code, so it lives next to your other tests and runs in the same pipeline. The difference is what it asserts. A functional test proves the invoice total is correct. A fitness function proves the invoice calculation does not depend on the HTTP layer.
Two properties make one useful. It has to be automated, because a convention that lives only in a wiki is a suggestion. And it has to be binary. "Domain classes must not import infrastructure classes" can fail a build. "Keep the domain clean" cannot.
Code review is the usual counterargument, and it is a weak one. Reviews depend on who happens to be reviewing that day and how much attention they have left at 5 pm. In the architecture reviews we run for clients, structural drift is the most common finding, and it almost always entered through reasonable pull requests that each moved one small step in the wrong direction. No single reviewer saw the trend. A fitness function would have caught the first step.
Five architecture fitness functions for a SaaS codebase
1. Keep the domain layer out of the framework
The most common agreement in a Symfony codebase with layered ambitions: classes in App\Domain must not depend on App\Infrastructure, and nothing in the domain touches Doctrine or the HTTP foundation. The first time a domain service type hints an EntityManagerInterface, your business logic is welded to the database, and every future persistence change becomes a domain change.
You could write a custom PHPStan rule for this. The phpat extension has already done the plumbing, so the boundary becomes a short test class that PHPStan evaluates on every run:
// tests/Architecture/LayerTest.php
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;
final class LayerTest
{
public function test_domain_stays_framework_free(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('App\Infrastructure'),
Selector::inNamespace('Doctrine'),
Selector::inNamespace('Symfony\Component\HttpFoundation'),
)
->because('domain logic must not know about persistence or HTTP');
}
}
Register the extension in phpstan.neon and violations show up as ordinary PHPStan errors with file and line. The first run on an older codebase will produce a long list of existing offenders. Baseline them; the rule still blocks new ones, and the baseline shrinks as cleanup progresses.
2. Stop pages from reaching into feature internals
The frontend failure mode is a page that imports a feature's internals. A page pulls a hook out of features/billing/hooks, someone refactors the hook, four screens break, and now every internal file is public API whether the feature team likes it or not. The agreement worth enforcing: pages import a feature's public entry point and nothing deeper. ESLint can hold that line with a core rule, no plugin required:
// eslint.config.mjs, scoped to the App Router directory
{
files: ['src/app/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['@/features/*/*'],
message: 'Import the feature root (@/features/billing), not its internals.',
}],
}],
},
},
This allows import { InvoiceTable } from '@/features/billing' and rejects @/features/billing/components/InvoiceTable. Each feature keeps an index.ts that exports its public surface. Everything not exported there is private by rule instead of by convention.
3. Put a query budget on every endpoint
N+1 queries rarely show up in code review because the code reads fine. They show up when the loop meets production data. A query budget makes the cost testable: assert that an endpoint completes within a fixed number of SQL queries, and the build fails the day an innocent ->getCustomer() call inside a loop turns 12 queries into 300.
public function test_project_list_respects_its_query_budget(): void
{
$client = self::createClient();
$client->enableProfiler();
$client->request('GET', '/api/projects');
self::assertResponseIsSuccessful();
$queries = $client->getProfile()->getCollector('db')->getQueryCount();
self::assertLessThanOrEqual(14, $queries, sprintf(
'Endpoint ran %d queries, budget is 14. Look for a lazy relation in a loop.',
$queries
));
}
Set budgets from measurement, not ambition. If the endpoint needs 14 queries today, the budget is 14; tighten it when you actually optimize. The value is the tripwire: the number cannot grow without a failing test asking why.
4. Refuse endpoints that ship without a rate limiter
Rate limiting is the classic control that teams plan to add later, and later tends to arrive in the middle of an incident. Instead of trusting memory, let the suite refuse any API route that does not declare a limiter. Give the kernel a small #[RateLimited] attribute that a request listener maps to Symfony's rate limiter component, then walk the route table in a test:
private const EXEMPT = ['api_health', 'api_stripe_webhook'];
public function test_every_api_route_declares_a_rate_limiter(): void
{
$routes = self::getContainer()->get('router')->getRouteCollection();
$missing = [];
foreach ($routes as $name => $route) {
if (!str_starts_with($route->getPath(), '/api/') || in_array($name, self::EXEMPT, true)) {
continue;
}
[$class, $method] = explode('::', $route->getDefault('_controller'));
if ((new ReflectionMethod($class, $method))->getAttributes(RateLimited::class) === []) {
$missing[] = $name;
}
}
self::assertSame([], $missing, 'Routes without a rate limiter: ' . implode(', ', $missing));
}
A new route without the attribute fails the test by name. Deliberate exceptions, like a health check or a webhook protected by signature verification, go on the exempt list, which doubles as documentation of what is unprotected and why.
5. Make migrations the only path to schema changes
Schema drift is the quiet one. Someone runs a manual ALTER TABLE on staging to unblock a deploy, and three months later a fresh environment built from the migration chain does not match reality. The countermeasure: CI builds its database from nothing but migrations, then asks Doctrine whether the mapped entities agree.
bin/console doctrine:database:create --env=test
bin/console doctrine:migrations:migrate --no-interaction --env=test
bin/console doctrine:schema:validate --env=test
If an entity changed without a migration, doctrine:schema:validate fails. If the database changed without an entity, the next doctrine:migrations:diff makes the drift visible. Either way the pipeline finds it while it is still an inconvenience rather than an outage.
Wiring them into CI
None of this matters as a nightly report somebody skims. Fitness functions work when they block the merge, so they belong in the same required job as the rest of your static checks:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: vendor/bin/phpstan analyse # includes the phpat boundary rules
- run: npx eslint . --max-warnings 0 # includes the import boundaries
- run: bin/console doctrine:migrations:migrate -n --env=test
- run: bin/console doctrine:schema:validate --env=test
- run: bin/phpunit --group architecture # query budgets, rate limiter walk
Tag the PHPUnit checks with @group architecture so the job stays fast. On our projects the full set runs in under two minutes, which is cheaper than one meeting about the architecture diagram.
Where teams overdo it
Fitness functions codify agreements, so only write them for agreements the team actually holds. I have watched a team adopt thirty boundary rules out of an article, spend a quarter fighting its own pipeline, then delete all of them, including the four that mattered. Start with the boundary that was violated most recently, because that one has proof it needs guarding.
Rules also have to move when decisions move. When an architecture decision changes, its fitness function changes with it, otherwise CI enforces a design you already abandoned. If you keep an architecture decision record, note in each entry which rule enforces it, and you get traceability in both directions.
If you inherited a codebase where the architecture mostly exists in a diagram from 2021, this is a sensible first repair. Pick one boundary, write one rule, baseline the current violations, and stop new ones from landing while you plan the larger cleanup. Enforcement first, then restoration; without the guard in place, cleanup erodes exactly the way the original architecture did.
Not sure which architecture fitness functions would pay off first in your codebase? Send a note to hello@wolf-tech.io or have a look around wolf-tech.io. Comparing an architecture document against the actual dependency graph is a short exercise, and it is usually an eye opener.

