Mobilni razvoj
FlutterDartArhitekturaRepository PatternTestiranjeClean Architecture

Flutter Repository Pattern kako treba: DTO-ovi, mapperi, tipizirane greške i testabilnost

AO
Adrijan Omićević
·13 min čitanja

# Što ćete izgraditi (i zašto je to važno)#

Dobar Flutter repository pattern nije samo skrivanje HTTP poziva. Radi se o postavljanju granica tako da vaš UI i domenska logika nikad ne ovise o JSON strukturama API-ja, shemama baze podataka ili čudnim ponašanjima third-party SDK-ova.

Ovaj vodič prikazuje konkretan, test-friendly pristup uz:

  • API modele kao DTO-ove, mapirane u domenske entitete
  • odvojene perzistencijske modele za lokalnu pohranu
  • tipizirane failuree umjesto ad hoc iznimki
  • paginaciju i cacheiranje koje je lako razumjeti
  • unit testove s fakeovima i mockovima koje možete pokrenuti u milisekundama

Ako već koristite Clean Architecture, ovo se prirodno uklapa. Ako ne koristite, pattern možete uvoditi postupno. Za širi kontekst o feature-first strukturi i granicama, pogledajte Arhitektura Flutter aplikacije: Clean Architecture feature-first.

# Ključno pravilo: jedna granica, jedna odgovornost#

Repozitoriji postoje da zaštite domenu od čestih promjena. API mijenja polja, baza mijenja stupce, provideri uvode breaking changeove. Vašu domenu to ne bi trebalo zanimati.

Praktična podjela izgleda ovako:

SlojVlasništvoModeliZna za
PresentationEkrani, stateView modeliUse caseovi, domenu
DomainPoslovna pravilaEntiteti, value objectiRepository interfacee
DataIntegracijski detaljiDTO-ovi, perzistencijski modeliHTTP, DB, cache, mapping

Pravilo: Domena definira repository interfacee. Data sloj ih implementira. Presentation ovisi o domeni, ne o data sloju.

🎯 Ključna poruka: Ako vaš UI importira DTO-ove, granica repozitorija curi.

# Data ugovori: DTO-ovi, entiteti i perzistencijski modeli#

Najveći izvor arhitektonskog “odluta” je miješanje modela. Ako to riješite rano, uštedjet ćete mjesece kasnije.

DTO-ovi: preslikajte API, čak i ako je ružan#

DTO-ovi trebaju pratiti API shemu 1:1, uključujući nedosljedna imenovanja. To ih čini otpornima na promjene backend-a i drži mapping eksplicitnim.

Dart
class UserDto {
  final String id;
  final String email;
  final String? full_name;
 
  UserDto({
    required this.id,
    required this.email,
    required this.full_name,
  });
 
  factory UserDto.fromJson(Map<String, dynamic> json) {
    return UserDto(
      id: json['id'] as String,
      email: json['email'] as String,
      full_name: json['full_name'] as String?,
    );
  }
}

Ako backend sutra doda full_name, mijenjate samo DTO i mapper.

Domenski entiteti: odražavaju poslovno značenje#

Entiteti trebaju biti stabilni i izražavati što aplikaciji treba, a ne što API slučajno isporučuje.

Dart
class User {
  final String id;
  final String email;
  final String displayName;
 
  const User({
    required this.id,
    required this.email,
    required this.displayName,
  });
}

Perzistencijski modeli: optimizirani za lokalnu pohranu#

Perzistencijski modeli slijede ograničenja storage rješenja. Npr. SQLite tablice ili Hive objekti mogu koristiti različite tipove, indekse i optionality.

Dart
class UserRecord {
  final String id;
  final String email;
  final String displayName;
  final int updatedAtMs;
 
  UserRecord({
    required this.id,
    required this.email,
    required this.displayName,
    required this.updatedAtMs,
  });
}

ℹ️ Napomena: Normalno je imati tri reprezentacije iste “stvari”. Stabilnost domene vrijedi dodatnog mapiranja.

# Mapping: držite ga eksplicitnim i centraliziranim#

Mapperi su mjesto gdje dokumentirate transformacije, default vrijednosti i pravila backward kompatibilnosti.

Mapper iz DTO-a u domenu#

Dart
extension UserDtoMapper on UserDto {
  User toDomain() {
    return User(
      id: id,
      email: email,
      displayName: (full_name == null || full_name!.trim().isEmpty)
          ? email
          : full_name!.trim(),
    );
  }
}

