Wie Effect bei uns von der Form-Validierung zum Standard-Framework wurde

Blog » Development » One Effect to Rule them all

One Effect to Rule them all

Wie Effect bei uns im Projekt von der Form-Validierung zum Standard-Framework wurde

Avatar von Maria Haubner

Im TypeScript-Ökosystem gibt es mittlerweile eine ganze Galerie von Validierungs-Libraries. Zod, Yup, und viele mehr – jede hat ihre Anhängerschaft, und alle versprechen mehr oder weniger dasselbe: typsichere Schemas, aus denen sich TypeScript-Typen, Validatoren und Fehlermeldungen ableiten lassen. Wir haben uns vor anderthalb Jahren in einem Projekt für Effect entschieden … und mittlerweile ist daraus mehr geworden als „nur Validierung“.

Und damit gleich der Spoiler: Effect ist bei uns inzwischen nicht mehr die Library, mit der wir Formulare validieren. Es ist die Sprache, in der unser Backend redet. Und mittlerweile auch die Sprache, in der unser Frontend mit dem Backend spricht. Wie wir da hingekommen sind, möchte ich in diesem Artikel erzählen.

Was ist Effect?

Effect ist eigentlich zwei Dinge in einem. Da ist einmal effect/Schema, eine Schema-Library im Stil von Zod, mit der man Datenstrukturen beschreibt und daraus Typen und Validatoren bekommt. Und dann ist da Effect selbst, ein Framework für TypeScript-Anwendungen, das stark von funktionaler Programmierung inspiriert ist. Es bietet strukturierte Fehlerbehandlung, Dependency Injection, asynchrone Workflows, Observability und vieles mehr in einem konsistenten Modell.

Daneben gibt es ein wachsendes Ökosystem an offiziellen Paketen: @effect/platform für HTTP-Server, @effect/sql für Datenbankzugriff, @effect/rpc für typisierte RPCs, @effect/cli für Command-Line-Tools und einiges mehr.

Wir sind nicht mit „Effect“ eingestiegen. Wir sind mit „Schema“ eingestiegen.

Der harmlose Einstieg

Im Sommer 2024 brauchten wir für ein neues Frontend ein Formular-Setup mit Validierung. Die Wahl fiel auf react-hook-form, und für die Validierung haben wir @effect/schema ausprobiert. Der Plan war wirklich klein: Forms im Frontend validieren, mehr nicht.

Stellen wir uns vor, wir bauen ein kleines Bibliotheks-Tool. Ein simples Formular zum Anlegen eines Buchs sah dann ungefähr so aus (Achtung: vereinfacht!):

import { Schema } from 'effect';

export const createBookSchema = Schema.Struct({
  title: Schema.String.pipe(
    Schema.nonEmptyString({ message: () => 'Titel ist ein Pflichtfeld.' })
  ),
  author: Schema.String.pipe(
    Schema.nonEmptyString({ message: () => 'Autor ist ein Pflichtfeld.' })
  ),
  year: Schema.optional(
    Schema.Int.pipe(Schema.between(1450, 2100))
  ),
});

export type CreateBookFormData = Schema.Schema.Type<typeof createBookSchema>;

In Zeile 1 importieren wir Schema aus dem Effect-Paket. Dann definieren wir mit Schema.Struct die Form der Daten, die wir vom Formular erwarten. nonEmptyString macht aus dem String einen Pflicht-String – und gibt uns die Möglichkeit, eine eigene Fehlermeldung mitzugeben, die wir später direkt im Formular anzeigen können. Am Ende leitet Schema.Schema.Type den passenden TypeScript-Typen aus dem Schema ab.

Warum nicht einfach Zod?

Klein, isoliert, low risk. Hätte auf den ersten Blick auch mit Zod oder Yup funktioniert. Was uns dann aber tatsächlich zu Effect-Schema gebracht hat, war ein Detail, das man in einem klassischen Validierungs-Setup leicht übersieht: Effect-Schemas sind bi-direktional.

Klingt erstmal abstrakt, ist im Alltag aber ziemlich praktisch. Ein Formular liefert in der Regel Strings, auch wenn dahinter mal ein number oder ein Date stecken soll. Vor dem Abschicken einer Query wandelt das Schema diese String-Eingaben in die getypten Felder um, die die Query erwartet. So weit, so normal – das machen die anderen Schema-Libraries auch.

Nur: wenn die Query zurückkommt, hat man wieder das getypte Objekt; und will daraus zum Beispiel die Default-Werte für ein Edit-Formular bauen. Dafür müssen die gleichen Felder zurück in die Form-freundliche String-Repräsentation. Mit Effect-Schema definiert man die Transformation einmal, und kann sie in beide Richtungen laufen lassen: Schema.decode für „Form → Domain“, Schema.encode für „Domain → Form“. Eine Definition, beide Richtungen, kein doppelt gepflegtes Mapping.

