Als ich Cook Book mit Serverpod aufgebaut habe, lag eine Lösung erst einmal nahe: die generierten Serverpod-Modelle überall verwenden. Im Backend, im Client und in der Flutter-App.

Genau das wollte ich aber nicht.

Meine Fachlogik sollte in einem eigenen Dart-Paket leben. Ohne Session, ohne Tabellenmodelle und vor allem ohne einen laufenden Serverpod-Server, nur damit ich einen Test ausführen kann. Serverpod sollte für mich Infrastruktur sein: RPC, Authentifizierung und Datenbankzugriff. Nicht der Ort, an dem die Anwendung ihre Sprache lernt.

Damit blieb eine praktische Frage: Wie kommen meine eigenen Dart-Modelle durch die Serverpod-Endpunkte und in den generierten Client? Die Antwort war überraschend klein. dart_mappable übernimmt die Serialisierung, Serverpod bindet die Typen über extraClasses ein und eine fromJson-Factory verbindet beides.

Architektur Dieselben Modelle bis zur Persistenzgrenze

Der Aufbau ist recht simpel: Flutter-App, Endpunkte und Fachservices sprechen mit den Typen aus cook_book_api. Erst im Serverpod-Repository wird übersetzt. Dort dürfen dann auch Tabellenmodelle, UUIDs und includes auftauchen.

Für mich ist diese Grenze vor allem wegen der Tests wichtig. Ein Fachservice kennt nur seinen Repository-Vertrag. Im Test gebe ich ihm eine In-Memory- oder Fake-Implementierung und kann die Regeln direkt ausführen. Kein Serverstart. Keine Datenbank. Kein Serverpod-Setup, das mit der eigentlichen Regel gar nichts zu tun hat.

Warum ich nicht einfach ein Modell für alles verwende

Ein Serverpod-Tabellenmodell ist ziemlich gut in dem, wofür es gedacht ist: eine Datenbankstruktur abzubilden. Es kennt Tabellenfelder, Fremdschlüssel, Relationen und Werte, die nur auf dem Server gebraucht werden.

Für die App sind viele dieser Details schlicht Ballast. Beim Rezept sieht man das ganz gut:

Zwei Modellwelten Gleicher Inhalt, andere Verantwortung
App & Fachlogik Recipe
Zeit
Duration
Beziehungen
Reference<Household>
Zutaten
List<RecipeIngredient>
Bild
öffentliche imageUrl

Lesbar und stabil für UI, Tests und Fachoperationen.

Server & Datenbank RecipeTable
Zeit
durationInSeconds
Beziehungen
householdId
Zutaten
Serverpod-Relation
Bild
serverOnly-Relation

Optimiert für Schema, Includes und Datenbankzugriff.

Auf der Serverpod-Seite sieht das Modell deshalb auch nach Datenbank aus:

# backend/cook_book_server/lib/src/features/recipe/protocol/recipe.spy.yaml
class: RecipeTable
table: recipes

fields:
  durationInSeconds: int
  image: CookBookFileTable?, relation(optional, onDelete=SetNull), scope=serverOnly
  imageUrl: String?, !persist
  household: HouseholdTable?, relation(onDelete=Cascade)

Im Fachmodell möchte ich dagegen Typen sehen, die ausdrücken, was gemeint ist:

@MappableClass()
class Recipe extends Model with RecipeMappable {
  final String name;
  final Duration duration;
  final Reference<Household> household;
  final List<RecipeIngredient> ingredients;
  final String? imageUrl;

  const Recipe({
    required super.id,
    required this.name,
    required this.duration,
    required this.household,
    this.ingredients = const [],
    this.imageUrl,
    required super.createdAt,
    required super.updatedAt,
  });

  factory Recipe.fromJson(Map<String, dynamic> json) =>
      RecipeMapper.fromJson(json);
}

Wie die eigenen Modelle durch Serverpod kommen

Jetzt zum interessanten Teil: Recipe ist kein Serverpod-Protokollmodell und soll auch keines werden. Trotzdem taucht es direkt in der Signatur eines Endpoints auf.

Dafür muss Serverpod den Typ beim Generieren kennen. In backend/cook_book_server/config/generator.yaml steht er unter extraClasses:

extraClasses:
  - package:cook_book_api/cook_book_api.dart:Recipe
  - package:cook_book_api/cook_book_api.dart:RecipeIngredient
  - package:cook_book_api/cook_book_api.dart:RecipeInstruction

