Pernah buka kodebase Flutter orang lain — atau bahkan kode sendiri dari tiga bulan lalu — terus langsung ngerasa pengen tutup laptop dan ganti profesi jadi penjual es teh?

Saya pernah.

http.get(...) nongol di dalam build(). Logic role user diitung di dalam widget. Repository throw exception sembarangan kayak lagi lempar ember. Dan satu file controller yang panjangnya udah kayak novel Pramoedya.

Dari sana lahirlah Karna MVC — sebuah Flutter architecture template yang saya buat bukan karena ingin kelihatan keren di GitHub, tapi karena saya sudah tidak tahan, p̶e̶n̶g̶e̶n̶ ̶p̶i̶p̶i̶s̶.

Kenapa namanya Karna?

Karna adalah ksatria dalam Mahabharata yang merupakan salah satu karakter favorit saya, ia sempurna dalam dirinya sendiri. Tidak butuh apa-apa dari luar untuk berfungsi. Tapi tetap berjuang untuk tujuan yang lebih besar.

Tiap fitur dalam Karna MVC dirancang seperti itu: mandiri, tidak import sana-sini, tapi tetap bisa berkomunikasi dengan fitur lain lewat mekanisme yang proper.

Dan karena kebetulan dalam Bahasa Indonesia ada kata karena — setiap layer ada karena fiturnya. Bukan karena kebiasaan. Bukan karena “biasanya gitu”. Tapi karena memang dibutuhkan.

Oke cukup filosofinya, mari kita masuk ke masalah nyata.

Masalah yang ingin diselesaikan (dan mungkin kamu juga ngalamin ini)

1. Error handling yang “asal jalan”

// Yang sering saya lihat (dan sering saya lakuin dulu juga, hehe)
try {
  final data = await repository.getUser(id);
  setState(() => user = data);
} catch (e) {
  print(e); // siap-siap debugging 2 jam karena ini
}

Repository throw exception. Controller catch seadanya. UI entah bereaksi entah tidak. Tidak ada tipe, tidak ada konteks, tidak ada harapan.

2. Tidak ada kejelasan: data ini dari mana?

Apakah data yang ditampilkan ini dari cache lokal? Dari server? Stale dari 3 hari lalu? Tidak ada yang tahu. Termasuk developernya.

3. Coupling yang merajalela

Fitur profile import auth. auth import user. user import profile. Selamat, kamu baru saja membuat lingkaran setan dependency yang tidak akan selesai di-debug sampai lebaran.

Solusinya: Empat Pilar Karna MVC

🛡 Pilar 1: Result — Tidak ada lagi exception terbang bebas

sealed class Result<T> {
  R when<R>({
    required R Function(T data) success,
    required R Function(AppException error) failure,
  });
}

Repository tidak pernah throw. Titik. Kalau gagal, kembalikan Failure(NetworkException(...)). Kalau berhasil, kembalikan Success(data). Controller yang nerima tinggal .when(success: ..., failure: ...).

Hasilnya? Error handling yang terpaksa eksplisit. Tidak bisa pura-pura tidak ada.

⚡ Pilar 2: DataStrategy — Deklarasikan niatmu dari awal

Ini bagian favorit saya.

Setiap kali controller mau load data, dia harus mengaku mau pakai strategi apa:

enum DataStrategy {
  localFirst,          // Cache dulu, remote kalau miss
  staleWhileRevalidate, // Tampil cache, refresh di background
  remoteFirst,          // Selalu hit server, fallback ke cache
}

Kayak tanda tangan kontrak. Tidak bisa seenaknya “nanti dilihat situasi”. Deklarasikan, jalankan, konsisten.

staleWhileRevalidate adalah yang paling sering saya pakai untuk dashboard dan feed: user langsung lihat data (dari cache), sementara di background data fresh sudah diambil. Tidak ada loading spinner yang bikin frustrasi, tapi data tetap up-to-date.

🗄 Pilar 3: LocalStorage — Ganti backend storage tanpa sentuh satu fitur pun

abstract class LocalStorage {
  Future<Map?> getJson(String key);
  Future<void> putJson(String key, Map value);
  Future<void> remove(String key);
  Future<bool> has(String key);
  // dan beberapa method lainnya
}

Ini interface-nya. Di belakangnya bisa Hive, SharedPreferences, atau bahkan InMemory (khusus testing).

Mau ganti dari SharedPrefs ke Hive karena dataset makin gede? Ganti satu baris di DI. Semua fitur otomatis ikut. Tidak perlu refactor satu per satu.

📡 Pilar 4: EventBus — Fitur ngobrol tanpa saling kenal

// Di AuthController, setelah logout:
eventBus.fire(UserLoggedOutEvent());

// Di ProfileController, yang tidak pernah import auth:
_sub = _eventBus.on<UserLoggedOutEvent>().listen((_) => clearProfile());

auth tidak kenal profile. profile tidak import auth. Tapi ketika user logout, profile langsung bersih. Lewat event.

