Skip to content

Bab 33 — Clean Architecture & MVVM

Bab 22 sudah membahas arsitektur berlapis dengan BLoC. Bab ini melihat masalah yang sama dari sudut lain: MVVM dengan Riverpod, pendekatan yang lebih ringan tetapi menerapkan prinsip yang sama.

Kita juga akan membahas kenapa memisahkan lapisan itu sepadan — dan kapan ia justru berlebihan.

MVVM: tiga peran

MVVM membagi kode menjadi tiga peran yang jelas.

   ┌─────────────────────────────────────────────────────────┐
   │  VIEW  (widget)                                         │
   │  Menggambar state, meneruskan aksi pengguna             │
   │  Tidak tahu dari mana data berasal                      │
   └────────────────────────┬────────────────────────────────┘
                            │  watch state ↑  panggil metode ↓
   ┌────────────────────────▼────────────────────────────────┐
   │  VIEWMODEL  (Notifier / Cubit)                          │
   │  Menyiapkan data untuk tampilan, menyimpan state UI     │
   │  Tidak tahu apa pun tentang widget                      │
   └────────────────────────┬────────────────────────────────┘

   ┌────────────────────────▼────────────────────────────────┐
   │  MODEL  (repository + data source + entity)             │
   │  Data dan aturan bisnis                                 │
   │  Tidak tahu apa pun tentang UI maupun ViewModel         │
   └─────────────────────────────────────────────────────────┘

Perbedaannya dengan arsitektur BLoC di Bab 22 lebih pada penamaan daripada substansi. "ViewModel" dan "Business Logic Layer" mengisi peran yang sama.

Kenapa disebut ViewModel

Namanya menjelaskan tugasnya: ia adalah model untuk view. Ia mengubah data mentah dari repository menjadi bentuk yang siap ditampilkan.

Contohnya, repository mengembalikan suhu dalam Kelvin dan kode kondisi cuaca. ViewModel mengubahnya menjadi "24°C" dan "Berawan" — bukan tugas repository, bukan tugas widget.

Kasus: layar cuaca

Kita akan membangun layar cuaca lengkap dengan Riverpod, dari lapisan terbawah ke atas.

Entity: bentuk data domain

dart
// lib/domain/entities/cuaca.dart
class Cuaca {
  const Cuaca({
    required this.kota,
    required this.suhuCelsius,
    required this.terasaSeperti,
    required this.kondisi,
    required this.kodeIkon,
    required this.kelembapan,
    required this.kecepatanAngin,
    required this.diperbaruiPada,
  });

  final String kota;
  final double suhuCelsius;
  final double terasaSeperti;
  final String kondisi;
  final String kodeIkon;
  final int kelembapan;
  final double kecepatanAngin;
  final DateTime diperbaruiPada;

  /// Properti turunan yang menjadi bagian dari domain,
  /// bukan sekadar pemformatan tampilan.
  bool get panasEkstrem => suhuCelsius >= 35;
  bool get hujan => kondisi.toLowerCase().contains('hujan');
}

Perhatikan bahwa entity ini tidak mengenal JSON. Ia adalah representasi murni konsep "cuaca" di aplikasimu, terlepas dari dari mana datanya berasal.

Ini pemisahan yang penting: kalau suatu saat kamu ganti dari OpenWeather ke penyedia lain dengan format JSON berbeda, entity ini tidak berubah — dan seluruh kode yang memakainya juga tidak.

DTO: bentuk data dari API

dart
// lib/data/dto/cuaca_dto.dart
import '../../domain/entities/cuaca.dart';

/// Data Transfer Object — cerminan langsung struktur JSON API.
class CuacaDto {
  const CuacaDto({
    required this.nama,
    required this.suhuKelvin,
    required this.terasaKelvin,
    required this.deskripsi,
    required this.ikon,
    required this.kelembapan,
    required this.angin,
  });

  final String nama;
  final double suhuKelvin;
  final double terasaKelvin;
  final String deskripsi;
  final String ikon;
  final int kelembapan;
  final double angin;