Zusätzlich braucht die Klasse eine fromJson-Factory. Die macht selbst nichts Besonderes, sondern reicht an den von dart_mappable erzeugten Mapper weiter. Serverpod kann den Typ damit deserialisieren und in seinen Client übernehmen. Die eigentlichen JSON-Regeln bleiben bei dart_mappable.

Von Hand würde ich diese Liste garantiert irgendwann vergessen. Deshalb sucht tools/api_serverpod_generator.dart im API-Paket nach allen @MappableClass-Klassen, ergänzt fehlende Factories und schreibt extraClasses. Der gekürzte Kern sieht so aus:

final parsedResult = parseString(content: file.readAsStringSync());

for (final declaration in parsedResult.unit.declarations) {
  if (declaration is! ClassDeclaration) {
    continue;
  }

  final isMappable = declaration.metadata.any(
    (annotation) => annotation.name.name == 'MappableClass',
  );
  if (!isMappable) {
    continue;
  }

  final className = declaration.name.lexeme;
  modelNames.add(className);

  final hasFromJson = declaration.members
      .whereType<ConstructorDeclaration>()
      .any((constructor) => constructor.name?.lexeme == 'fromJson');
  if (hasFromJson) {
    continue;
  }

  insertions[declaration.rightBracket.offset] = '''
  factory $className.fromJson(Map<String, dynamic> json) =>
      ${className}Mapper.fromJson(json);
''';
}

final extraClasses = [
  for (final className in modelNames)
    'package:$packageName/$packageName.dart:$className',
];

final editor = YamlEditor(generatorFile.readAsStringSync());
editor.update(['extraClasses'], extraClasses);
generatorFile.writeAsStringSync(editor.toString());

Der Helper arbeitet auf dem Dart-AST. Er muss also nicht mit regulären Ausdrücken erraten, wo eine Klasse oder ein Konstruktor beginnt. YamlEditor ersetzt anschließend nur extraClasses und lässt den Rest der Serverpod-Konfiguration in Ruhe.

Danach ist die Reihenfolge wichtig: erst die Mapper, dann der Serverpod-Code.

Codegenerierung Vom API-Modell zum generierten RPC-Code
  1. 01 Modelle im API-Paket ändern Die Fachmodelle bleiben frei von Serverpod-Abhängigkeiten.
  2. 02 Serverpod konfigurieren Der Helper ergänzt fromJson und extraClasses.
  3. 03 JSON-Mapper generieren build_runner erzeugt die Mapper für die API-Typen.
  4. 04 Serverpod-Code generieren Server und Client übernehmen die API-Typen.

Eine kleine Falle bleibt: Client und Server müssen beim Start initializeCookBookApiMappers() aufrufen. Dort registriere ich neben den generierten Mappern auch einen eigenen DurationSecondsMapper. Er transportiert eine Duration als Sekunden. Fehlt die Initialisierung auf einer Seite, fehlt dort auch diese Regel zur Laufzeit.

Das Repository ist die Übersetzungsgrenze

Der Endpoint selbst bleibt dadurch angenehm unspektakulär. Er nimmt ein Recipe entgegen und gibt wieder eines zurück:

class RecipeEndpoint extends AuthenticatedEndpoint {
  Future<Recipe> create(Session session, Recipe value) async =>
      (await session.recipeService).create(value);
}

Erst das Serverpod-Repository kennt beide Typen:

Explizites Mapping Nur das Repository übersetzt API- und Tabellenmodelle
API-ModellÜbersetzungTabellenmodell
DurationSekundendurationInSeconds
Reference<Household>UUIDhouseholdId
Idneu oder persistentUuidValue?
imageUrlPublic URLimage · serverOnly
class RecipeRepositoryServerpod extends RecipeRepository<Transaction>
    with ApiConversionMethods<Recipe, RecipeTable> {
  @override
  Recipe toApi(RecipeTable entity) => Recipe(
    id: entity.id.toId(),
    name: entity.name,
    duration: Duration(seconds: entity.durationInSeconds),
    household: entity.householdId.toReference(),
    createdAt: entity.createdAt,
    updatedAt: entity.updatedAt,
  );

  @override
  RecipeTable toEntity(Recipe api) => RecipeTable(
    id: api.id.isNew ? null : api.id.toUuidValue(),
    name: api.name,
    durationInSeconds: api.duration.inSeconds,
    householdId: api.household.requireId().toUuidValue(),
    createdAt: api.createdAt,
    updatedAt: api.updatedAt,
  );
}

