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.

Architecture The same models up to the persistence boundary

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:

Two model worlds Same content, different responsibilities
App & domain logic Recipe
Time
Duration
Relations
Reference<Household>
Ingredients
List<RecipeIngredient>
Image
public imageUrl

Readable and stable for UI, tests, and domain operations.

Server & database RecipeTable
Time
durationInSeconds
Relations
householdId
Ingredients
Serverpod relation
Image
serverOnly relation

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.

Code generation From API model to generated RPC code
  1. 01 Change models in the API package The domain models remain free of Serverpod dependencies.
  2. 02 Configure Serverpod The helper adds fromJson and extraClasses.
  3. 03 Generate JSON mappers build_runner creates the mappers for the API types.
  4. 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:

Explicit mapping Only the repository translates API and table models
API modelTranslationTable model
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.

Areas of impact Database and domain changes stay separate
Database changes Relation, column, or include
stays behind Serverpod adapter + table model
Domain model changes Type, rule, or operation
runs independently in cook_book_api + isolated tests
Persistence strategy changes Server, device, or both
only affects new or combined repository adapters

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.

Architecture decision When two model worlds are worth it
Small CRUD app Use Serverpod models directly Few screens, little domain logic, similar data shapes
The further business logic and persistence diverge, the more this separation pays off.
Growing application Separate API and table models Stable contracts, replaceable persistence, independent tests

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.