Domena u perzistenciju i natrag#

Dart
extension UserRecordMapper on UserRecord {
  User toDomain() => User(id: id, email: email, displayName: displayName);
}
 
extension UserToRecordMapper on User {
  UserRecord toRecord({required int nowMs}) {
    return UserRecord(
      id: id,
      email: email,
      displayName: displayName,
      updatedAtMs: nowMs,
    );
  }
}

Ovo drži “poslovne defaultove” poput fallback display namea na jednom mjestu, umjesto da su razbacani po UI widgetima.

💡 Savjet: Stavite mappere uz svoje tipove modela unutar data feature foldera. Izbjegavajte globalni mappers/ direktorij koji se s vremenom pretvori u odlagalište.

# Tipizirani failurei: učinite obradu grešaka predvidljivom#

Većina Flutter aplikacija završi s miksom bacanja iznimki, string poruka i nullable returna. To čini retry, analitiku i UI stanja nedosljednima.

Umjesto toga, vraćajte Result s tipiziranim failureima.

Jednostavan Result tip#

Dart
sealed class Result<T> {
  const Result();
}
 
class Ok<T> extends Result<T> {
  final T value;
  const Ok(this.value);
}
 
class Err<T> extends Result<T> {
  final Failure failure;
  const Err(this.failure);
}

Tipovi failurea na koje UI može reagirati#

Dart
sealed class Failure {
  final String message;
  const Failure(this.message);
}
 
class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}
 
class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure() : super('Unauthorized');
}
 
class NotFoundFailure extends Failure {
  const NotFoundFailure() : super('Not found');
}
 
class ValidationFailure extends Failure {
  const ValidationFailure(super.message);
}
 
class CacheFailure extends Failure {
  const CacheFailure(super.message);
}
 
class UnknownFailure extends Failure {
  const UnknownFailure(super.message);
}

Sada UI može mapirati failuree u akcije: prikaz offline bannera, redirekcija na login, prikaz retry opcije ili detalja validacije.

⚠️ Upozorenje: Izbjegavajte vraćanje Exception ili dynamic iz repozitorija. Izgubit ćete exhaustiveness, a testovi će prestati biti precizni.

# Repository interface: domena je vlasnik ugovora#

Repository interface pripada domenskom sloju. Koristi domenske entitete i tipizirane failuree kroz Result.

Paginacija zaslužuje vlastiti model jer paginacija nije “samo lista”.

Dart
class Page<T> {
  final List<T> items;
  final String? nextCursor;
 
  const Page({required this.items, required this.nextCursor});
}
 
abstract class UsersRepository {
  Future<Result<Page<User>>> getUsersPage({
    required int limit,
    String? cursor,
    bool forceRefresh = false,
  });
 
  Future<Result<User>> getUserById(String id, {bool forceRefresh = false});
}

Ovaj interface je stabilan čak i ako kasnije pređete s REST-a na GraphQL ili sa SQLite-a na Isar.

# Implementacija u data sloju: API + cache + mapping#

Dobar repozitorij slaže manje cjeline:

  • UsersApi za remote pozive
  • UsersCache za lokalnu pohranu
  • Clock za kontrolu vremena u testovima
  • Connectivity kao opcionalna ovisnost za pametniju offline logiku

Data sourceovi#

Dart
abstract class UsersApi {
  Future<ApiPageDto<UserDto>> fetchUsers({required int limit, String? cursor});
  Future<UserDto> fetchUserById(String id);
}
 
class ApiPageDto<T> {
  final List<T> items;
  final String? nextCursor;
  ApiPageDto({required this.items, required this.nextCursor});
}
 
abstract class UsersCache {
  Future<void> upsertMany(List<UserRecord> records);
  Future<void> upsertOne(UserRecord record);
 
  Future<List<UserRecord>> getPage({required int limit, String? cursor});
  Future<UserRecord?> getById(String id);
 
  Future<int?> getLastSyncMs();
  Future<void> setLastSyncMs(int ms);
}
 
abstract class Clock {
  int nowMs();
}

Cache policy: učinite je eksplicitnom#

Jednostavna, ali učinkovita politika je “stale while revalidate” s TTL-om. Mnogi timovi biraju 5 do 30 minuta ovisno o podacima. Za consumer aplikacije, 10 minuta je često dobar kompromis.