Genau hier darf das Datenbankwissen sichtbar sein. Das Repository übersetzt Duration in Sekunden, Referenzen in UUIDs und entscheidet, welche Relationen geladen werden müssen.

Auch Abweichungen lassen sich hier sauber verstecken. Beim Rezept bleibt die interne Bildrelation auf dem Server. Das Repository macht daraus eine öffentliche URL und setzt imageUrl im Fachmodell. Die Flutter-App bekommt einfach ein Recipe. Von RecipeTable, der Bildrelation und den Serverpod-UUIDs weiß sie nichts.

Warum ich die zusätzlichen Mapper in Kauf nehme

Ja, das ist zusätzlicher Code. Für viele fachliche Typen gibt es ein zweites Tabellenmodell und irgendwo müssen beide ineinander übersetzt werden.

Für mich lohnt sich das zuerst bei den Tests. Die Fachlogik läuft unabhängig von Serverpod und Datenbank. Direkt danach kommt die Ruhe bei Datenbankänderungen: Eine neue Relation oder ein anderes Tabellenfeld endet im Adapter und wandert nicht automatisch durch die ganze App.

Wirkungsbereiche Datenbank- und Fachänderungen bleiben getrennt
Datenbank ändert sich Relation, Spalte oder Include
bleibt hinter Serverpod-Adapter + Tabellenmodell
Fachmodell ändert sich Typ, Regel oder Operation
läuft unabhängig in cook_book_api + isolierten Tests
Persistenzstrategie ändert sich Server, Gerät oder beides
betrifft nur neue oder kombinierte Repository-Adapter

Ein weiterer Vorteil wurde für mich erst später wichtig: Das Serverpod-Repository ist nur eine Implementierung des Vertrags. Daneben kann genauso gut ein lokales Repository stehen, das Daten auf dem Gerät ablegt. Oder ein Adapter, der erst lokal speichert und ausgewählte Daten später synchronisiert. Solange der Vertrag gleich bleibt, müssen die Fachservices davon nichts wissen.

Das ist mehr als ein Offline-Feature. In regulierten oder sicherheitskritischen Bereichen darf oder soll nicht jede Information sofort auf einem Server landen. Eine austauschbare Persistenz gibt mir dafür Spielraum. Sie beantwortet allerdings keine Sicherheitsfrage von selbst. Verschlüsselung, Zugriffsschutz, Synchronisation und Konflikte müssen weiterhin sauber gelöst werden.

Würde ich das immer so bauen?

Nein.

Für eine kleine CRUD-App würde ich wahrscheinlich einfach die Serverpod-Modelle verwenden. Zwei Modellwelten kosten Zeit. Eine Feldänderung kann das Schema, das Fachmodell und den Mapper betreffen. Vergisst man eine Zuordnung, fällt das ohne guten Test womöglich erst zur Laufzeit auf.

Auch die Codegenerierung hat eine feste Reihenfolge und die Mapper müssen auf Client und Server initialisiert sein. Mein Helper nimmt davon Arbeit ab. Er nimmt mir aber nicht die Verantwortung ab, die generierten Änderungen anzuschauen.

Architekturentscheidung Wann sich zwei Modellwelten lohnen
Kleine CRUD-App Serverpod-Modelle direkt verwenden Wenige Screens, kaum Fachlogik, ähnliche Datenformen
Je stärker Businesslogik und Persistenz auseinanderlaufen, desto mehr zahlt sich die Trennung aus.
Wachsende Anwendung API- und Tabellenmodelle trennen Stabile Verträge, austauschbare Persistenz, eigenständige Tests

Der Aufwand lohnt sich für mich dann, wenn Persistenz und Fachmodell sichtbar auseinanderlaufen. Oder sobald ich die Kernlogik ohne das Backend testen, lokal speichern oder später an eine andere Infrastruktur hängen möchte.

Mein Fazit

Ich möchte Serverpod vollständig nutzen, ohne meine gesamte Anwendung an seine Tabellenmodelle zu hängen. dart_mappable, extraClasses und die kleine fromJson-Brücke machen genau das möglich: Meine eigenen Modelle laufen direkt durch die Serverpod-Endpunkte und landen im generierten Client.

Die Übersetzung beginnt erst dort, wo sie wirklich gebraucht wird: im Repository vor der Datenbank. Das sind ein paar Mapper mehr. Dafür bleibt cook_book_api ein eigenständiges Dart-Paket, meine Tests bleiben schnell und Serverpod bleibt das, was es in dieser Architektur sein soll: ein sehr guter Adapter.