Genau diesen Use-Case hatten wir, und genau dafür haben wir Effect ausgewählt. Wir wussten zu dem Zeitpunkt nicht, dass das nur die erste Tür war.

„Hey, das passt auch hier“

Sobald die ersten Forms produktiv waren, kamen die nächsten Use-Cases ganz von selbst. API-Antworten validieren … warum nicht mit demselben Schema-Mechanismus? Suchparameter aus der URL parsen … warum nicht? LocalStorage-Werte typsicher lesen … auch das.

Spannender wurde es, als das gleiche Datenmodell sowohl im Frontend als auch in einem Node-Backend-Service auftauchte. Das war der Punkt, an dem wir uns ein eigenes Package für geteilte Schemas gebaut haben. Ein einziges Schema, das in beiden Welten denselben Typ erzeugt, dieselben Validierungsregeln durchsetzt, dieselben Fehlermeldungen liefert.

// im geteilten Package
export const Book = Schema.Struct({
  id: Schema.UUID,
  title: Schema.NonEmptyString,
  author: Schema.NonEmptyString,
  publishedYear: Schema.optional(Schema.Int),
});

export type Book = Schema.Schema.Type<typeof Book>;

Wenn im Frontend ein API-Response decodiert wird und das gleiche Schema im Backend zum Encodieren genutzt wird, kann eigentlich nicht mehr viel auseinanderlaufen. Das mag selbstverständlich klingen, aber wer schon mal eine TypeScript-Definition im Frontend hatte und eine handgepflegte Definition im Backend, weiß, wie schnell die beiden auseinanderdriften können.

Bis hierher hatten wir aber immer noch „nur“ Schemas im Einsatz. Das eigentliche Effect-Framework hatten wir noch nicht angefasst.

Systeme stabilisieren und optimieren

Effect breitet sich aus

Im Herbst 2025 stand ein neuer Backend-Service an. Es war von Anfang an klar: dieser Service wird viele Schemas brauchen, die wir mit dem Frontend teilen wollen. Und an dem Punkt stellte sich die Frage: Wenn die Schemas eh aus Effect kommen – warum nicht gleich den Rest mitnehmen?

Ab da war es nicht mehr nur Schema, sondern auch Effect, Layer, Context, @effect/platform und @effect/sql. Anstatt throw und try/catch schreibt man Effects, in denen mögliche Fehler typisiert in der Signatur stehen:

import { Effect, Schema } from 'effect';

class BookNotFoundError extends Schema.TaggedError<BookNotFoundError>()(
  'BookNotFoundError',
  { id: Schema.UUID }
) {}

const getBookById = (id: string) =>
  Effect.gen(function* () {
    const repo = yield* BookRepository;
    const book = yield* repo.findById(id);
    if (book === null) {
      return yield* Effect.fail(new BookNotFoundError({ id }));
    }
    return book;
  });

Was hier passiert: Wir definieren erst eine getaggte Fehlerklasse – die ist gleichzeitig ein Schema, lässt sich also über Netzwerk-Grenzen hinweg serialisieren. Dann beschreiben wir mit Effect.gen einen Workflow, der ein BookRepository aus dem Context zieht (Dependency Injection à la Effect), das Buch sucht und im Fehlerfall einen typisierten Fehler liefert. Wer den Effect am Ende ausführt, weiß durch den Typ: dieser Workflow kann mit einem BookNotFoundError fehlschlagen. Und nur damit.

Das ist ein anderer Weg, Code zu schreiben. Es fühlt sich anfangs ungewohnt an, doch dazu später mehr. Aber sobald man drin ist, will man nicht mehr ohne. Fehler verschwinden nicht in einem generischen Error, Abhängigkeiten landen nicht implizit als Singletons im Speicher, asynchrone Workflows haben eine Struktur, die man auch nach sechs Monaten wieder versteht.

Die Konsolidierung

Es ging dann eine Weile in alle Richtungen. Mehrere kleine Effect-basierte Pakete, ein paar Migrationen von alten Services, ein bisschen Wildwuchs. Anfang dieses Jahres haben wir aufgeräumt: Ein zentrales Package für alle Domain-Schemas, ein klares Port-und-Adapter-Setup für Infrastruktur, geteilte Observability mit OpenTelemetry, alles im selben Effect-Stil.

Das war keine Big-Bang-Aktion; einzelne Domänen wurden Stück für Stück migriert. Aber das Endbild ist deutlich konsistenter geworden: Es gibt nicht mehr „mehrere Arten, eine Domain zu modellieren“, sondern eine.

