A breath of fresh AIr

Ich wollte Phoenix endlich mal richtig verproben. Herausgekommen ist ein laufendes Produkt und ein paar unbequeme Erkenntnisse darüber, wie viel Code wir eigentlich schreiben, ohne es zu merken.

Ich baue seit Jahren mit den üblichen Verdächtigen: PHP/Laravel im Backend, Angular/TypeScript im Frontend, als getrennte Services über eine API verdrahtet. Das funktioniert zuverlässig. Aber „funktioniert" heißt nicht „effizient". Im Zeitalter von AI-powered Development entscheidet eine ganz andere Frage über die Kosten eines Features: Wie viele Schichten (und wie verteilt), wie viel Code, wie viele Tokens braucht es, bis es steht? Genau da wurde es interessant.

Warum überhaupt?

Mein Tischtennisverein braucht eine vernünftige Turniersoftware für die jährlichen Stadtmeisterschaften, ohne Excel-Chaos am Turniertag. Ein klar abgegrenzter Fall, genau richtig, um Elixir und Phoenix endlich einmal zu verproben. Was ich dabei herausfinden wollte, steht ausführlich an anderer Stelle: funktionale Programmierung und LiveView in der Praxis, OpenSpec als Spec-Werkzeug und vor allem eine Messung, der AI-Token-Verbrauch gegenüber klassischen OOP-Ansätzen.

Dieser letzte Punkt ist der, um den es hier geht. Denn wer heute mit AI entwickelt, bezahlt direkt oder indirekt pro Token. Und Tokens korrelieren erstaunlich gut mit einer alten Tugend: wie wenig Code man schreiben muss, um dieselbe Sache auszudrücken.

Der Kontrast: Microservices vs. Fullstack-Mono-Repo

Die gängige State-of-the-Art-Architektur, die ich aus dem beruflichen Kontext kenne, sieht so aus: ein Laravel-Backend, ein Angular-Frontend, dazwischen eine REST-API. Zwei Repos oder zumindest zwei Welten. Zwei Sprachen, zwei Typsysteme. Dieselbe Entität, sagen wir ein „Spieler", existiert als PHP-Model und als TypeScript-Interface. Dazwischen wird sie als DTO über die Leitung gemappt und auf der anderen Seite wieder zusammengebaut. Zwei Build-Pipelines, zwei Deploys, zwei Stellen, an denen dasselbe Feld heißt, nur leicht anders geschrieben.

Phoenix dreht das um: ein Repo, eine Sprache, ein mentales Modell. Backend, Templates und die Echtzeit-Schicht liegen im selben Projekt, in derselben Sprache. Kein Mapping über eine API-Grenze, weil es keine API-Grenze zwischen „Server" und „View" gibt.

Das ist die Simplicity of Fullstack Mono Repos, und sie ist real spürbar, auch für die AI, die das ganze Feature auf einmal überblickt, statt zwischen zwei Codebasen zu pendeln. Zwei konkrete Vorteile zeige ich in den nächsten beiden Abschnitten. Erstens weniger Overhead: der ganze Pflicht-Code, der in klassischen Frameworks nur Daten von einer Schicht in die nächste reicht, fällt bei funktionaler Programmierung größtenteils weg. Zweitens durchgängige Testbarkeit: weil Frontend und Backend ein Programm sind, prüft ein einziger Test die echte Oberfläche gegen die echte Datenbank, ohne Attrappe dazwischen. Gerade wenn AI den Code schreibt, ist das das entscheidende Sicherheitsnetz.

Eine faire Notiz am Rande: Ein JavaScript-Mono-Repo, TypeScript vorne wie hinten, würde vieles davon ähnlich liefern. Ein Repo, eine Sprache, dieselben durchgängigen Tests. Der Unterschied bleibt beim Overhead: TypeScript ist objektorientiert, die funktionale Knappheit von Elixir bekommt man damit nicht. Genau dieser Punkt ist es, der mich an Elixir so fasziniert.

Weniger Overhead: dasselbe Feature, ein Bruchteil der Dateien

Nehmen wir eine banale Operation: „Füge einem Turnier einen Spieler hinzu." In einer sauber geschichteten Laravel-Anwendung berührt dieser eine Vorgang typischerweise:

RoutePolicyFormRequest (Validierung) → ControllerDTOModelProvider/DI/ServiceResource (JSON-Ausgabe)

