Sparround

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_case names, everything possibly nullable, dates as String, prices as int (cents). It lives in the data layer and knows fromJson/toJson.
  • Domain model — the shape the app wants. Non-nullable fields, DateTime, double price, an enum status, business getters. It lives in domain and 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.

dart
// ══ 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.

QuestionDTO (API model)Domain model
Who dictates the shapeThe backendThe app's business logic
NullabilityGenerous: a field may be absentStrict: if a field exists, it exists
NamingThe 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 changesWhen the backend changesWhen 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