  factory CuacaDto.dariJson(Map<String, dynamic> json) {
    return CuacaDto(
      nama: json['name'] as String,
      suhuKelvin: (json['main']['temp'] as num).toDouble(),
      terasaKelvin: (json['main']['feels_like'] as num).toDouble(),
      deskripsi: json['weather'][0]['description'] as String,
      ikon: json['weather'][0]['icon'] as String,
      kelembapan: (json['main']['humidity'] as num).toInt(),
      angin: (json['wind']['speed'] as num).toDouble(),
    );
  }

  /// Konversi ke entity domain — di sinilah Kelvin menjadi Celsius.
  Cuaca keEntity() {
    return Cuaca(
      kota: nama,
      suhuCelsius: suhuKelvin - 273.15,
      terasaSeperti: terasaKelvin - 273.15,
      kondisi: _kapitalkan(deskripsi),
      kodeIkon: ikon,
      kelembapan: kelembapan,
      kecepatanAngin: angin,
      diperbaruiPada: DateTime.now(),
    );
  }

  String _kapitalkan(String teks) {
    if (teks.isEmpty) return teks;
    return teks[0].toUpperCase() + teks.substring(1);
  }
}

Kenapa DTO terpisah dari entity

Godaan untuk memakai satu kelas saja itu besar. Tetapi pemisahan ini terbayar ketika:

  • Format API berubah. Hanya DTO yang perlu diperbarui.
  • Kamu punya beberapa sumber. API dan cache lokal punya format berbeda, tetapi keduanya menghasilkan entity yang sama.
  • API mengembalikan data yang tidak kamu butuhkan. Entity hanya berisi yang relevan.

Untuk aplikasi kecil, satu kelas memang cukup. Untuk aplikasi yang akan tumbuh, pemisahan ini menghemat banyak kesulitan.

Data source

dart
// lib/data/datasources/cuaca_api.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../dto/cuaca_dto.dart';

sealed class KegagalanJaringan implements Exception {
  const KegagalanJaringan(this.pesan);
  final String pesan;

  @override
  String toString() => pesan;
}

final class TidakAdaKoneksi extends KegagalanJaringan {
  const TidakAdaKoneksi() : super('Tidak ada koneksi internet.');
}

final class WaktuHabis extends KegagalanJaringan {
  const WaktuHabis() : super('Server terlalu lama merespons.');
}

final class TidakDitemukan extends KegagalanJaringan {
  const TidakDitemukan(String kota) : super('Kota "$kota" tidak ditemukan.');
}

final class KegagalanServer extends KegagalanJaringan {
  const KegagalanServer(int kode) : super('Server bermasalah ($kode).');
}

class CuacaApi {
  CuacaApi({required this.kunciApi, http.Client? klien})
      : _klien = klien ?? http.Client();

  final String kunciApi;
  final http.Client _klien;

  static const _host = 'api.openweathermap.org';
  static const _batasWaktu = Duration(seconds: 10);

  Future<CuacaDto> ambil(String kota) async {
    final url = Uri.https(_host, '/data/2.5/weather', {
      'q': kota,
      'appid': kunciApi,
      'lang': 'id',
    });

    try {
      final respons = await _klien.get(url).timeout(_batasWaktu);

      if (respons.statusCode == 404) throw TidakDitemukan(kota);
      if (respons.statusCode >= 400) {
        throw KegagalanServer(respons.statusCode);
      }

      return CuacaDto.dariJson(
        json.decode(respons.body) as Map<String, dynamic>,
      );
    } on SocketException {
      throw const TidakAdaKoneksi();
    } on TimeoutException {
      throw const WaktuHabis();
    }
  }

  void tutup() => _klien.close();
}

Memakai sealed class untuk jenis kegagalan memberi keuntungan yang sama seperti pada state di Bab 20: switch yang diperiksa kelengkapannya oleh compiler.

Repository

dart
// lib/data/repositories/cuaca_repository_impl.dart
import '../../domain/entities/cuaca.dart';
import '../../domain/repositories/cuaca_repository.dart';
import '../datasources/cuaca_api.dart';
import '../datasources/cuaca_cache.dart';

class CuacaRepositoryImpl implements CuacaRepository {
  CuacaRepositoryImpl({required CuacaApi api, required CuacaCache cache})
      : _api = api,
        _cache = cache;

  final CuacaApi _api;
  final CuacaCache _cache;