Ini yang bikin fitur benar-benar mandiri — kayak Karna dalam mitologinya.

Struktur foldernya, biar tidak bingung

lib/
├── core/           ← Infrastruktur: ApiClient, EventBus, Storage, dll
└── features/
    └── <feature>/  ← Satu folder per fitur, lengkap dan mandiri
        ├── model/
        ├── repository/
        │   └── data_source/
        ├── controller/
        └── view/

Aturan emasnya: fitur tidak boleh import fitur lain. Kalau butuh komunikasi, pakai EventBus. Kalau butuh data bersama, angkat ke core.

BaseController: Boilerplate? Sudah diurus.

Semua controller extend BaseController. Hasilnya: isLoading, isRefreshing, hasError, dan errorMessage langsung tersedia tanpa nulis ulang.

class PostController extends BaseController {
  Future<void> loadPosts() async => load(
    strategy: DataStrategy.staleWhileRevalidate,
    cacheAction: () => _repository.getPostsFromCache(),
    freshAction: () => _repository.getPostsFromRemote(),
    onSuccess: (data) => _posts = data,
  );
}

Satu method load(), semua state diurus otomatis. Cukup kasih tahu strategi, dari mana cache-nya, dari mana remote-nya, dan apa yang dilakukan kalau sukses.

Testing: Tidak perlu Hive sungguhan, tidak perlu internet sungguhan

test('localFirst menampilkan cached data', () async {
  mockRepo.cacheResult = Success(fakePost);
  await controller.loadPosts();
  expect(controller.posts, equals([fakePost]));
});

InMemoryStorage ada di core khusus untuk ini. Test jalan cepat, deterministik, dan tidak bergantung pada kondisi jaringan kantor yang naik-turun.

Yang TIDAK Boleh Dilakukan (seriously, jangan)

  • ❌ await http.get(...) langsung di controller atau widget
  • ❌ if (userRole == 'admin') di dalam view — itu urusan controller
  • ❌ Import dari folder fitur lain
  • ❌ throw Exception(...) dari dalam repository
  • ❌ Pakai API asli di unit tests

Aturan-aturan ini bukan formalitas. Setiap satu yang dilanggar, kompleksitas naik satu level, dan debugging jadi satu jam lebih lama.

Cara mulai pakainya

git clone https://github.com/AkhmadRamadani/karna-flutter-base.git my_app
cd my_app
flutter pub get
flutter run

# Pelajari contoh fitur 'post', lalu hapus kalau sudah paham rm -rf lib/features/post test/features/post# Buat fitur pertamamu ./scripts/create_feature.sh my_feature

Ini starter template, bukan package yang di-pub-dev-in. Clone, pelajari, adaptasi. Kode ini milikmu sepenuhnya.

Pelajaran yang saya petik

1. Menurutku, arsitektur yang baik bukan yang paling canggih — tapi yang paling mudah saat tracing code. Saya tahu kalo kita butuh abstraction dll, kebutuhan unit test. Tapi jujur saya udah capek kalo cari abstraction yang terlalu dalam, ga nemu-nemu sumber issuenya, skill issue? i think so, tapi kalo kamu pernah jadi pemula, pasti pernah kesusahan di sini.

2. Typed error handling bukan pilihan, ini kewajiban. catch (e) { print(e); } adalah tech debt yang harus kamu bayar dengan jam debugging. Result<T> memaksa kamu bayar di muka, dengan cara yang lebih murah.

3. Strategi data loading harus eksplisit. Kalau tidak tahu data ini dari cache atau remote, berarti arsitekturmu tidak cukup jelas. Dan ketidakjelasan itu yang bikin bug susah di-trace.

4. Fitur yang tidak saling kenal itu fitur yang sehat. Coupling bukan cuma masalah teknis — itu masalah psikologis. Kalau ganti satu fitur bikin takut fitur lain rusak, developer jadi tidak berani refactor. Dan kode yang tidak pernah di-refactor itu… ya itu tadi, yang bikin pengen “mending ternak ayam, dah”.

Apa selanjutnya?

Saya sedang eksplorasi beberapa hal untuk iterasi berikutnya:

  • Code generator yang lebih lengkap — satu command, semua file terbentuk
  • Support untuk berbagai macam state management sebagai alternatif Provider
  • Dokumentasi video — kalau ga males — untuk setiap layer — karena kadang baca docs itu kurang, perlu lihat langsung.

Kalau kamu punya Flutter project yang mulai terasa berat, atau sedang mulai project baru dan ingin pondasi yang solid — cobain dehh Karna MVC. Bukan karena ini sempurna, tapi karena cukup berani untuk punya aturan yang jelas.

Dan kadang, aturan yang jelas itu yang paling kamu butuhkan.

Repo: github.com/AkhmadRamadani/karna-flutter-base

Feedback, issue, dan PR selalu terbuka. Kalau ada yang mau didiskusikan soal arsitektur Flutter, saya ada di sini.

Oke, sekian — terima kasih sudah baca sampai sini! 🙏