Architecture & Decision Log

Tiga Lapisan, Satu Pola: Struktur Presentation, Domain, dan Data di Holigo - Part 2

5 Oktober 2026·9 menit baca·Muchamad Buchori
FlutterClean ArchitectureBLoCMigrationSoftware DesignMobile Development

Cover artikel: Tiga Lapisan, Satu Pola: Struktur Presentation, Domain, dan Data di Holigo - Part 2

Menjawab Pertanyaan Mendasar: Kode Ini Seharusnya Tinggal di Mana?

Di Part 1 sebelumnya, saya menceritakan alasan di balik keputusan migrasi bertahap per fitur di Holigo dan kenapa tiket Kapal (PELNI) dipilih sebagai proyek percontohan. Setelah fondasi core disiapkan, tantangan berikutnya adalah merumuskan arsitektur konkret di tingkat fitur: bagaimana membagi tanggung jawab kode agar rapi, konsisten, dan mudah dipahami oleh seluruh anggota tim.

Masalah terbesar pada basis kode lama adalah ketiadaan batas yang jelas; setiap developer menyusun fitur dengan gaya masing-masing. Akibatnya, setiap kali ada bug atau penambahan logika baru, pertanyaan sederhana seperti "di file mana kode ini seharusnya ditaruh?" selalu membutuhkan investigasi panjang dan menghasilkan jawaban yang berbeda-beda di setiap layar.

Tujuan utama dari arsitektur baru ini sangat tegas: pertanyaan tersebut harus memiliki jawaban yang identik dan dapat diprediksi di seluruh fitur aplikasi. Kami membagi setiap fitur ke dalam tiga lapisan independen: Presentation, Domain, dan Data, dengan aturan baku bahwa antarmuka UI dilarang keras berkomunikasi langsung dengan jaringan API maupun penyimpanan lokal.

Struktur Tiga Lapisan: Presentation, Domain, dan Data
Batas & Tanggung Jawab
🏛️ Aturan Dependensi: Arah Alur Selalu Mengarah ke DomainClean Architecture

Prinsip utama: UI tidak boleh menyentuh jaringan atau penyimpanan data secara langsung. Domain berdiri murni tanpa dependensi eksternal.

🎨 Presentation (UI & BLoC)
🧠 Domain (Pure Dart)
💾 Data (API & Cache)
🔒 Repository Contract
Presentation & Domain
UI & Business Logic
  • •Presentation: Halaman, widget, dan BLoC yang menangani event pengguna serta merender state responsif.
  • •Domain (Pure Dart): Entity bisnis, kontrak antarmuka repository, dan use cases mandiri yang 100% dapat diuji.
💡 Domain tidak pernah mengimpor library Flutter UI
Data & Infrastructure
Penyimpanan & API
  • •Data Layer: Implementasi kontrak repository, remote API client, local storage cache, dan deserialisasi model.
  • •Model vs Entity: Data Model mem-parse JSON mentah dan dikonversi menjadi Domain Entity murni.
✨ Perubahan API server terisolasi hanya di lapisan Data
💡

Pelajaran Kunci: Pemisahan lapisan tegas memastikan bahwa perubahan teknis di database lokal atau format API server tidak akan pernah merusak logika bisnis maupun tampilan layar pengguna.

Studi Kasus Tiket Kapal: Melacak Alur Data dari Layar Sampai API

Fitur pemesanan tiket Kapal (PELNI) menjadi contoh studi kasus yang sangat ideal karena melibatkan interaksi pengguna yang dinamis, konsumsi REST API eksternal, sekaligus pemanfaatan database lokal untuk riwayat pencarian. Mari kita telusuri bagaimana sebuah permintaan tiket mengalir melewati ketiga lapisan ini secara teratur.

Semuanya bermula ketika pengguna memilih pelabuhan asal, pelabuhan tujuan, dan menekan tombol "Cari Jadwal". Layar di lapisan Presentation tidak memanggil HTTP request secara langsung, melainkan mengirimkan sebuah event SearchShipSchedule ke ShipBloc. BLoC kemudian mengeksekusi use case mandiri di lapisan Domain, yaitu GetShipSchedulesUseCase, yang bertugas memvalidasi kelayakan parameter tanggal dan pelabuhan sebelum diteruskan.

Use case tersebut hanya mengenal kontrak antarmuka ShipRepository. Implementasi konkretnya di lapisan Data (ShipRepositoryImpl) yang membagi tugas secara cerdas: menarik ketersediaan tiket live dari Remote Data Source, sekaligus menyimpan rute pencarian ke SQLite lokal via Local Data Source. Hasil JSON dipetakan ke dalam Domain Entity murni, lalu BLoC merilis state baru ShipScheduleLoaded yang otomatis dirender oleh UI.