PolitikaKada se koristiPrednostiMane
Samo mrežaVrlo dinamični podaciUvijek svježeNe radi offline, sporije
Samo cacheOffline-first ekraniRadi offline, brzoMože biti zauvijek zastarjelo
Cache pa mrežaVećina list ekranaBrz first paintTreba background refresh logiku
Na temelju TTL-aUravnotežen defaultPredvidljivoTreba izvor vremena

ℹ️ Napomena: Offline-first ide dalje od cacheiranja i uključuje sinkronizaciju i rješavanje konflikata. Ako vam to treba, pročitajte Flutter offline-first: sinkronizacija i rješavanje konflikata.

# Implementacija repozitorija s paginacijom i cacheiranjem#

Ovaj repozitorij čita iz cachea kada je dopušteno, osvježava s mreže kada je stale ili forsirano, i uvijek mapira u domenske entitete.

Dart
class UsersRepositoryImpl implements UsersRepository {
  final UsersApi api;
  final UsersCache cache;
  final Clock clock;
 
  final int ttlMs;
 
  UsersRepositoryImpl({
    required this.api,
    required this.cache,
    required this.clock,
    this.ttlMs = 10 * 60 * 1000,
  });
 
  @override
  Future<Result<Page<User>>> getUsersPage({
    required int limit,
    String? cursor,
    bool forceRefresh = false,
  }) async {
    final now = clock.nowMs();
    final lastSync = await cache.getLastSyncMs();
    final isStale = lastSync == null || (now - lastSync) > ttlMs;
 
    if (!forceRefresh && !isStale) {
      final cached = await cache.getPage(limit: limit, cursor: cursor);
      final users = cached.map((r) => r.toDomain()).toList();
      return Ok(Page(items: users, nextCursor: _nextCursorFromCache(cached, limit)));
    }
 
    try {
      final dtoPage = await api.fetchUsers(limit: limit, cursor: cursor);
      final users = dtoPage.items.map((d) => d.toDomain()).toList();
 
      final records = users.map((u) => u.toRecord(nowMs: now)).toList();
      await cache.upsertMany(records);
      await cache.setLastSyncMs(now);
 
      return Ok(Page(items: users, nextCursor: dtoPage.nextCursor));
    } on ApiUnauthorizedException {
      return Err(const UnauthorizedFailure());
    } on ApiNotFoundException {
      return Err(const NotFoundFailure());
    } on ApiNetworkException catch (e) {
      return Err(NetworkFailure(e.message));
    } catch (e) {
      return Err(UnknownFailure(e.toString()));
    }
  }
 
  @override
  Future<Result<User>> getUserById(String id, {bool forceRefresh = false}) async {
    if (!forceRefresh) {
      final cached = await cache.getById(id);
      if (cached != null) {
        return Ok(cached.toDomain());
      }
    }
 
    try {
      final dto = await api.fetchUserById(id);
      final user = dto.toDomain();
      await cache.upsertOne(user.toRecord(nowMs: clock.nowMs()));
      return Ok(user);
    } on ApiUnauthorizedException {
      return Err(const UnauthorizedFailure());
    } on ApiNotFoundException {
      return Err(const NotFoundFailure());
    } on ApiNetworkException catch (e) {
      return Err(NetworkFailure(e.message));
    } catch (e) {
      return Err(UnknownFailure(e.toString()));
    }
  }
 
  String? _nextCursorFromCache(List<UserRecord> cached, int limit) {
    if (cached.length < limit) return null;
    return cached.last.id;
  }
}

Dobivate:

  • output isključivo u domeni
  • tipizirane failuree
  • TTL cachea koji možete podešavati po featureu
  • paginaciju preko cursora, i dalje testabilnu

API iznimke: držite ih u data sloju#

Ne želite da API iznimke “procure” u domenu ili UI.

Dart
class ApiNetworkException implements Exception {
  final String message;
  ApiNetworkException(this.message);
}
 
class ApiUnauthorizedException implements Exception {}
 
class ApiNotFoundException implements Exception {}

U produkciji, vaš API klijent može prevoditi Dio greške, HTTP status kodove i timeoutove u ove iznimke.

# Detalji paginacije: cursor, veličina stranice i rubni slučajevi#

Cursor paginacija je česta jer se bolje skalira od offset paginacije na velikim skupovima podataka. Ipak, ponašanje trebate precizno definirati:

  • Ako je nextCursor null, na kraju ste.
  • Ako se refresh dogodi usred scrollanja, možete resetirati listu ili mergeati promjene.

Praktičan pristup je:

  • Repozitorij vraća Page<User> s nextCursor.
  • UI sprema nextCursor i nadodaje stranice.
  • Force refresh resetira cursor na null i čisti listu u stateu.

