# Š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:
| Sloj | Vlasništvo | Modeli | Zna za |
|---|---|---|---|
| Presentation | Ekrani, state | View modeli | Use caseovi, domenu |
| Domain | Poslovna pravila | Entiteti, value objecti | Repository interfacee |
| Data | Integracijski detalji | DTO-ovi, perzistencijski modeli | HTTP, 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.
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.
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.
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#
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#
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#
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#
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
Exceptionilidynamiciz 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”.
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:
UsersApiza remote poziveUsersCacheza lokalnu pohranuClockza kontrolu vremena u testovimaConnectivitykao opcionalna ovisnost za pametniju offline logiku
Data sourceovi#
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.
| Politika | Kada se koristi | Prednosti | Mane |
|---|---|---|---|
| Samo mreža | Vrlo dinamični podaci | Uvijek svježe | Ne radi offline, sporije |
| Samo cache | Offline-first ekrani | Radi offline, brzo | Može biti zauvijek zastarjelo |
| Cache pa mreža | Većina list ekrana | Brz first paint | Treba background refresh logiku |
| Na temelju TTL-a | Uravnotežen default | Predvidljivo | Treba 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.
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.
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
nextCursornull, 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>snextCursor. - UI sprema
nextCursori 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:
| Pitanje | Ako da | Ako ne |
|---|---|---|
| Poboljšava li startup ili scroll performanse? | Cacheiraj | Fetchaj po potrebi |
| Trebaju li korisnici to offline? | Cacheiraj | Opcionalno |
| Može li sigurno zastarjeti? | TTL cache | Network-first |
| Je li specifično po korisniku? | Namespacaj po user id-u | Izbjegni 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
Oks 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#
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.
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#
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#
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#
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
Resulttip, 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
Pages 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
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Mobilni razvoj
Sve →Flutter autentifikacija u 2026.: Firebase vs Supabase vs Custom OAuth (prednosti, nedostaci i kriteriji odluke)
Praktična usporedba pristupa autentifikaciji u Flutteru u 2026. — Firebase Auth vs Supabase Auth vs Custom OAuth — s matricom odluke, napomenama za implementaciju i iOS/Android zamkama.
Vodič za pristupačnost u Flutteru: Semantics, redoslijed fokusa i dinamički tekst kako treba
Praktičan vodič za pristupačnost u Flutteru za 2026.: Semantics oznake, ispravan redoslijed fokusa, navigacija tipkovnicom, kontrast i skaliranje teksta uz konkretne korake testiranja za TalkBack i VoiceOver.
Optimizacija Flutter cold starta: brže pokretanje, bolji splash, manje jank frameova
Praktični vodič za 2026. za optimizaciju Flutter cold starta: kako profilirati vrijeme pokretanja, smanjiti tešku inicijalizaciju, izbjeći sinkroni I/O, ukrotiti trošak pokretanja plugina i isporučiti glađi prvi frame uz bolji splash i sekvenciranje deep linkova.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Flutter arhitektura aplikacije koja se skalira: Clean Architecture vs Feature-First (s realnim strukturama mapa)
Praktičan vodič za arhitekturu Flutter aplikacije u 2026.: usporedite Clean Architecture i Feature-First, pogledajte stvarne strukture mapa, granice ovisnosti i strategije testiranja te odaberite pravi pristup za svoj tim i ritam izdanja.
Strategija testiranja u Flutteru: unit, widget, integracijski i golden testovi za brz i pouzdan CI (2026)
Praktična strategija testiranja u Flutteru temeljena na piramidi testiranja: kada koristiti unit, widget, integracijske i golden testove, kako smanjiti flakiness i kako sve pokretati brzo u CI-u.
Vodič za pristupačnost u Flutteru: Semantics, redoslijed fokusa i dinamički tekst kako treba
Praktičan vodič za pristupačnost u Flutteru za 2026.: Semantics oznake, ispravan redoslijed fokusa, navigacija tipkovnicom, kontrast i skaliranje teksta uz konkretne korake testiranja za TalkBack i VoiceOver.