Studi Kasus Kapal (PELNI): Alur dari Layar ke API
End-to-End Data Pipeline
01

Layar & BLoC

Presentation

User memilih rute & klik 'Cari Jadwal'. Layar mengirim event SearchShipSchedule ke ShipBloc.

02

Use Case Execution

Domain Layer

ShipBloc memanggil GetShipSchedulesUseCase yang memverifikasi parameter tanggal & pelabuhan.

03

Repository & Data Source

Data Layer

ShipRepositoryImpl memanggil Remote API untuk kuota tiket, dan Local DB untuk riwayat rute.

04

Mapping & Render

State Emission

JSON diubah ke Entity domain. ShipBloc merilis state ShipScheduleLoaded ke antarmuka layar.

💡

Pelajaran Kunci: Satu BLoC mengelola seluruh alur fitur secara deterministik, sehingga siapapun anggota tim yang membuka fitur Kapal langsung tahu letak pasti setiap logika.

Keandalan di Lingkungan Nyata: Penanganan Error dan Data Ganda

Dalam aplikasi transaksi finansial, jalur kegagalan (failure path) menuntut penanganan yang sama disiplinnya dengan jalur sukses. Pada app lama, exception jaringan seperti timeout atau status 500 sering kali lolos mentah hingga ke widget UI, memicu crash aplikasi atau layar putih yang membingungkan pengguna.

Untuk mengatasi hal ini, kami menerapkan pendekatan functional result menggunakan tipe Either<AppException, T> di seluruh repository. Lapisan Data bertindak sebagai benteng pertahanan: semua error HTTP Dio, kegagalan socket, atau galat parsing JSON langsung ditangkap dan dikonversi menjadi subclass AppException yang terstruktur. State di BLoC kemudian melacak status operasi melalui enum sederhana (initial, loading, success, failure), sehingga layar cukup merespons enum tersebut tanpa pernah bersentuhan dengan try-catch yang berserakan.

Prinsip isolasi ini juga berlaku penuh pada koeksistensi data lokal dan remote. Di fitur Kapal, riwayat pencarian rute cukup disimpan di SQLite lokal untuk akses instan tanpa kuota, sedangkan jadwal kapal dan tarif dinamis ditarik live dari server. Karena kedua sumber data ini disembunyikan rapat di balik satu kontrak repository, lapisan UI dan BLoC tidak perlu tahu dari mana data berasal; jika kelak strategi caching diubah dari SQLite ke Hive atau Secure Storage, perubahan teknis tersebut terkurung 100% di lapisan Data.

Penanganan Error & Sumber Data Ganda
Keandalan Arsitektur
🛡️Penanganan Error TerstandarisasiEither<AppException, T>
  1. 01Functional Result: Repository mengembalikan Either: Left membawa AppException, Right membawa data sukses.
  2. 02Zero Exception Leak: Exception teknis (timeout, 500, socket) ditangkap di Data Layer dan tidak tembus mentah ke UI.
  3. 03Enum Request Status: State BLoC melacak status dengan enum (initial, loading, success, failure) yang bersih.
⚠️ Mencegah crash tak terduga dan layar putih saat jaringan bermasalah
🔄Data Lokal & Remote BerdampinganDi Balik Satu Kontrak
  1. 01Penyimpanan Lokal: Riwayat pencarian pelabuhan dan master data offline tersimpan cepat di SQLite lokal.
  2. 02Remote Live API: Jadwal kapal, sisa kuota dek, dan tarif dinamis selalu ditarik langsung dari live API.
  3. 03Abstraksi Sempurna: BLoC tidak perlu tahu asal data; perubahan skema database terisolasi di repository.
✨ UI & BLoC tetap ringkas tanpa perlu logika if-else cache yang berserakan
💡

Pelajaran Kunci: Menstandarisasi alur kegagalan lewat functional result dan menyembunyikan sumber data di balik satu kontrak repository membuat pengujian dan pemeliharaan kode menjadi sangat mudah diprediksi.

Struktur Folder dan Trade-off: Menjaga Skalabilitas Tim Jangka Panjang