  static const _umurCache = Duration(minutes: 15);

  @override
  Future<Cuaca> ambilCuaca(String kota, {bool paksaSegarkan = false}) async {
    final kunci = kota.toLowerCase().trim();

    // Kembalikan dari cache kalau masih segar
    if (!paksaSegarkan) {
      final tersimpan = await _cache.baca(kunci);
      if (tersimpan != null &&
          DateTime.now().difference(tersimpan.diperbaruiPada) < _umurCache) {
        return tersimpan;
      }
    }

    try {
      final dto = await _api.ambil(kota);
      final entity = dto.keEntity();
      await _cache.tulis(kunci, entity);
      return entity;
    } on KegagalanJaringan {
      // Jaringan gagal — pakai cache lama kalau ada
      final tersimpan = await _cache.baca(kunci);
      if (tersimpan != null) return tersimpan;
      rethrow;
    }
  }

  @override
  Future<List<String>> riwayatPencarian() => _cache.daftarKota();
}

Perhatikan implements CuacaRepository — antarmuka abstrak yang didefinisikan di lapisan domain:

dart
// lib/domain/repositories/cuaca_repository.dart
import '../entities/cuaca.dart';

abstract interface class CuacaRepository {
  Future<Cuaca> ambilCuaca(String kota, {bool paksaSegarkan = false});
  Future<List<String>> riwayatPencarian();
}

Kenapa antarmuka abstrak

Ini yang membuat arah ketergantungan terbalik. Tanpa antarmuka, ViewModel bergantung pada implementasi konkret di lapisan data. Dengan antarmuka, keduanya bergantung pada kontrak yang dimiliki lapisan domain.

Manfaat praktisnya: kamu bisa membuat CuacaRepositoryPalsu untuk pengujian atau demo, tanpa ViewModel tahu bedanya.

Untuk aplikasi kecil, ini bisa terasa berlebihan — dan memang sering begitu. Lihat bagian "Kapan ini berlebihan" di akhir bab.

ViewModel dengan Riverpod

dart
// lib/presentation/viewmodels/cuaca_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/datasources/cuaca_api.dart';
import '../../domain/entities/cuaca.dart';
import '../../domain/repositories/cuaca_repository.dart';

/// State yang siap ditampilkan — sudah diformat.
class CuacaTampilan {
  const CuacaTampilan({
    required this.kota,
    required this.suhu,
    required this.terasaSeperti,
    required this.kondisi,
    required this.urlIkon,
    required this.kelembapan,
    required this.angin,
    required this.diperbarui,
    required this.peringatan,
  });

  final String kota;
  final String suhu;
  final String terasaSeperti;
  final String kondisi;
  final String urlIkon;
  final String kelembapan;
  final String angin;
  final String diperbarui;
  final String? peringatan;

  /// Di sinilah entity domain diubah menjadi teks siap tampil.
  factory CuacaTampilan.dariEntity(Cuaca c) {
    return CuacaTampilan(
      kota: c.kota,
      suhu: '${c.suhuCelsius.round()}°',
      terasaSeperti: 'Terasa seperti ${c.terasaSeperti.round()}°',
      kondisi: c.kondisi,
      urlIkon: 'https://openweathermap.org/img/wn/${c.kodeIkon}@2x.png',
      kelembapan: '${c.kelembapan}%',
      angin: '${c.kecepatanAngin.toStringAsFixed(1)} m/s',
      diperbarui: _waktuRelatif(c.diperbaruiPada),
      peringatan: c.panasEkstrem
          ? 'Suhu sangat tinggi. Perbanyak minum air.'
          : c.hujan
              ? 'Kemungkinan hujan. Bawa payung.'
              : null,
    );
  }

  static String _waktuRelatif(DateTime waktu) {
    final selisih = DateTime.now().difference(waktu);
    if (selisih.inMinutes < 1) return 'Baru saja';
    if (selisih.inMinutes < 60) return '${selisih.inMinutes} menit lalu';
    if (selisih.inHours < 24) return '${selisih.inHours} jam lalu';
    return '${selisih.inDays} hari lalu';
  }
}

class CuacaViewModel extends AsyncNotifier<CuacaTampilan?> {
  @override
  Future<CuacaTampilan?> build() async {
    // State awal: belum ada pencarian
    return null;
  }

