Architektur-Fitness-Functions: Automatisierte Quality Gates für SaaS-Codebasen
Jede Codebasis, die ich auditiere, hat ein Architekturdokument, und fast keines passt zum Code. Das Dokument sagt Schichtenarchitektur; die Controller führen SQL aus. Das Dokument sagt, Module reden über Interfaces; der Code importiert, was er braucht, von dort, wo es gerade liegt. Niemand hat beschlossen, die Regeln zu brechen. Die Regeln hatten keine Durchsetzung, und Deadlines erledigten den Rest.
Architektur-Fitness-Functions schließen diese Lücke. Der Begriff stammt aus Building Evolutionary Architectures von Neal Ford, Rebecca Parsons und Patrick Kua: automatisierte Checks, die eine strukturelle Eigenschaft des Systems prüfen, so wie ein Unit-Test Verhalten prüft. Deine Testsuite beantwortet "funktioniert es?". Eine Fitness Function beantwortet "hat es noch die Form, auf die wir uns geeinigt haben?". Lautet die Antwort nein, wird die Pipeline rot, und der Verstoß erreicht nie den Main-Branch.
Das hier ist die praktische Version für einen typischen SaaS-Stack: fünf Fitness Functions für Symfony- und Next.js-Codebasen, der Code, um jede davon umzusetzen, und die CI-Verdrahtung, die sie verbindlich macht.
Was eine Fitness Function ist, und was nicht
Eine Fitness Function ist ein Test gegen die Struktur des Codes, sie lebt also neben deinen anderen Tests und läuft in derselben Pipeline. Der Unterschied liegt darin, was sie behauptet. Ein funktionaler Test beweist, dass die Rechnungssumme stimmt. Eine Fitness Function beweist, dass die Rechnungsberechnung nicht von der HTTP-Schicht abhängt.
Zwei Eigenschaften machen sie nützlich. Sie muss automatisiert sein, denn eine Konvention, die nur in einem Wiki lebt, ist ein Vorschlag. Und sie muss binär sein. "Domain-Klassen dürfen keine Infrastruktur-Klassen importieren" kann einen Build brechen. "Halte die Domain sauber" kann es nicht.
Code Review ist das übliche Gegenargument, und es ist ein schwaches. Reviews hängen davon ab, wer an diesem Tag gerade reviewt und wie viel Aufmerksamkeit um 17 Uhr noch übrig ist. In den Architektur-Reviews, die wir für Kunden durchführen, ist strukturelle Drift der häufigste Befund, und sie kam fast immer über vernünftige Pull Requests herein, die jeweils einen kleinen Schritt in die falsche Richtung gingen. Kein einzelner Reviewer sah den Trend. Eine Fitness Function hätte den ersten Schritt erwischt.
Fünf Architektur-Fitness-Functions für eine SaaS-Codebasis
1. Die Domain-Schicht aus dem Framework heraushalten
Die häufigste Vereinbarung in einer Symfony-Codebasis mit Schichten-Ambitionen: Klassen in App\Domain dürfen nicht von App\Infrastructure abhängen, und nichts in der Domain berührt Doctrine oder die HTTP Foundation. Beim ersten Domain-Service, der ein EntityManagerInterface per Type Hint anfordert, ist deine Geschäftslogik an die Datenbank geschweißt, und jede künftige Persistenz-Änderung wird zur Domain-Änderung.
Du könntest dafür eine eigene PHPStan-Regel schreiben. Die Extension phpat hat die Verkabelung bereits erledigt, die Grenze wird also zu einer kurzen Testklasse, die PHPStan bei jedem Lauf auswertet:
// 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');
}
}
Registriere die Extension in phpstan.neon, und Verstöße erscheinen als gewöhnliche PHPStan-Fehler mit Datei und Zeile. Der erste Lauf auf einer älteren Codebasis liefert eine lange Liste bestehender Sünder. Nimm sie in eine Baseline; die Regel blockiert trotzdem neue, und die Baseline schrumpft, während das Aufräumen voranschreitet.
2. Pages davon abhalten, in Feature-Interna zu greifen
Der Fehlerfall im Frontend ist eine Page, die die Interna eines Features importiert. Eine Page zieht einen Hook aus features/billing/hooks, jemand refactort den Hook, vier Screens brechen, und jetzt ist jede interne Datei öffentliche API, ob das Feature-Team will oder nicht. Die Vereinbarung, die sich durchzusetzen lohnt: Pages importieren den öffentlichen Einstiegspunkt eines Features und nichts Tieferes. ESLint kann diese Linie mit einer Core-Regel halten, ganz ohne Plugin:
// 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.',
}],
}],
},
},
Das erlaubt import { InvoiceTable } from '@/features/billing' und lehnt @/features/billing/components/InvoiceTable ab. Jedes Feature hält eine index.ts, die seine öffentliche Oberfläche exportiert. Alles, was dort nicht exportiert wird, ist per Regel privat statt per Konvention.
3. Jedem Endpoint ein Query-Budget geben
N+1-Queries tauchen im Code Review selten auf, weil der Code sich gut liest. Sie tauchen auf, wenn die Schleife auf Produktionsdaten trifft. Ein Query-Budget macht die Kosten testbar: Behaupte, dass ein Endpoint innerhalb einer festen Zahl von SQL-Queries fertig wird, und der Build bricht an dem Tag, an dem ein harmloser ->getCustomer()-Aufruf in einer Schleife aus 12 Queries 300 macht.
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
));
}
Setze Budgets nach Messung, nicht nach Ambition. Wenn der Endpoint heute 14 Queries braucht, ist das Budget 14; zieh es an, wenn du tatsächlich optimierst. Der Wert liegt im Stolperdraht: Die Zahl kann nicht wachsen, ohne dass ein fehlschlagender Test nach dem Warum fragt.
4. Endpoints ablehnen, die ohne Rate Limiter ausgeliefert werden
Rate Limiting ist der klassische Kontrollmechanismus, den Teams später einbauen wollen, und später kommt gern mitten in einem Incident. Statt dem Gedächtnis zu vertrauen, lass die Suite jede API-Route ablehnen, die keinen Limiter deklariert. Gib dem Kernel ein kleines #[RateLimited]-Attribut, das ein Request Listener auf Symfonys Rate-Limiter-Komponente abbildet, und laufe dann in einem Test die Routentabelle ab:
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));
}
Eine neue Route ohne das Attribut lässt den Test namentlich fehlschlagen. Bewusste Ausnahmen, etwa ein Health Check oder ein durch Signaturprüfung geschützter Webhook, kommen auf die Ausnahmeliste, die zugleich dokumentiert, was ungeschützt ist und warum.
5. Migrationen zum einzigen Weg für Schema-Änderungen machen
Schema-Drift ist die leise Variante. Jemand führt auf Staging ein manuelles ALTER TABLE aus, um ein Deploy freizumachen, und drei Monate später passt eine frische Umgebung, die aus der Migrationskette gebaut wurde, nicht zur Realität. Die Gegenmaßnahme: CI baut seine Datenbank aus nichts als Migrationen und fragt dann Doctrine, ob die gemappten Entities zustimmen.
bin/console doctrine:database:create --env=test
bin/console doctrine:migrations:migrate --no-interaction --env=test
bin/console doctrine:schema:validate --env=test
Wenn sich eine Entity ohne Migration geändert hat, schlägt doctrine:schema:validate fehl. Wenn sich die Datenbank ohne Entity geändert hat, macht das nächste doctrine:migrations:diff die Drift sichtbar. So oder so findet die Pipeline sie, solange sie noch eine Unannehmlichkeit ist und kein Ausfall.
Die Verdrahtung in CI
Nichts davon zählt als nächtlicher Report, den jemand überfliegt. Fitness Functions wirken, wenn sie den Merge blockieren, sie gehören also in denselben Pflicht-Job wie der Rest deiner statischen 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
Markiere die PHPUnit-Checks mit @group architecture, damit der Job schnell bleibt. In unseren Projekten läuft der komplette Satz in unter zwei Minuten, und das ist billiger als ein Meeting über das Architekturdiagramm.
Wo Teams übertreiben
Fitness Functions kodifizieren Vereinbarungen, schreib sie also nur für Vereinbarungen, die das Team tatsächlich trägt. Ich habe ein Team gesehen, das dreißig Grenzregeln aus einem Artikel übernahm, ein Quartal gegen die eigene Pipeline kämpfte und dann alle löschte, inklusive der vier, die zählten. Fang mit der Grenze an, die zuletzt verletzt wurde, denn die hat den Beweis, dass sie Schutz braucht.
Regeln müssen sich auch bewegen, wenn Entscheidungen sich bewegen. Ändert sich eine Architekturentscheidung, ändert sich ihre Fitness Function mit, sonst setzt CI ein Design durch, das du bereits aufgegeben hast. Wenn du Architecture Decision Records führst, notiere in jedem Eintrag, welche Regel ihn durchsetzt, und du bekommst Nachvollziehbarkeit in beide Richtungen.
Wenn du eine Codebasis geerbt hast, deren Architektur hauptsächlich in einem Diagramm von 2021 existiert, ist das eine vernünftige erste Reparatur. Wähle eine Grenze, schreib eine Regel, nimm die aktuellen Verstöße in eine Baseline und verhindere, dass neue landen, während du die größere Aufräumarbeit planst. Erst Durchsetzung, dann Wiederherstellung; ohne die Wache erodiert das Aufräumen genau so, wie es die ursprüngliche Architektur getan hat.
Unsicher, welche Architektur-Fitness-Functions sich in deiner Codebasis zuerst auszahlen würden? Schreib an hello@wolf-tech.io oder schau dich auf wolf-tech.io um. Ein Architekturdokument mit dem tatsächlichen Abhängigkeitsgraphen zu vergleichen ist eine kurze Übung, und meist ein Augenöffner.