Di tingkat struktur direktori proyek, kami membagi seluruh kode ke dalam dua direktori besar: core/ dan features/. Direktori core/ menampung segala hal yang menjadi infrastruktur bersama dan dipakai lintas layanan—mulai dari router bridge, tema desain, network client Dio, hingga service locator dependency injection. Sementara itu, setiap kemampuan bisnis independen ditaruh di dalam folder features/<nama_fitur>, lengkap dengan subfolder presentation/, domain/, dan data/-nya masing-masing.

Aturan pembagian kami sangat sederhana namun tidak boleh dilanggar: jika sesuatu dipakai oleh lebih dari satu fitur, ia berhak masuk ke core; tetapi jika ia hanya milik satu domain bisnis, ia wajib tinggal di dalam fiturnya. Aturan tegas ini berhasil menyelamatkan tim dari jebakan klasik di mana folder common perlahan berubah menjadi "tempat pembuangan sampah" bagi kode yang malas dirapikan.

Tentu ada harga yang harus dibayar dari arsitektur ini. Perubahan kecil kini menuntut sentuhan di beberapa file terpisah: model, entity, use case, repository interface, implementasi, dan BLoC event-state. Untuk prototipe sederhana, pola ini memang terasa terlalu bertele-tele (ceremonial). Namun seiring bertambahnya jumlah fitur dan developer di tim mobile kami, investasi struktur ini terbayar lunas lewat proses onboarding yang mulus, kemudahan refactoring, dan kebebasan dari regresi tak terduga.

Struktur Folder & Evaluasi Trade-off
Codebase Organization
📁Struktur: Folder core/Fondasi Bersama

Router, theme tokens, HTTP client, service locator, dan shared widgets yang dipakai lintas fitur.

📦Struktur: Folder features/Modul Terisolasi

Satu folder mandiri per domain bisnis (kapal, hotel, pesawat, promo) lengkap dengan 3 lapisannya.

⚖️Trade-off: Tambahan BoilerplateKonsekuensi Diterima

Perubahan kecil menyentuh beberapa file (model, entity, use case, repo, BLoC). Terasa berlebih di app kecil.

🚀Manfaat: Skalabilitas Jangka PanjangImbal Hasil Tinggi

Onboarding cepat, isolasi error sempurna, zero side-effects saat menambah fitur baru atau multi-tim.

💡

Pelajaran Kunci: Aturan pemisahan 'jika dipakai bersama masuk core, jika spesifik satu fitur tinggal di fiturnya' adalah vaksin paling efektif mencegah folder bersama berubah menjadi tempat sampah teknis.

🏗️ Modularity+🧪 Testability+👥 Team Scale

Apa yang Bisa Dibawa dari Cerita Ini

Jika diringkas, ada beberapa prinsip arsitektur penting yang bisa langsung kamu terapkan pada proyek aplikasimu sendiri:

Pisahkan apa yang dipakai bersama dari apa yang milik satu fitur: Aturan folder core vs features yang tegas mencegah kode utilitas berkembang liar tanpa pemilik yang jelas.

Jaga UI agar tidak pernah tahu soal network: Jika widget UI hanya mendengarkan state BLoC dan dilarang menyentuh HTTP client, mengganti API atau menguji logika bisnis menjadi sangat mudah.

Seragamkan jalur sukses dan gagal lewat functional result: Mengembalikan tipe data yang eksplisit untuk keberhasilan dan kegagalan menghilangkan bug akibat exception mentah yang bocor ke layar.

Gunakan satu fitur sebagai referensi dokumentasi hidup: Fitur pertama yang dimigrasi (seperti Kapal di Holigo) berfungsi sebagai cetak biru konkret, sehingga anggota tim baru cukup meniru pola yang sudah terbukti.

Penutup

Pola tiga lapisan Clean Architecture bukanlah sihir atau dogma teoritis yang kaku. Ia hanyalah sebuah cara sistematis untuk memastikan bahwa pertanyaan "di mana kode ini seharusnya berada?" selalu memiliki jawaban yang konsisten dan terprediksi.

Di Part 3 berikutnya, kita akan membedah lebih dalam jantung manajemen state aplikasi: bagaimana kami mengorkestrasi BLoC dan menyusun dependency injection menggunakan GetIt/Injectable, serta trade-off performa yang menyertainya.

Terima kasih telah membaca cerita saya :)

P.S. Seri ini ditulis di level keputusan dan arsitektur perangkat lunak, tanpa membuka kode atau data internal perusahaan. Jika ada pertanyaan seputar pemisahan lapisan di Flutter atau kamu punya pengalaman serupa, mari berdiskusi!

Ditulis oleh Muchamad Buchori · Senior Mobile Engineer
Artikel lainnya→