DTO vs domain model, and mappers
The backend's JSON and the app's model are not the same thing, and they do not change at the same pace. When you merge those two worlds into one type, every backend change ripples through the whole app.
Keeping them apart gives you two types:
- DTO (API model) — a mirror of the wire format.
snake_casenames, everything possibly nullable, dates asString, prices asint(cents). It lives in thedatalayer and knowsfromJson/toJson. - Domain model — the shape the app wants. Non-nullable fields,
DateTime,double price, anenumstatus, business getters. It lives indomainand knows nothing about JSON.
Between them stands a mapper: dto.toDomain(). It is one function, and its test is among the most valuable you can write — it is the first place that breaks when the backend changes.
The official recommendations treat this separation as conditional: recommended in large apps, extra verbosity in small ones. So the decision depends on the project's size.
// ══ lib/data/dto/product_dto.dart ══ (məftil formatının güzgüsü)
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_dto.freezed.dart';
part 'product_dto.g.dart';
@freezed
abstract class ProductDto with _$ProductDto {
const factory ProductDto({
required String id,
// Backend-in adları olduğu kimi saxlanılır.
@JsonKey(name: 'name') String? name,
@JsonKey(name: 'price_cents') int? priceCents,
@JsonKey(name: 'discount_percent') int? discountPercent,
@JsonKey(name: 'created_at') String? createdAt,
@JsonKey(name: 'is_archived') bool? isArchived,
}) = _ProductDto;
factory ProductDto.fromJson(Map<String, dynamic> json) =>
_$ProductDtoFromJson(json);
}
// ══ lib/data/dto/product_dto_mapper.dart ══ (yeganə çevirmə nöqtəsi)
import 'package:my_app/data/dto/product_dto.dart';
import 'package:my_app/domain/models/product.dart';
extension ProductDtoMapper on ProductDto {
Product toDomain() => Product(
id: id,
// null-un müdafiəsi BURADA olur, UI-da yox.
title: name ?? 'Adsız məhsul',
// Qəpik → manat: biznes vahidinə çevrilmə.
price: (priceCents ?? 0) / 100,
discountPercent: discountPercent ?? 0,
createdAt: DateTime.tryParse(createdAt ?? ''),
isArchived: isArchived ?? false,
);
}
// ══ Repository-də istifadə ══
final dtos = await _apiClient.getActiveProducts();
return dtos.map((dto) => dto.toDomain()).toList();
// Nəticə: `priceCents`, `is_archived` və `null` sözləri
// data qatından KƏNARA çıxmır.The DTO, the mapper and the domain model. Note how `null`, `snake_case` and the cents-to-currency conversion all end at the mapper.
| Question | DTO (API model) | Domain model |
|---|---|---|
| Who dictates the shape | The backend | The app's business logic |
| Nullability | Generous: a field may be absent | Strict: if a field exists, it exists |
| Naming | The backend's names (`price_cents`) | The app's vocabulary (`price`) |
| Dates | `String` (an ISO string) | `DateTime` |
| Status | `String` (`"pending"`) | `enum OrderStatus` |
| Which layer it lives in | `data/dto/` | `domain/models/` |
| When it changes | When the backend changes | When business rules change |
When is one model enough? The official stance treats this separation as conditional, so make the decision deliberately.
One model is enough when: you control the backend yourself, the JSON shape already matches the app's vocabulary, the project is small, and nullability is not a problem. Then a single freezed model carries both fromJson and the business getters.
You need two models if at least one of these is true:
- The backend belongs to another team and its shape shifts.
- The JSON is "dirty": everything nullable, dates as
String, booleans as"1"/"0". - Several sources (REST plus a local database) map into the same domain model.
- The domain model carries computed fields that do not exist in the API.
There is a middle option too: write DTOs only for the problematic endpoints. That is pragmatic and often the most correct choice — nothing requires every endpoint to follow the same rule.
The mapper belongs in the data layer, because it knows the backend's shape. A domain model should not have a fromJson — that would make the domain aware of JSON and break the dependency rule.
There are two technical options: extension ProductDtoMapper on ProductDto { Product toDomain() } (as above) or a separate ProductMapper class. The extension is more compact; a separate class is more comfortable once the mapper itself takes a dependency (a currency rate, for instance).
Practice. Write a DTO plus a mapper for your most troublesome endpoint, and three tests for the mapper: (1) complete JSON, (2) half the fields null, (3) a malformed date. Done means: all three are green and no null checks are left in a UI file.
📚 Sources and documentation
- json_serializableofficialpub.dev
`@JsonKey`, generated `fromJson`/`toJson` and the `part '*.g.dart'` directive — the main tool for DTOs.
- Flutter: JSON and serializationofficialdocs.flutter.dev
The official explanation of the choice between manual parsing and code generation.
- Architecture recommendations: separate API and domain modelsofficialdocs.flutter.dev
Why this separation is "conditional" — recommended in large apps, overhead in small ones.
- Case study: the data layerofficialdocs.flutter.dev
How API models are kept in real code and how the repository converts them.