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.
cook_book_api
Modelle · Services · Repository-Verträge
- 01 Flutter-App arbeitet mit API-Modellen
- 02 RPC & Endpoint transportieren API-Modelle
-
03
Fachservice
lebt in
cook_book_api -
04
Serverpod-Repository
toApi↔toEntity - 05 Serverpod & PostgreSQL Tabellen, Relationen, UUIDs
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:
Recipe
- Zeit
Duration- Beziehungen
Reference<Household>- Zutaten
List<RecipeIngredient>- Bild
- öffentliche
imageUrl
Lesbar und stabil für UI, Tests und Fachoperationen.
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.
- 01 Modelle im API-Paket ändern Die Fachmodelle bleiben frei von Serverpod-Abhängigkeiten.
-
02
Serverpod konfigurieren
Der Helper ergänzt
fromJsonundextraClasses. -
03
JSON-Mapper generieren
build_runnererzeugt die Mapper für die API-Typen. - 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:
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.
cook_book_api + isolierten Tests
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.
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.