  Future<void> cari(String kota) async {
    if (kota.trim().isEmpty) {
      state = AsyncError(
        const KotaKosong(),
        StackTrace.current,
      );
      return;
    }

    state = const AsyncLoading();

    state = await AsyncValue.guard(() async {
      final repo = ref.read(cuacaRepositoryProvider);
      final entity = await repo.ambilCuaca(kota);
      return CuacaTampilan.dariEntity(entity);
    });
  }

  Future<void> segarkan() async {
    final sekarang = state.valueOrNull;
    if (sekarang == null) return;

    state = await AsyncValue.guard(() async {
      final repo = ref.read(cuacaRepositoryProvider);
      final entity = await repo.ambilCuaca(
        sekarang.kota,
        paksaSegarkan: true,
      );
      return CuacaTampilan.dariEntity(entity);
    });
  }
}

class KotaKosong implements Exception {
  const KotaKosong();

  @override
  String toString() => 'Masukkan nama kota terlebih dahulu.';
}

final cuacaViewModelProvider =
    AsyncNotifierProvider<CuacaViewModel, CuacaTampilan?>(
  CuacaViewModel.new,
);

AsyncValue.guard adalah helper yang berguna: ia menjalankan fungsi dan otomatis membungkus hasilnya menjadi AsyncData atau AsyncError. Tanpa itu kamu menulis try/catch manual di setiap metode.

Perhatikan juga bahwa seluruh pemformatan ada di ViewModel, bukan di widget. Widget hanya menampilkan string yang sudah jadi. Ini membuat logika pemformatan bisa diuji tanpa merender apa pun.

Menyusun ketergantungan

dart
// lib/providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'data/datasources/cuaca_api.dart';
import 'data/datasources/cuaca_cache.dart';
import 'data/repositories/cuaca_repository_impl.dart';
import 'domain/repositories/cuaca_repository.dart';

const _kunciApi = String.fromEnvironment('OPENWEATHER_API_KEY');

final cuacaApiProvider = Provider<CuacaApi>((ref) {
  final api = CuacaApi(kunciApi: _kunciApi);
  ref.onDispose(api.tutup);
  return api;
});

final cuacaCacheProvider = Provider<CuacaCache>((ref) {
  return CuacaCache();
});

/// Tipe kembaliannya adalah ANTARMUKA, bukan implementasi.
/// Ini yang memungkinkan penggantian saat pengujian.
final cuacaRepositoryProvider = Provider<CuacaRepository>((ref) {
  return CuacaRepositoryImpl(
    api: ref.watch(cuacaApiProvider),
    cache: ref.watch(cuacaCacheProvider),
  );
});

ref.onDispose(api.tutup) memastikan http.Client ditutup ketika provider dibuang — detail kecil yang mencegah kebocoran koneksi.

View

dart
// lib/presentation/screens/layar_cuaca.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../viewmodels/cuaca_viewmodel.dart';

class LayarCuaca extends ConsumerStatefulWidget {
  const LayarCuaca({super.key});

  @override
  ConsumerState<LayarCuaca> createState() => _LayarCuacaState();
}

class _LayarCuacaState extends ConsumerState<LayarCuaca> {
  final _pengendali = TextEditingController();

  @override
  void dispose() {
    _pengendali.dispose();
    super.dispose();
  }

  void _cari() {
    ref.read(cuacaViewModelProvider.notifier).cari(_pengendali.text);
    FocusScope.of(context).unfocus();
  }

  @override
  Widget build(BuildContext context) {
    final asyncCuaca = ref.watch(cuacaViewModelProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Cuaca')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _pengendali,
              textInputAction: TextInputAction.search,
              onSubmitted: (_) => _cari(),
              decoration: InputDecoration(
                hintText: 'Cari kota...',
                prefixIcon: const Icon(Icons.search),
                border: const OutlineInputBorder(),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.arrow_forward),
                  onPressed: _cari,
                ),
              ),
            ),
          ),

          Expanded(
            child: asyncCuaca.when(
              loading: () => const Center(child: CircularProgressIndicator()),

              error: (kesalahan, _) => Center(
                child: Padding(
                  padding: const EdgeInsets.all(24),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      const Icon(Icons.cloud_off, size: 48),
                      const SizedBox(height: 12),
                      Text('$kesalahan', textAlign: TextAlign.center),
                      const SizedBox(height: 16),
                      ElevatedButton(
                        onPressed: _cari,
                        child: const Text('Coba Lagi'),
                      ),
                    ],
                  ),
                ),
              ),

              data: (cuaca) {
                if (cuaca == null) {
                  return const Center(
                    child: Text('Cari kota untuk melihat cuacanya.'),
                  );
                }
                return _KartuCuaca(cuaca: cuaca);
              },
            ),
          ),
        ],
      ),
    );
  }
}