Über acht Stellen, jede mit ihrem eigenen Pflicht-Gerüst. Und das ist nur das Backend. Im Angular-Frontend wiederholt sich das Spiel: ein interface für den Spieler, ein model, ein service, der den HTTP-Call kapselt und die Antwort zurück in Objekte gießt. Dieselbe fachliche Idee, über ein Dutzend Dateien und zwei Sprachen verteilt.

In Elixir, der Sprache unter Phoenix, sieht dieselbe Operation anders aus. Eine Map (also schlicht Daten) fließt durch Pattern Matching, eine Pipe und eine Handvoll Funktionen, fertig:

def create_player(%Scope{} = scope, tournament_id, attrs) do
  tournament = get_tournament_for_admin!(scope, tournament_id)
  do_create_player(tournament, attrs)
end

# Diese Funktion greift NUR, wenn das Turnier im Status :planned ist.
# Die Bedingung steht im Funktionskopf, nicht in einem if-Block darin.
defp do_create_player(%Tournament{status: :planned} = tournament, attrs) do
  if player_count(tournament.id) >= player_cap(tournament) do
    {:error, :cap_reached}
  else
    attrs
    |> Player.create_changeset(tournament.id, next_position(tournament))
    |> Repo.insert()
  end
end

Das ist echter Code aus dem Projekt, nur leicht gekürzt. Ein paar Dinge, die für Nicht-Elixir-Leser die Magie erklären:

  • Pattern Matching (%Tournament{status: :planned}): Die Funktion existiert nur für Turniere im Planungsstatus. Ein Spieler, der einem bereits laufenden Turnier hinzugefügt werden soll, läuft schlicht nicht in diese Funktion hinein. Kein defensives if (status != 'planned') throw ....
  • Die Pipe (|>) reicht die Daten von links nach rechts durch: attrs werden zu einem validierten Changeset, und der landet in der Datenbank. Man liest den Ablauf wie einen Satz.
  • Die Antwort ist einfach ein Tupel, {:ok, player} oder {:error, :cap_reached}. Keine Resource-Klasse, kein Serializer.
  • Daten sind unveränderbar (by design): Ein einmal erzeugter Wert lässt sich nicht heimlich ändern, jede „Änderung" erzeugt einen neuen. Kein Objekt, das drei Funktionen weiter unbemerkt umgeschrieben wird.

Weniger Overhead heißt weniger Zeilen. Weniger Zeilen heißt bei AI-Entwicklung direkt weniger Tokens: im Prompt, in der Antwort, im Kontext, den das Modell mitschleppen muss. Günstiger, schneller, und weniger Stellen, an denen etwas auseinanderläuft.

Testbarkeit: das Sicherheitsnetz für generierten Code

In der getrennten Welt aus Backend und Frontend testet jede Seite für sich, und zwar gegen einen Mock der anderen. Der Backend-Test prüft: „Ich liefere dieses JSON." Ob das Frontend daraus das Richtige macht, kann er nur hoffen. Der Frontend-Test baut sich umgekehrt ein nachgebautes Backend, einen sogenannten Mock, und prüft gegen dessen erfundene Antworten. Beide Seiten sind grün. Ob sie in echt zusammenpassen, hat niemand getestet. Genau in dieser Naht, zwischen „was das Backend zu liefern glaubt" und „was das Frontend zu bekommen glaubt", stecken die typischen Integrationsfehler. Und Mocks veralten: Ändert sich das Backend, muss man daran denken, das Frontend nachzuziehen. Vergisst man es, wird der Test grün und lügt.

Im Phoenix-Mono-Repo gibt es diese Naht nicht. Frontend und Backend sind ein Programm, also fährt ein einziger Test die echte Oberfläche gegen die echte Datenbank, in einem Rutsch, ohne Attrappe dazwischen:

test "Löschen fragt erst nach, statt sofort zu löschen", %{conn: conn, user: user} do
  t = tournament_fixture(user)
  [andreas] = players_fixture(t, ["Andreas"])

  # Die echte Live-Seite öffnen, so wie sie im Browser läuft.
  {:ok, lv, _html} = live(conn, ~p"/app/tournaments/#{t}/players")

  # Einen echten Klick auslösen: „diese Zeile löschen".
  render_hook(element(lv, "#players-table"), "row_delete_request", %{"row_id" => andreas.id})

  # Das echte UI zeigt jetzt den Bestätigen-Dialog ...
  assert has_element?(lv, "[data-modal=\"delete-confirm\"]", "Andreas")
  # ... und die echte Datenbank hat noch nichts gelöscht.
  assert length(Tournaments.list_players(Scope.for_user(user), t.id)) == 1