💡 Savjet: Za brz first paint, učitajte prvu stranicu iz cachea odmah, a zatim pokrenite background refresh. Držite ovu logiku u use caseu ili state sloju, ne u repozitoriju, kako bi repozitorij ostao determinističan.

# Strategija cacheiranja: što cacheirati, a što ne#

Ne treba sve cacheirati. Cacheirajte podatke koji su:

  • skupi za dohvat
  • potrebni offline
  • bitni za dojam performansi

Izbjegavajte cacheiranje podataka koji su:

  • sigurnosno osjetljivi bez enkripcije
  • vrlo volatilni osim ako imate jasan plan invalidacije

Pragmatična cache checklist:

PitanjeAko daAko ne
Poboljšava li startup ili scroll performanse?CacheirajFetchaj po potrebi
Trebaju li korisnici to offline?CacheirajOpcionalno
Može li sigurno zastarjeti?TTL cacheNetwork-first
Je li specifično po korisniku?Namespacaj po user id-uIzbjegni shared cache

# Testabilnost: dependency injection i čiste granice#

Repozitorije je lako unit testirati kada:

  • ovisnosti su injektirane
  • mapping je čist (pure)
  • vrijeme je kontrolirano preko Clock
  • cache i API su interfacei

Ovo je u skladu s preporukom “test piramide”: puno jeftinih unit testova, manje widget testova i minimalno integracijskih testova. Za konkretnu strategiju testiranja, pogledajte Flutter test piramida: unit, widget, integration, golden.

# Unit testiranje repozitorija s fakeovima i mockovima#

Želite testirati i output i side effecte:

  • vraća li Ok s mapiranim entitetima
  • upisuje li u cache na uspjeh
  • vraća li ispravan failure na greške
  • sprječava li TTL nepotrebne mrežne pozive

Primjer fakeova#

Dart
class FakeClock implements Clock {
  int _nowMs;
  FakeClock(this._nowMs);
  @override
  int nowMs() => _nowMs;
 
  void advanceMs(int delta) => _nowMs += delta;
}
 
class FakeUsersCache implements UsersCache {
  final Map<String, UserRecord> store = {};
  int? lastSync;
 
  @override
  Future<UserRecord?> getById(String id) async => store[id];
 
  @override
  Future<List<UserRecord>> getPage({required int limit, String? cursor}) async {
    final items = store.values.toList()..sort((a, b) => a.id.compareTo(b.id));
    if (cursor == null) return items.take(limit).toList();
    final start = items.indexWhere((e) => e.id == cursor) + 1;
    return items.skip(start).take(limit).toList();
  }
 
  @override
  Future<int?> getLastSyncMs() async => lastSync;
 
  @override
  Future<void> setLastSyncMs(int ms) async => lastSync = ms;
 
  @override
  Future<void> upsertMany(List<UserRecord> records) async {
    for (final r in records) {
      store[r.id] = r;
    }
  }
 
  @override
  Future<void> upsertOne(UserRecord record) async {
    store[record.id] = record;
  }
}

Primjer mock API-ja#

Koristite alat za mockanje koji vam odgovara. Primjer ispod prikazuje minimalan ručno napisani fake, koji je često lakši za održavanje od “teških” mockova kad testirate repozitorije.

Dart
class FakeUsersApi implements UsersApi {
  ApiPageDto<UserDto>? pageToReturn;
  Exception? errorToThrow;
  int calls = 0;
 
  @override
  Future<ApiPageDto<UserDto>> fetchUsers({required int limit, String? cursor}) async {
    calls += 1;
    if (errorToThrow != null) throw errorToThrow!;
    return pageToReturn!;
  }
 
  @override
  Future<UserDto> fetchUserById(String id) {
    throw UnimplementedError();
  }
}

Unit test: vraća cacheirano kada je svježe#

Dart
import 'package:test/test.dart';
 
void main() {
  test('returns cached page when within TTL', () async {
    final api = FakeUsersApi();
    final cache = FakeUsersCache();
    final clock = FakeClock(1_000_000);
 
    await cache.upsertMany([
      User(id: '1', email: 'a@x.com', displayName: 'A').toRecord(nowMs: clock.nowMs()),
      User(id: '2', email: 'b@x.com', displayName: 'B').toRecord(nowMs: clock.nowMs()),
    ]);
    await cache.setLastSyncMs(clock.nowMs());
 
    final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock, ttlMs: 60_000);
 
    final result = await repo.getUsersPage(limit: 2);
 
    expect(result is Ok<Page<User>>, true);
    final page = (result as Ok<Page<User>>).value;
    expect(page.items.length, 2);
    expect(api.calls, 0);
  });
}