class _KartuCuaca extends ConsumerWidget {
  const _KartuCuaca({required this.cuaca});

  final CuacaTampilan cuaca;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final teks = Theme.of(context).textTheme;

    return RefreshIndicator(
      onRefresh: () =>
          ref.read(cuacaViewModelProvider.notifier).segarkan(),
      child: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          Text(cuaca.kota, style: teks.headlineMedium, textAlign: TextAlign.center),
          const SizedBox(height: 8),
          Image.network(cuaca.urlIkon, height: 100),
          Text(
            cuaca.suhu,
            style: teks.displayLarge,
            textAlign: TextAlign.center,
          ),
          Text(
            cuaca.kondisi,
            style: teks.titleMedium,
            textAlign: TextAlign.center,
          ),
          Text(
            cuaca.terasaSeperti,
            style: teks.bodySmall,
            textAlign: TextAlign.center,
          ),

          if (cuaca.peringatan != null) ...[
            const SizedBox(height: 16),
            Card(
              color: Colors.amber.shade100,
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Row(
                  children: [
                    const Icon(Icons.warning_amber, color: Colors.orange),
                    const SizedBox(width: 12),
                    Expanded(child: Text(cuaca.peringatan!)),
                  ],
                ),
              ),
            ),
          ],

          const SizedBox(height: 24),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              _Statistik(
                ikon: Icons.water_drop,
                label: 'Kelembapan',
                nilai: cuaca.kelembapan,
              ),
              _Statistik(
                ikon: Icons.air,
                label: 'Angin',
                nilai: cuaca.angin,
              ),
            ],
          ),

          const SizedBox(height: 24),
          Text(
            'Diperbarui ${cuaca.diperbarui}',
            style: teks.bodySmall,
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }
}

class _Statistik extends StatelessWidget {
  const _Statistik({
    required this.ikon,
    required this.label,
    required this.nilai,
  });

  final IconData ikon;
  final String label;
  final String nilai;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Icon(ikon, size: 28),
        const SizedBox(height: 4),
        Text(nilai, style: Theme.of(context).textTheme.titleMedium),
        Text(label, style: Theme.of(context).textTheme.bodySmall),
      ],
    );
  }
}

Perhatikan bahwa widget ini tidak melakukan satu pun perhitungan atau pemformatan. Tidak ada .round(), tidak ada konversi suhu, tidak ada logika "kapan menampilkan peringatan". Semuanya sudah selesai di ViewModel.

Struktur folder

lib/
├── main.dart
├── providers.dart                 ← perakitan ketergantungan

├── domain/                        ← lapisan paling dalam, tidak bergantung apa pun
│   ├── entities/
│   │   └── cuaca.dart
│   └── repositories/
│       └── cuaca_repository.dart  ← ANTARMUKA abstrak

├── data/                          ← implementasi lapisan domain
│   ├── dto/
│   │   └── cuaca_dto.dart
│   ├── datasources/
│   │   ├── cuaca_api.dart
│   │   └── cuaca_cache.dart
│   └── repositories/
│       └── cuaca_repository_impl.dart

└── presentation/
    ├── viewmodels/
    │   └── cuaca_viewmodel.dart
    ├── screens/
    │   └── layar_cuaca.dart
    └── widgets/
        └── kartu_statistik.dart

Arah ketergantungannya:

Perhatikan bahwa domain tidak menunjuk ke mana pun. Ia tidak tahu tentang HTTP, tidak tahu tentang Flutter, tidak tahu tentang Riverpod. Itulah yang membuatnya stabil — perubahan di lapisan lain tidak menyentuhnya.

