When I started building Cook Book with Serverpod, the obvious approach was to use the generated Serverpod models everywhere: in the backend, the client, and the Flutter app.
That was exactly what I did not want.
I wanted my domain logic to live in its own Dart package. No Session, no table models, and no need to start a Serverpod server just to run a test. For me, Serverpod should provide infrastructure: RPC, authentication, and database access. It should not define the language of the application.
That left one practical question: How do my own Dart models pass through Serverpod endpoints and into the generated client? The answer turned out to be quite small. dart_mappable handles serialization, Serverpod includes the types through extraClasses, and a fromJson factory connects the two.
cook_book_api
Models · Services · Repository contracts
- 01 Flutter app works with API models
- 02 RPC & endpoint transport API models
-
03
Domain service
lives in
cook_book_api -
04
Serverpod repository
toApi↔toEntity - 05 Serverpod & PostgreSQL Tables, relations, UUIDs
The setup is fairly simple. The Flutter app, endpoints, and domain services all use types from cook_book_api. Translation starts in the Serverpod repository. That is where table models, UUIDs, and includes are allowed to appear.
Testing is the main reason I care about this boundary. A domain service only knows its repository contract. In a test, I can pass in an in-memory or fake implementation and run the rules directly. No server startup. No database. No Serverpod setup unrelated to the rule I am testing.
Why I do not use one model for everything
A Serverpod table model is good at what it is made for: describing a database structure. It knows about columns, foreign keys, relations, and values that only exist on the server.
Most of that is just baggage in the app. A recipe makes the difference easy to see:
Recipe
- Time
Duration- Relations
Reference<Household>- Ingredients
List<RecipeIngredient>- Image
- public
imageUrl
Readable and stable for UI, tests, and domain operations.
RecipeTable
- Time
durationInSeconds- Relations
householdId- Ingredients
- Serverpod relation
- Image
serverOnlyrelation
Optimized for schemas, includes, and database access.
On the Serverpod side, the model looks like a database model:
# 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)
In the domain model, I want types that say what they mean:
@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);
}
How the models pass through Serverpod
Now for the interesting part. Recipe is not a Serverpod protocol model, and I do not want it to become one. It still appears directly in an endpoint signature.
Serverpod needs to know the type during code generation. I list it under extraClasses in backend/cook_book_server/config/generator.yaml:
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
The class also needs a fromJson factory. It does not do much on its own. It simply delegates to the mapper generated by dart_mappable. Serverpod can then deserialize the type and include it in its client, while the actual JSON rules stay with dart_mappable.
I would eventually forget to maintain that list by hand. So tools/api_serverpod_generator.dart finds every @MappableClass in the API package, adds missing factories, and writes extraClasses. Here is the shortened core:
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());
The helper works with Dart’s AST. It does not have to guess where a class or constructor starts with regular expressions. YamlEditor then replaces only extraClasses and leaves the rest of the Serverpod configuration alone.
The order matters after that: mappers first, Serverpod code second.
- 01 Change models in the API package The domain models remain free of Serverpod dependencies.
-
02
Configure Serverpod
The helper adds
fromJsonandextraClasses. -
03
Generate JSON mappers
build_runnercreates the mappers for the API types. - 04 Generate Serverpod code Server and client adopt the API types.
There is one small trap left. Client and server both need to call initializeCookBookApiMappers() at startup. Alongside the generated mappers, I register a custom DurationSecondsMapper there. It transports a Duration as seconds. If either side skips initialization, that runtime does not know the rule.
The repository is the translation boundary
The endpoint stays pleasantly uneventful. It accepts a Recipe and returns one:
class RecipeEndpoint extends AuthenticatedEndpoint {
Future<Recipe> create(Session session, Recipe value) async =>
(await session.recipeService).create(value);
}
The Serverpod repository is the first place that knows both types:
DurationSecondsdurationInSeconds
Reference<Household>UUIDhouseholdId
Idnew or persistedUuidValue?
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,
);
}
This is where database knowledge belongs. The repository turns Duration into seconds, references into UUIDs, and decides which relations need to be loaded.
It also hides deliberate differences. The internal image relation for a recipe stays on the server. The repository turns it into a public URL and sets imageUrl on the domain model. The Flutter app simply receives a Recipe. It knows nothing about RecipeTable, the image relation, or Serverpod’s UUID types.
Why I accept the extra mapping code
Yes, this is extra code. Many domain types also need a table model, and something has to translate between the two.
For me, the tests make it worthwhile first. The domain logic runs without Serverpod or a database. The next benefit is how contained database changes become. A new relation or a different column ends at the adapter instead of automatically spreading through the app.
cook_book_api + isolated tests
Another advantage became important to me later. The Serverpod repository is only one implementation of the contract. A local repository can sit beside it and store data on the device. Or an adapter can save locally first and synchronize selected data later. As long as the contract stays the same, the domain services do not need to know.
This is more than an offline feature. In regulated or safety-critical fields, not every piece of information may or should go straight to a server. Replaceable persistence gives me room to handle that. It does not answer the security questions for me. Encryption, access control, synchronization, and conflicts still need proper solutions.
Would I always build it this way?
No.
For a small CRUD app, I would probably use the Serverpod models directly. Two model worlds take time. A field change can touch the schema, domain model, and mapper. Forget one assignment, and without a good test the mistake may only show up at runtime.
Code generation also has a fixed order, and both client and server need to initialize the mappers. My helper removes some manual work. It does not remove the need to review the generated changes.
The effort becomes worthwhile for me once persistence and domain models clearly start to diverge. Or when I want to test the core logic without the backend, store data locally, or connect it to different infrastructure later.
My conclusion
I want to use everything Serverpod offers without tying the whole application to its table models. dart_mappable, extraClasses, and the small fromJson bridge make that possible. My own models pass straight through Serverpod endpoints and into the generated client.
Translation starts where it is actually needed: in the repository in front of the database. That means a few more mappers. In return, cook_book_api stays an independent Dart package, my tests stay fast, and Serverpod remains what it should be in this architecture: a very good adapter.