end

Auch das ist echter Projekt-Code, nur leicht gekürzt. Ein Test, zwei Enden, keine Erfindung: Er klickt wie ein echter Nutzer und prüft danach zugleich, was die Oberfläche zeigt und was in der Datenbank steht.

Und hier kommt der Punkt, der in Zeiten von AI-generiertem Code wirklich zählt: Diese Art Test gibt eine Sicherheit, die kein Mock geben kann. Ein Mock kodiert eine Annahme darüber, wie die andere Seite sich verhält. Schreibt die AI sowohl den Code als auch den dazu passenden Mock, backt sie eine falsche Annahme gleich in beide Seiten, und der Test wird trotzdem grün, obwohl das Feature kaputt ist. Ein Test, der beide Enden in echt durchspielt, kann so nicht schummeln. Genau dieses Vertrauen ist es, das einen generierten Code überhaupt in der Geschwindigkeit annehmen lässt, in der er heute entsteht.

Und die AI?

Hier schließt sich der Kreis. Und hier liegt der Grund, warum das Thema für alle relevant ist, die mit AI-powered Development zu tun haben, nicht nur für Elixir-Fans:

  • Weniger Code, weniger Tokens. Jede Datei, die es nicht gibt, ist Kontext, den das Modell nicht lesen, nicht verstehen und nicht neu generieren muss. Das ist bare Münze, kein Ästhetik-Argument.
  • Funktionale Maps und Pattern Matching passen zur Denkweise der Modelle. Datenfluss, klare Ein- und Ausgänge, wenig verborgener Zustand. Die AI muss weniger raten, was ein Objekt drei Klassen weiter gerade mit sich selbst anstellt.
  • Ein Mono-Repo passt in ein Kontextfenster. Backend und Frontend in einer Sprache, an einem Ort. Die AI hat das ganze Feature im Blick, statt zwischen zwei Codebasen zu vermitteln.

Die konkrete Zahl dazu: Das gesamte Projekt, ein reales, laufendes Produkt, hat mich über zwei Wochenenden rund 40 % meines Claude-Code-Weekly-Limits gekostet. Für den Umfang ist das erstaunlich wenig. Ich bin überzeugt, dass die knappe, funktionale, monolithische Bauweise ein direkter Grund dafür ist.

Fazit

Das hier ist kein Plädoyer, morgen alles auf Elixir umzuschreiben. Ein JavaScript-Fullstack räumt viele der gleichen Probleme aus, und die beste Sprache ist immer noch die, in der dein Team liefern kann.

Der Punkt ist ein anderer, und er ist geschäftlich, nicht ideologisch: In dem Moment, in dem AI pro Token bezahlt und pro Kontext denkt, wird vermeidbarer Overhead zum messbaren Kostenfaktor. Weniger Schichten, weniger Duplikate, weniger nachgerüstete Infrastruktur war früher eine Frage des guten Geschmacks. Heute ist es eine Frage der Velocity und der Rechnung am Monatsende.

Dahinter steckt eine größere Verschiebung. Wir schreiben Code immer weniger für große Teams aus Menschen, die ihn lesen, verstehen und pflegen müssen, und immer mehr für Maschinen, die ihn erzeugen und umbauen. Viele der klassischen Schichten und Muster waren im Kern Werkzeuge, um große Mannschaften zu koordinieren. Für eine AI zählt anderes: ein kleiner Kontext, den sie vollständig überblickt, eine Testabsicherung, die echtes Verhalten prüft statt Annahmen, und wenig Komplexität, an der sie sich verheddern kann. Genau diese drei halten Velocity und Sicherheit gleichzeitig hoch, statt das eine gegen das andere einzutauschen.

Und die Rolle des Menschen verschiebt sich mit: weg vom Tippen jeder Zeile, hin zum präzisen Beschreiben, was entstehen soll. In diesem Projekt lief das über OpenSpec, eine schriftliche Spezifikation, aus der die AI den Code ableitet und an der sie sich messen lassen muss. Die Spec wird zur Quelle der Wahrheit, der Code zum Ergebnis: Spec vorne, Testnetz hinten, dazwischen eine Sprache mit wenig Overhead.

Phoenix hat sich für mich angefühlt wie a breath of fresh AIr. Vielleicht ist es für dich derselbe Atemzug, vielleicht findest du ihn woanders. Aber die Frage lohnt sich für jeden, der ernsthaft mit AI baut.