Menguji arsitektur ini

Manfaat pemisahan lapisan terasa paling nyata saat menulis pengujian.

dart
// test/viewmodels/cuaca_viewmodel_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';

class MockCuacaRepository extends Mock implements CuacaRepository {}

void main() {
  late MockCuacaRepository repo;

  final entityContoh = Cuaca(
    kota: 'Bandung',
    suhuCelsius: 24.4,
    terasaSeperti: 25.0,
    kondisi: 'Berawan',
    kodeIkon: '02d',
    kelembapan: 78,
    kecepatanAngin: 3.2,
    diperbaruiPada: DateTime.now(),
  );

  setUp(() {
    repo = MockCuacaRepository();
  });

  ProviderContainer wadah() {
    final c = ProviderContainer(
      overrides: [
        // Ganti implementasi asli dengan mock
        cuacaRepositoryProvider.overrideWithValue(repo),
      ],
    );
    addTearDown(c.dispose);
    return c;
  }

  test('memformat suhu dengan pembulatan', () async {
    when(() => repo.ambilCuaca(any(), paksaSegarkan: any(named: 'paksaSegarkan')))
        .thenAnswer((_) async => entityContoh);

    final c = wadah();
    await c.read(cuacaViewModelProvider.notifier).cari('Bandung');

    final hasil = c.read(cuacaViewModelProvider).valueOrNull!;
    expect(hasil.suhu, '24°');            // 24.4 dibulatkan
    expect(hasil.kelembapan, '78%');
  });

  test('menampilkan peringatan saat suhu ekstrem', () async {
    when(() => repo.ambilCuaca(any(), paksaSegarkan: any(named: 'paksaSegarkan')))
        .thenAnswer(
      (_) async => Cuaca(
        kota: 'Surabaya',
        suhuCelsius: 37,
        terasaSeperti: 40,
        kondisi: 'Cerah',
        kodeIkon: '01d',
        kelembapan: 60,
        kecepatanAngin: 2,
        diperbaruiPada: DateTime.now(),
      ),
    );

    final c = wadah();
    await c.read(cuacaViewModelProvider.notifier).cari('Surabaya');

    final hasil = c.read(cuacaViewModelProvider).valueOrNull!;
    expect(hasil.peringatan, contains('Suhu sangat tinggi'));
  });

  test('menolak kota kosong tanpa memanggil repository', () async {
    final c = wadah();
    await c.read(cuacaViewModelProvider.notifier).cari('   ');

    expect(c.read(cuacaViewModelProvider), isA<AsyncError>());
    verifyNever(
      () => repo.ambilCuaca(any(), paksaSegarkan: any(named: 'paksaSegarkan')),
    );
  });
}

overrideWithValue adalah kekuatan besar Riverpod: kamu mengganti satu provider dengan mock, dan seluruh rantai ketergantungan mengikutinya. Tidak perlu membangun widget tree sama sekali.

Kapan ini berlebihan

Ini bagian yang jujur, dan penting.

Arsitektur di atas punya empat lapisan dan sebuah antarmuka abstrak untuk satu layar cuaca. Untuk banyak aplikasi, itu terlalu banyak.

Sederhanakan kalau:

  • Aplikasimu punya kurang dari sepuluh layar.
  • Kamu bekerja sendiri.
  • Sumber datanya hanya satu dan tidak akan berubah.
  • Kamu tidak menulis pengujian unit yang serius.

Versi sederhananya cukup dua lapisan:

lib/
├── models/          ← model dengan fromJson bawaan
├── services/        ← langsung memanggil API/Firestore
├── providers/       ← Notifier
└── screens/

Pertahankan pemisahan penuh kalau:

  • Tim kamu lebih dari tiga orang.
  • Aplikasimu akan dikembangkan selama bertahun-tahun.
  • Ada beberapa sumber data yang harus digabungkan.
  • Kamu punya target cakupan pengujian.
  • Backend-nya mungkin berubah.

Tanda kamu butuh lebih banyak struktur