Unit test: dohvaća s mreže kada je stale i ažurira cache#

Dart
import 'package:test/test.dart';
 
void main() {
  test('fetches network when stale and caches results', () async {
    final api = FakeUsersApi()
      ..pageToReturn = ApiPageDto(
        items: [
          UserDto(id: '10', email: 'c@x.com', full_name: 'C'),
        ],
        nextCursor: null,
      );
 
    final cache = FakeUsersCache();
    final clock = FakeClock(2_000_000);
    await cache.setLastSyncMs(clock.nowMs() - 999_999);
 
    final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock, ttlMs: 60_000);
 
    final result = await repo.getUsersPage(limit: 20);
 
    expect(result is Ok<Page<User>>, true);
    expect(api.calls, 1);
    expect(cache.store.containsKey('10'), true);
  });
}

Unit test: tipizirani failure na Unauthorized#

Dart
import 'package:test/test.dart';
 
void main() {
  test('returns UnauthorizedFailure on 401', () async {
    final api = FakeUsersApi()..errorToThrow = ApiUnauthorizedException();
    final cache = FakeUsersCache();
    final clock = FakeClock(3_000_000);
 
    final repo = UsersRepositoryImpl(api: api, cache: cache, clock: clock);
 
    final result = await repo.getUsersPage(limit: 10, forceRefresh: true);
 
    expect(result is Err<Page<User>>, true);
    final failure = (result as Err<Page<User>>).failure;
    expect(failure is UnauthorizedFailure, true);
  });
}

Ovi testovi se izvršavaju bez Flutter bindingsa, bez HTTP-a i bez baze podataka. To je poanta: brza povratna informacija i stabilni buildovi.

# Česte pogreške i kako ih izbjeći#

Miješanje DTO-ova u domenu#

Ako domenski entiteti sadrže JSON anotacije ili snake_case polja, granica se urušila. Držite DTO-ove u data sloju.

Korištenje iznimki kao control flow#

Iznimke su ok unutar data sloja, ali output repozitorija treba biti eksplicitan. Tipizirani failurei poboljšavaju UI logiku i telemetriju.

Skrivanje cache politike u UI-ju#

Ako svaki ekran donosi vlastitu odluku o cacheiranju, ponašanje postaje nedosljedno. Stavite politiku u repozitorije ili use caseove, pa je ponovno koristite.

Izrada jednog mega-repozitorija za cijelu aplikaciju#

Jedan repozitorij po agregatu ili featureu je lakši za održavanje. Feature-first struktura pomaže spriječiti spajanje featurea. Pogledajte Arhitektura Flutter aplikacije: Clean Architecture feature-first.

# Ključne poruke#

  • Držite DTO-ove, domenske entitete i perzistencijske modele odvojene te mapirajte eksplicitno na granicama.
  • Neka repozitoriji vraćaju domenske entitete i tipizirane failuree kroz Result tip, a ne “sirove” iznimke.
  • Implementirajte cacheiranje s eksplicitnom politikom i kontroliranim satom (Clock) kako bi TTL logika bila deterministična u testovima.
  • Modelirajte paginaciju kao Page s cursorom i testirajte ponašanja kraja liste i refresha.
  • Unit testirajte repozitorije s injektiranim fakeovima ili mockovima, provjeravajući i return vrijednosti i side effecte poput upisa u cache.

# Zaključak#

Testabilan Flutter repository pattern manje je stvar “gdje stavljate datoteke”, a više stvar granica koje provodite: DTO-ovi za remote sheme, perzistencijski modeli za ograničenja pohrane i domenski entiteti za poslovno značenje. Dodajte tipizirane failuree i eksplicitne cache politike, i vaš data sloj postaje predvidljiv, ponovno iskoristiv i jednostavan za refaktoriranje.

Ako želite pomoć da ovaj pattern primijenite na postojeći Flutter codebase ili da dizajnirate offline-first strategiju sa sinkronizacijom i rješavanjem konflikata, kontaktirajte Samioda i pregledat ćemo vašu arhitekturu te predložiti konkretan plan migracije. Za dodatno čitanje krenite s Flutter offline-first: sinkronizacija i rješavanje konflikata i Flutter test piramida: unit, widget, integration, golden.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.