RPCs und die Grenze, die fast verschwindet

Der spannendste Schritt war für mich @effect/rpc. Damit definiert man typisierte RPC-Endpunkte, bei denen Payload, Success-Antwort, mögliche Fehler und Middleware-Anforderungen alle aus einer einzigen Schema-Definition fallen.

Ein gekürztes Beispiel:

import { Rpc, RpcGroup } from '@effect/rpc';
import { Schema } from 'effect';

class GetBookByIdRpc extends Rpc.make('getBookById', {
  payload: Schema.Struct({ id: Schema.UUID }),
  success: Book,
  error: BookNotFoundError,
}) {}

class CreateBookRpc extends Rpc.make('createBook', {
  payload: Schema.Struct({
    title: Schema.NonEmptyString,
    author: Schema.NonEmptyString,
  }),
  success: Book,
  error: ValidationError,
}) {}

export class BookRpcGroup extends RpcGroup
  .make(GetBookByIdRpc, CreateBookRpc)
  .prefix('book_')
  .middleware(RpcAuthMiddleware) {}

Was wir hier sehen: Jede RPC ist eine kleine Klasse mit einem Namen ('getBookById'), einem Payload-Schema, einem Success-Schema und einem Error-Schema. Die RPCs werden zu einer RpcGroup zusammengefasst, bekommen ein gemeinsames Präfix und eine gemeinsame Middleware – in unserem Fall eine Auth-Middleware, die den Auth-Context dem RPC zur Verfügung stellt.

Der Clou: Der Server implementiert die RPCs gegen diese Definition. Der Client wird aus derselben Definition generiert. Beide Seiten teilen sich nicht nur die Datenstrukturen, sondern auch die Fehler und die Auth-Anforderungen. Wenn jemand eine RPC um ein neues Pflichtfeld erweitert, fliegt es im Frontend zur TypeScript-Build-Zeit raus. Kein „ich hab vergessen, das Schema im Client zu aktualisieren“ mehr.

Vom Service-zu-Service zum Browser

Am Anfang war das eine reine Backend-Sache. Service A ruft Service B mit @effect/rpc über einen geteilten Secret-Header auf, beide laufen in derselben Server-Umgebung. Schon das war ein Gewinn gegenüber handgepflegten REST-Endpunkten.

Vor wenigen Wochen haben wir aber den nächsten Schritt gemacht: Auch das Frontend ruft das Backend jetzt direkt über @effect/rpc auf – ohne den Umweg über eine separate API-Schicht dazwischen. Statt eines Shared-Secrets verwenden wir auf dem Browser-Pfad ein JWT, das per Cookie mitgeschickt wird. Auf dem Server gibt es zwei Varianten derselben Middleware: eine für Server-zu-Server (Shared Secret), eine für den Browser (JWT-Verifikation). Beide stellen denselben RpcAuthContext bereit, sodass die eigentliche RPC-Implementierung gar nicht weiß, woher der Caller kommt.

Wir haben das nicht in einem Big Bang umgeschaltet. Beide Wege laufen parallel weiter, und Features wandern Stück für Stück auf den direkten Pfad. Der erste Pilot war ein bestimmter Export-Workflow, der dadurch sowohl spürbar schneller wurde als auch deutlich weniger Code drumherum brauchte.

Das ist der Punkt, an dem ich Effect anders sehe als am Anfang. Es ist nicht mehr „die Library, mit der wir Forms validieren“. Auch die Beschreibung „Backend-Framework für strukturierte Fehler“ greift zu kurz. Es ist das gemeinsame Vokabular zwischen Browser und Server. Wenn dort hinten in einer RPC ein neuer Fehler-Tag dazukommt, weiß der Code im Browser hier vorne davon.

Nicht durch Konvention, sondern durch den Compiler.

Vom Pattern zum Standard

Irgendwann hat sich aus all dem ein wiederkehrendes Muster herauskristallisiert. Eine Domäne, die wir auf Effect migrieren, bekommt drei klare Schichten:

  • Foundation: technische Adapter, geteilte Helfer, Repository-Implementierungen.
  • Domain: Read-Models und Use-Cases; die fachlichen Workflows, die nichts über HTTP oder Datenbank wissen müssen.
  • Protocol: die nach außen sichtbare Schnittstelle. RPCs, HTTP-APIs, CLI-Kommandos. Hier wird das Domain-Vokabular auf das jeweilige Protokoll übersetzt, häufig mit kleinen Handler-Factories, die für jede RPC den passenden Effect-Workflow zusammenstecken.