Jangan menerapkan arsitektur berlapis karena "katanya benar". Terapkan ketika kamu merasakan gejalanya:

  • Mengubah satu format API memaksamu menyentuh sepuluh berkas.
  • Kamu tidak bisa menguji logika tanpa membangun widget.
  • Dua orang mengerjakan fitur berbeda dan terus bertabrakan di berkas yang sama.
  • Kamu takut mengubah sesuatu karena tidak tahu apa yang akan rusak.

Arsitektur adalah obat untuk gejala-gejala itu. Meminum obat sebelum sakit biasanya tidak membantu.

Perbandingan pendekatan

AspekMVVM + RiverpodBLoC berlapis
Jumlah kodeLebih sedikitLebih banyak
Kurva belajarLebih landaiLebih curam
Jejak perubahanTidak ada bawaanBlocObserver bawaan
Cocok untukTim kecil, proyek cepatTim besar, logika kompleks
Penanganan asyncAsyncValue sangat rapiManual lewat state
Konsistensi timBergantung disiplinDipaksakan oleh pola

Keduanya menerapkan prinsip yang sama; yang berbeda adalah seberapa ketat pola itu dipaksakan.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Refaktor Layar Kurs — ⭐⭐ · 45–60 menit

Tantangan: Kode di bawah ini menaruh semuanya di dalam widget: HTTP, penguraian, pemformatan, dan penanganan error. Refaktor ke MVVM dengan Riverpod — entity, DTO, antarmuka repository, dan ViewModel — tanpa mengubah tampilannya.

dart
class LayarKurs extends StatefulWidget {
  const LayarKurs({super.key});
  @override
  State<LayarKurs> createState() => _LayarKursState();
}

class _LayarKursState extends State<LayarKurs> {
  bool _memuat = true;
  String? _error;
  double? _kurs;
  DateTime? _diperbarui;

  @override
  void initState() {
    super.initState();
    _muat();
  }

  Future<void> _muat() async {
    try {
      final res = await http.get(Uri.parse('https://api.contoh.id/kurs/usd-idr'));
      final json = jsonDecode(res.body) as Map<String, dynamic>;
      setState(() {
        _kurs = (json['rate'] as num).toDouble();
        _diperbarui = DateTime.parse(json['updated_at'] as String);
        _memuat = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _memuat = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_memuat) return const Center(child: CircularProgressIndicator());
    if (_error != null) return Center(child: Text(_error!));
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('Rp ${_kurs!.toStringAsFixed(0)}'),
          Text('Diperbarui ${_diperbarui!.hour}:${_diperbarui!.minute}'),
        ],
      ),
    );
  }
}

Kriteria selesai:

  • Ada entity yang tidak mengenal JSON sama sekali, dan DTO terpisah yang mencerminkan struktur API.
  • Repository dideklarasikan sebagai antarmuka abstrak di lapisan domain, dan implementasinya berada di lapisan data.
  • Lapisan domain tidak mengimpor http, tidak mengimpor Flutter, dan tidak mengimpor Riverpod.
  • Seluruh pemformatan — rupiah dan jam — berada di ViewModel. Widget hanya menampilkan string yang sudah jadi.
  • ViewModel memakai AsyncValue.guard, dan widget menampilkan ketiga keadaan lewat .when().
  • Ada satu pengujian yang mengganti repository dengan mock lewat overrideWithValue, tanpa membangun widget.

Petunjuk: Pertanyaan yang membuat pemisahan entity dan DTO terasa masuk akal: apa yang terjadi kalau penyedia API mengganti nama updated_at menjadi last_update? Dengan DTO terpisah, perubahannya berhenti di satu berkas. Tanpa itu, ia merambat sampai ke widget. Perhatikan juga bagian yang menyusun jam pada kode di atas — ia akan menghasilkan "9:5" untuk pukul 09.05, dan itu bug pemformatan yang justru mudah diperbaiki begitu pemformatan punya rumah sendiri di ViewModel dan bisa diuji. Untuk arah ketergantungan, aturan praktisnya: buka setiap berkas di folder domain dan lihat baris impornya. Kalau ada yang menunjuk keluar, ada yang salah.

Variasi 2: Menguji Seluruh Lapisan — ⭐⭐⭐ · 45–60 menit

Tantangan: Ambil hasil refaktor Variasi 1, lalu tulis pengujian untuk setiap lapisannya: entity dan aturan domainnya, repository dengan sumber palsu, dan ViewModel dengan repository yang ditimpa. Semuanya tanpa membangun satu widget pun.

