Sparround

DTO vs domain modeli və mapper-lər

Backend-in JSON-u və tətbiqin modeli eyni şey deyil və eyni tempo ilə dəyişmir. Bu iki dünyanı bir tipdə birləşdirdikdə backend-in hər dəyişikliyi bütün tətbiqə yayılır.

Ayrı saxladıqda iki tip yaranır:

  • DTO (API modeli) — məftil formatının güzgüsü. snake_case adlar, hər şey nullable ola bilər, tarixlər String, qiymətlər int (qəpik). O, data qatında yaşayır və fromJson/toJson bilir.
  • Domain modeli — tətbiqin istədiyi forma. Non-nullable sahələr, DateTime, double price, enum status, biznes getter-ləri. O, domain qatında yaşayır və JSON haqqında heç nə bilmir.

Aralarında mapper durur: dto.toDomain(). Bu, bir funksiyadır və onun testi ən dəyərli testlərdən biridir — backend dəyişəndə ilk sınan yer məhz o olur.

Rəsmi tövsiyələr bu ayrılığı şərtli (conditional) sayır: böyük tətbiqlərdə tövsiyə olunur, kiçikdə əlavə söz-söhbətdir. Yəni qərar layihənin ölçüsündən asılıdır.

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.

DTO, mapper və domain modeli. Diqqət et: `null`, `snake_case` və qəpik-manat çevrilməsi — hamısı mapper-də bitir.

SualDTO (API modeli)Domain modeli
Formanı kim müəyyən edirBackendTətbiqin biznes məntiqi
NullabilitySəxavətli: sahə gəlməyə bilərSərt: sahə varsa, o var
AdlandırmaBackend-in adları (`price_cents`)Tətbiqin lüğəti (`price`)
Tarix`String` (ISO mətn)`DateTime`
Status`String` (`"pending"`)`enum OrderStatus`
Hansı qatda yaşayır`data/dto/``domain/models/`
Nə vaxt dəyişirBackend dəyişdikdəBiznes qaydaları dəyişdikdə

Nə vaxt bir model kifayətdir? Rəsmi mövqe bu ayrılığı şərtli sayır, ona görə qərarı bilərəkdən vermək lazımdır.

Bir model kifayətdir əgər: backend-i sən özün idarə edirsən, JSON forması tətbiqin lüğətinə uyğundur, layihə kiçikdir və nullability problemi yoxdur. Bu halda freezed modeli hem fromJson, hem biznes getter-lərini daşıyır.

İki model lazımdır əgər ən azı biri doğrudur:

  • Backend başqa komandanın nəzarətindədir və forma dəyişir.
  • JSON "çirklidir": hər şey nullable, String tarixlər, "1"/"0" boolean-lar.
  • Bir neçə mənbə (REST + lokal baza) eyni domain modelinə çevrilir.
  • Domain modelinin sahələri API-də olmayan hesablanmış dəyərlər daşıyır.

Ortadaki variant da var: DTO-nu yalnız problemli endpoint-lər üçün yazmaq. Bu, praqmatik və tez-tez ən düzgün seçimdir — hər şeyi eyni qaydaya salmaq məcburiyyəti yoxdur.

Mapper-in yeri data qatıdır, çünki o, backend-in formasını bilir. Domain modelinin fromJson metodu olmamalıdır — o zaman domain JSON haqqında bilməyə başlayır və asılılıq qaydası pozulur.

İki texniki variant var: extension ProductDtoMapper on ProductDto { Product toDomain() } (yuxarıdaki kimi) və ya ayrı ProductMapper sinifi. Extension daha yığcamdır; ayrı sinif isə mapper-in özü asılılıq qəbul etdikdə (məsələn valyuta kursu) daha rahat olur.

Praktika. Ən problemli endpoint-in üçün DTO + mapper yaz və mapper üçün 3 test hazırla: (1) tam JSON, (2) sahələrin yarısı null, (3) tarix formatı yanlış. Hazır sayılır: üç test də yaşıldır və null yoxlaması UI faylında qalmır.

📚 Mənbələr və sənədlər