Das klingt nach klassischer Schichtenarchitektur und ist es im Grunde auch. Der Unterschied ist, dass diese Schichten in Effect mit den eingebauten Mitteln modelliert sind. Die Foundation gibt Layers heraus, die Domain hängt nur an Service-Contexts, das Protocol kombiniert beides zur fertigen Anwendung. Statt aus drei verschiedenen Architektur-Welten zusammengeschraubt zu sein, kommt alles aus demselben Werkzeugkasten.

Inzwischen haben wir das Pattern auf mehrere Domains angewendet. Es ist mittlerweile kein „lass uns mal Effect ausprobieren“ mehr; es ist der Standard, gegen den neue Domains gebaut werden.

Ein Wort der Warnung

Bevor das hier zu sehr nach Werbung klingt: Die Lernkurve von Effect ist nicht ohne. Schema allein kann man in ein paar Stunden lernen – das ist überschaubar. Aber sobald man bei Effect.gen, Layer, Context, Scope, Stream, Fiber und Konsorten ankommt, ist man in einem anderen Universum. Wer aus einem klassischen TypeScript-/Node-Hintergrund kommt und noch nie funktional programmiert hat, braucht eine Weile, bis die Begriffe sich anfühlen wie selbstverständliche Werkzeuge und nicht wie kryptische Hieroglyphen.

Was uns geholfen hat: in kleinen Schritten anfangen. Mit Schema einsteigen, das ist die niedrigschwelligste Hürde. Dann mal eine kleine Backend-Funktion in Effect schreiben. Danach eine ganze HTTP-Route. Dann eine ganze Domain. Wer von Anfang an versucht, alles auf einmal zu lernen, brennt aus. Wir haben das verteilt über fast zwei Jahre durchlaufen … und ich glaube, es war kein Zufall, dass es so funktioniert hat.

Außerdem: die Dokumentation ist gut, aber sie ist nicht „lesen und du kannst es“. Es lohnt sich, im echten Code zu kramen – die Effect-eigenen Beispiele auf GitHub sind dafür eine Goldgrube.

Würde ich’s nochmal so machen?

Kurze Antwort: Ja.

Längere Antwort: Ja, aber ich würde von Anfang an klarer kommunizieren, dass das eine längere Lernreise wird. Wir sind über Schema reingestolpert und haben das volle Effect-Universum nach und nach mitgenommen. Das hat bei uns gut funktioniert, weil das Team Lust hatte, sich darauf einzulassen und weil uns das Versprechen von Stabilität und Robustheit und getrieben hat.

Der Mehrwert ist für uns inzwischen so groß, dass sich die Lernkurve auszahlt. Wir haben weniger Bug-Klassen (die ganze Schiene „Backend liefert was anderes als Frontend erwartet“ ist fast ausgestorben), wir haben deutlich strukturierteren Backend-Code, und wir können Frontend-Entwickler und Backend-Entwickler an gemeinsamen Schemas und Schnittstellen arbeiten lassen, ohne dass jemand sich mühsam in eine andere Sprache hineindenken muss. Das ist viel wert.

Wenn dieser Einblick Lust auf Effect gemacht hat: Die offizielle Doku ist ein guter Einstieg, und es gibt mittlerweile eine sehr aktive Community auf Discord. Viel Spaß beim Reinschnuppern.

Fokus-Webinar: Der #1-Killer für Dein KI-Projekt
Fokus-Webinar: Advanced Unstructured Data
Fokus-Webinar: Daten, aber schnell!
Fokus-Webinar: Vertrauenswürdige KI mit Observable RAG
Fokus-Webinar: LLMs als Wissensgedächtnis
Fokus-Webinar: Personenbezogene Daten & KI – ie Pseudonymisierung hilft
Fokus-Webinar: Der #1-Killer für Dein KI-Projekt
Fokus-Webinar: Advanced Unstructured Data
Fokus-Webinar: Daten, aber schnell!
Fokus-Webinar: Vertrauenswürdige KI mit Observable RAG
Fokus-Webinar: LLMs als Wissensgedächtnis
Fokus-Webinar: Personenbezogene Daten & KI – ie Pseudonymisierung hilft

Dein Kontakt zu uns

Avatar von Maria Haubner

Dein Thema?

Das Thema interessiert dich? Wenn Du fragen hast, dann melde dich ganz unverbindlich bei uns!


Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Für das Handling unseres Newsletters nutzen wir den Dienst HubSpot. Mehr Informationen, insbesondere auch zu Deinem Widerrufsrecht, kannst Du jederzeit unserer Datenschutzerklärung entnehmen.

Erlebe VoiceAI

Melde dich jetzt für deinen exklusiven Demo-Termin der Mayflower VoiceAI an und überzeuge dich von dem Basissetup.

Stelle uns in diesem Termin deine Herausforderung vor und wir finden gemeinsam heraus, wie VoiceAI in deinem Szenario zum tragen kommt.