Kriteria selesai:

  • Pengujian domain berjalan tanpa satu pun impor Flutter maupun Riverpod.
  • Repository diuji dengan klien HTTP palsu, termasuk kasus JSON yang bentuknya tidak sesuai.
  • ViewModel diuji dengan overrideWithValue, dan seluruh rantai ketergantungannya ikut mengikuti.
  • Ada pengujian untuk pemformatan — misalnya bahwa pukul 09.05 tidak ditampilkan sebagai "9:5".

Petunjuk: Kriteria pertama adalah hadiah dari arsitektur ini: pengujian domain berjalan dalam milidetik karena tidak ada yang perlu dinyalakan. Kalau pengujian domainmu ternyata butuh TestWidgetsFlutterBinding, berarti ada sesuatu dari lapisan luar yang bocor ke dalam. Kriteria keempat menunjukkan alasan praktis memindahkan pemformatan ke ViewModel — bug pemformatan yang tadinya hanya bisa dilihat dengan mata sekarang bisa ditangkap otomatis.

Variasi 3: Kapan Ini Berlebihan — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Ambil satu aplikasi kecil yang benar-benar sederhana — satu layar, satu sumber data, tanpa aturan bisnis — lalu bangun ia dengan arsitektur berlapis penuh. Hitung biayanya secara jujur, lalu bangun ulang dengan pendekatan paling sederhana yang masih rapi.

Kriteria selesai:

  • Kedua versi berjalan dan berperilaku identik.
  • Kamu mencatat jumlah berkas dan baris kode masing-masing.
  • Kamu menyebutkan satu perubahan kebutuhan yang akan membuat versi berlapis menang, dan satu yang membuatnya tetap kalah.
  • Kamu menulis aturan pribadi tentang kapan mulai menerapkan lapisan, dalam satu kalimat.

Petunjuk: Bab ini menegaskan bahwa arsitektur adalah obat untuk gejala, dan latihan ini adalah cara merasakan sendiri apa artinya. Gejala yang biasanya menandakan sudah waktunya: sumber data bertambah, aturan bisnis mulai muncul di dua tempat, tim bertambah orang, atau kamu mulai takut mengubah sesuatu karena tidak tahu apa yang akan rusak. Tanpa satu pun gejala itu, lapisan tambahan hanya menambah berkas untuk dibuka setiap kali kamu mengubah satu hal kecil.

Ikhtisar

  • MVVM membagi kode menjadi View (widget), ViewModel (menyiapkan data untuk tampilan), dan Model (data + aturan bisnis).
  • Entity adalah representasi domain yang tidak mengenal JSON; DTO adalah cerminan struktur API. Memisahkannya melindungi aplikasimu dari perubahan format API.
  • Antarmuka abstrak repository membalik arah ketergantungan — lapisan data dan presentation sama-sama bergantung pada kontrak di domain.
  • Lapisan domain tidak boleh menunjuk ke mana pun — tidak ke HTTP, tidak ke Flutter, tidak ke Riverpod.
  • Seluruh pemformatan ada di ViewModel, bukan di widget. Widget hanya menampilkan string yang sudah jadi.
  • AsyncValue.guard menghilangkan try/catch berulang di ViewModel.
  • ref.onDispose untuk membersihkan sumber daya seperti http.Client.
  • overrideWithValue memungkinkan mengganti satu provider dengan mock, dan seluruh rantai ketergantungan mengikuti — pengujian tanpa membangun widget.
  • Arsitektur berlapis penuh sering berlebihan untuk aplikasi kecil. Terapkan ketika kamu merasakan gejalanya, bukan karena "katanya benar".
  • MVVM+Riverpod dan BLoC berlapis menerapkan prinsip yang sama; bedanya seberapa ketat polanya dipaksakan.

Bagian IV selesai. Aplikasimu kini punya backend yang lengkap.

Berikutnya: Bab 34 — AI Chatbot dengan Gemini, membuka Bagian V.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/14_riverpod-clean-architecture-and-http-networking.md (4 video: MVVM Architecture, Riverpod Introduction, Building the Weather Screen Part I & II). Lihat PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.