Clean architecture

Book wire types

Spy files for the API, and a wire mapper that lives beside the book endpoint.

clean-architecturetutorialserverpod

The client needs types Serverpod can serialize. Those types are YAML under presentation/book/, not under infra/models/. They are not serverOnly. serverpod generate copies them into shelf_client.

The mapper that converts them is Dart, in the same folder as the endpoint. It is not a shared presentation/mappers/wire_mappers.dart. A shared mapper file is where every feature's fields accumulate. On a large server that file becomes the one everyone edits. A new project starts with the mapper next to the feature.

Create lib/src/presentation/book/reading_status_wire.spy.yml:

yaml
enum: ReadingStatusWire
values:
  - unread
  - reading
  - read

The Wire suffix is the rule. ReadingStatus is the domain enum. ReadingStatusWire is the protocol enum. They share names so .values.byName can map them. They are not the same type, and the domain file does not import this one.

Create lib/src/presentation/book/input/save_book_input.spy.yml:

yaml
class: SaveBookInput
fields:
  title: String
  authorName: String
  status: ReadingStatusWire

Create lib/src/presentation/book/dto/book_dto.spy.yml:

yaml
class: BookDto
fields:
  id: UuidValue
  title: String
  authorName: String
  status: ReadingStatusWire
  shelfId: UuidValue?
  createdAt: DateTime

Create lib/src/presentation/book/dto/book_list_response.spy.yml:

yaml
class: BookListResponse
fields:
  books: List<BookDto>

Inputs are named *Input. Responses that are more than one object are named *Response. A single book on the wire is BookDto.

Also create the three exception types the endpoint will throw. They live in presentation/shared/ because every feature maps the same domain failures.

api_validation_exception.spy.yml:

yaml
exception: ApiValidationException
fields:
  message: String

api_not_found_exception.spy.yml and api_conflict_exception.spy.yml are the same shape, with classes ApiNotFoundException and ApiConflictException.

Generate again:

bash
serverpod generate

The generated classes are what BookWireMappers constructs. Create lib/src/presentation/book/wire_mappers.dart:

dart
import 'package:shelf_server/src/domain/book/entities/book.dart';
import 'package:shelf_server/src/domain/book/value_objects/reading_status.dart';
import 'package:shelf_server/src/generated/protocol.dart';
import 'package:shelf_server/src/infra/shared/uuid_values.dart';

/// Book wire mapping lives next to the book endpoint, not in a shared file.
class BookWireMappers {
  const BookWireMappers();

  BookDto toDto(Book book) {
    return BookDto(
      id: uuidFromString(book.id),
      title: book.title,
      authorName: book.authorName,
      status: ReadingStatusWire.values.byName(book.status.name),
      shelfId: book.shelfId == null ? null : uuidFromString(book.shelfId!),
      createdAt: book.createdAt,
    );
  }

  ReadingStatus fromStatus(ReadingStatusWire wire) {
    return ReadingStatus.values.byName(wire.name);
  }
}

uuid_values.dart is an infrastructure helper. Presentation importing it is a small leak of a UUID function, not of a repository implementation. If you want the rule stricter, copy the two one-line functions into the presentation folder. Shelf keeps one helper so the string and the UuidValue cannot drift.

This mapper does not import BookRepositoryImpl.

Next: the endpoint.