Final-08-Evan-Jap-Bambang-W.../ooad-docs/DESIGN_PATTERNS_NOTES.md

36 KiB
Raw Permalink Blame History

Design Patterns — WalkGuide

Flutter × Spring Boot × In-Device AI Dokumentasi 7 Design Patterns (GoF) · Wajib ≥4, min. 1 per kategori


Daftar Isi

  1. Gambaran Umum
  2. Creational Patterns
  3. Structural Patterns
  4. Behavioral Patterns
  5. Ringkasan Matriks

Gambaran Umum

WalkGuide mengimplementasikan 7 design patterns GoF yang tersebar di seluruh lapisan sistem — Flutter (mobile), Spring Boot (backend), dan lapisan AI on-device. Setiap pattern dipilih berdasarkan kebutuhan nyata arsitektur, bukan sekadar pemenuhan syarat akademis.

# Pattern Kategori Layer
1 Builder Creational Backend (Spring Boot)
2 Singleton Creational Flutter
3 Facade Structural Flutter + Backend
4 Repository (Proxy) Structural Flutter
5 Observer Behavioral Flutter + Backend
6 Strategy Behavioral Flutter + Backend
7 Chain of Responsibility Behavioral Flutter + Backend

Creational Patterns

Creational patterns mengatur cara objek diciptakan, memisahkan logika konstruksi dari penggunaan objek tersebut.


1. Builder Pattern

Lokasi: entity/User.java, service/FcmService.java, service/AuthService.java

Masalah yang Diselesaikan

Entitas User memiliki banyak field opsional — displayName, uniqueUserId, fcmToken — yang tidak selalu diisi. Tanpa Builder, constructor akan memiliki terlalu banyak parameter sehingga kode sulit dibaca dan rawan salah urutan argumen.

Solusi

Lombok @Builder pada User.java membangkitkan builder class secara otomatis. FcmService menggunakan Message.builder() dari Firebase Admin SDK untuk menyusun payload notifikasi secara berantai. AuthService membangun AuthDataResponse step-by-step.

Implementasi

// entity/User.java
@Entity
@Table(name = "users")
@Data
@Builder                          // ← Builder Pattern
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id @GeneratedValue
    private Long id;

    private String email;
    private String password;
    private String displayName;   // opsional
    private String uniqueUserId;  // hanya ROLE_USER
    private String fcmToken;      // update tiap login

    @Enumerated(EnumType.STRING)
    private Role role;
}
// service/AuthService.java — bangun response step-by-step
private AuthDataResponse buildAuthResponse(User user, String accessToken, String refreshToken) {
    return AuthDataResponse.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .role(user.getRole().name())
        .userId(user.getId())
        .displayName(user.getDisplayName())
        .uniqueUserId(user.getUniqueUserId())   // null jika GUARDIAN
        .build();
}
// service/FcmService.java — Firebase Message builder
private Message buildSosMessage(String fcmToken, double lat, double lng) {
    return Message.builder()
        .setToken(fcmToken)
        .setNotification(Notification.builder()
            .setTitle("🚨 SOS ALERT!")
            .setBody("User butuh bantuan! Lokasi: " + lat + ", " + lng)
            .build())
        .putData("type", "SOS_ALERT")
        .putData("lat", String.valueOf(lat))
        .putData("lng", String.valueOf(lng))
        .setAndroidConfig(AndroidConfig.builder()
            .setPriority(AndroidConfig.Priority.HIGH)
            .build())
        .build();
}

Diagram

              «Builder»
           UserBuilder
    ┌─────────────────────┐
    │ + email(String)     │
    │ + password(String)  │
    │ + displayName(...)  │
    │ + uniqueUserId(...) │
    │ + fcmToken(...)     │
    │ + build() : User    │
    └──────────┬──────────┘
               │ creates
               ▼
           «Entity»
             User
    ┌─────────────────────┐
    │ - id : Long         │
    │ - email : String    │
    │ - displayName       │
    │ - uniqueUserId      │
    │ - fcmToken          │
    │ + builder()         │
    └─────────────────────┘

Keuntungan dalam WalkGuide

  • Kode registrasi user bersih meski ada field yang null (Guardian tidak punya uniqueUserId).
  • Firebase Message payload bisa disusun kondisional tanpa if-else bertumpuk.
  • Mudah di-test karena konstruksi objek eksplisit dan readable.

2. Singleton Pattern

Lokasi: app/injection_container.dart, semua core/services/*.dart

Masalah yang Diselesaikan

YoloDetector memuat model .tflite ~6 MB ke memori. TtsService mengelola engine TTS. WebSocketService mempertahankan satu koneksi STOMP. Membuat multiple instance dari service-service ini akan membuang memori dan menyebabkan konflik resource.

Solusi

Semua heavy service di-register sebagai singleton melalui GetIt service locator. GetIt menjamin hanya ada satu instance untuk seluruh lifecycle aplikasi.

Implementasi

// app/injection_container.dart
final sl = GetIt.instance;

Future<void> initDependencies() async {
  // ── Singletons (dibuat sekali, hidup selamanya) ──────────────
  sl.registerSingleton<TtsService>(TtsService());
  sl.registerSingleton<SttService>(SttService());
  sl.registerSingleton<YoloDetector>(YoloDetector());
  sl.registerSingleton<WebSocketService>(WebSocketService());
  sl.registerSingleton<AgoraService>(AgoraService());
  sl.registerSingleton<HapticService>(HapticService());

  // ── Factories (baru tiap pakai) ──────────────────────────────
  sl.registerFactory<WalkGuideBloc>(
    () => WalkGuideBloc(
      yoloDetector: sl(),
      ttsService: sl(),       // inject singleton yang sama
      hapticService: sl(),
    ),
  );
}
// core/ai/yolo_detector.dart — resource-heavy singleton
class YoloDetector {
  Interpreter? _interpreter;
  List<String> _labels = [];
  bool get isRunning => _isRunning;
  bool _isRunning = false;

  Future<void> loadModel() async {
    final modelPath = await _getModelPath('assets/models/yolov8n.tflite');
    _interpreter = await Interpreter.fromAsset(modelPath,
        options: InterpreterOptions()..useNnApiForAndroid = true);
    _labels = await _loadLabels('assets/models/labels.txt');
  }

  Future<List<DetectionResult>> detect(CameraImage frame) async {
    if (_isRunning) return [];   // skip frame, model sedang dipakai
    _isRunning = true;
    try {
      // YUV420 → RGB → resize 640×640 → normalize → inference → NMS
      return await compute(_runInference, frame);
    } finally {
      _isRunning = false;
    }
  }
}

Diagram

          GetIt (Service Locator)
    ┌────────────────────────────────┐
    │  registerSingleton<T>(T)       │
    │  get<T>() : T                  │
    └──┬──────┬──────┬──────┬───────┘
       │      │      │      │
       ▼      ▼      ▼      ▼
    Yolo    TTS    STT   WebSocket
  Detector Service Service  Service
  (6MB AI) (TTS  (Always  (STOMP
   model)  engine) listen) connect)

Keuntungan dalam WalkGuide

  • Model YOLO hanya di-load satu kali saat startup, bukan setiap buka layar.
  • Semua BLoC berbagi instance TTS yang sama — tidak ada suara bertumpuk.
  • Koneksi WebSocket tidak dibuat ulang setiap navigasi.

Structural Patterns

Structural patterns mengatur cara objek dan kelas disusun menjadi struktur yang lebih besar.


3. Facade Pattern

Lokasi (Flutter): core/services/voice_command_handler.dart Lokasi (Backend): service/GuardianDashboardService.java

Masalah yang Diselesaikan

Memproses satu perintah suara seperti "Send SOS" membutuhkan koordinasi antara: STT service, command matcher, SOS BLoC, GPS service, TTS feedback, dan router navigasi. Tanpa Facade, setiap widget harus tahu dan memanggil semua subsystem tersebut sendiri.

Solusi

VoiceCommandHandler bertindak sebagai Facade — satu antarmuka tunggal yang menyembunyikan seluruh kompleksitas di baliknya. Client hanya perlu memanggil processText(string).

Di backend, GuardianDashboardService adalah Facade yang mengagregasi data dari LocationService, ActivityLogService, SosService, dan NotificationService menjadi satu response DashboardResponse.

Implementasi

// core/services/voice_command_handler.dart
class VoiceCommandHandler {
  final TtsService _ttsService;
  final GoRouter _router;
  final WalkGuideBloc _walkGuideBloc;
  final SosBloc _sosBloc;
  final NotificationBloc _notifBloc;
  List<VoiceCommandConfig> _commandConfigs = [];

  // ─── Satu-satunya method yang perlu dipanggil client ─────────
  Future<void> processText(String rawText) async {
    final normalized = rawText.toLowerCase().trim();
    final key = _matchCommand(normalized);
    if (key == null) return;
    await _executeCommand(key);
  }

  VoiceCommandKey? _matchCommand(String text) {
    for (final config in _commandConfigs) {
      if (!config.enabled) continue;
      if (text.contains(config.triggerPhrase.toLowerCase())) {
        return config.commandKey;
      }
    }
    return null;
  }

  Future<void> _executeCommand(VoiceCommandKey key) async {
    switch (key) {
      case VoiceCommandKey.START_WALKGUIDE:
        _walkGuideBloc.add(StartWalkGuide());
      case VoiceCommandKey.SEND_SOS:
        _sosBloc.add(TriggerSos());
        await _ttsService.speakImmediate('SOS sent. Guardian has been alerted.');
      case VoiceCommandKey.WHERE_AM_I:
        final pos = await Geolocator.getCurrentPosition();
        await _ttsService.speak('You are at ${pos.latitude}, ${pos.longitude}');
      case VoiceCommandKey.CALL_GUARDIAN:
        _router.push('/user/call');
      // ... 10 command lainnya
    }
  }
}
// service/GuardianDashboardService.java
@Service
@RequiredArgsConstructor
public class GuardianDashboardService {

    private final LocationService locationService;
    private final ActivityLogService activityLogService;
    private final SosService sosService;
    private final NotificationService notificationService;
    private final PairingService pairingService;

    // ─── Facade: satu method agregasi semua data dashboard ───────
    public DashboardResponse getDashboard(Long guardianId) {
        Long userId = pairingService.getPairedUserId(guardianId);

        return DashboardResponse.builder()
            .lastLocation(locationService.getLastLocation(userId))
            .recentActivities(activityLogService.getRecent(userId, 10))
            .activeSosCount(sosService.countActive(userId))
            .unreadNotifCount(notificationService.countUnread(userId))
            .userStatus(locationService.getUserStatus(userId))
            .build();
    }
}

Diagram

Client (WalkGuideBloc)
        │
        │ processText("start walkguide")
        ▼
  VoiceCommandHandler          ← FACADE
  ┌─────────────────────────────────────┐
  │  _matchCommand(text)                │
  │  _executeCommand(key)               │
  └──┬──────┬──────┬──────┬────────────┘
     │      │      │      │
     ▼      ▼      ▼      ▼
  TTS    WalkGuide  Sos  GoRouter
 Service   Bloc    Bloc  (navigate)
    ↑      ↑      ↑      ↑
    └──────┴──────┴──────┘
         Subsystems
    (client tidak tahu ini)

Keuntungan dalam WalkGuide

  • Widget cukup panggil satu method — tidak perlu inject 6 dependency berbeda.
  • Mudah menambah command baru tanpa mengubah semua widget yang memanggil handler.
  • Backend: satu endpoint GET /guardian/dashboard mengembalikan semua data yang dibutuhkan, mengurangi jumlah request dari Flutter.

4. Repository Pattern (Proxy)

Lokasi: features/*/data/repositories/*_repository_impl.dart

Masalah yang Diselesaikan

WalkGuide harus berjalan saat offline (area tanpa sinyal). Obstacle log dan activity log harus tetap tersimpan meski tidak ada koneksi, lalu di-sync ke backend saat online kembali. Domain layer tidak boleh tahu apakah data berasal dari API atau cache lokal.

Solusi

Setiap *_repository_impl.dart bertindak sebagai Proxy yang mengimplementasikan abstract interface dari domain layer. Proxy memutuskan: ambil dari SQLite (Drift) saat offline, atau dari REST API saat online. Domain layer — termasuk BLoC dan use case — hanya kenal interface, tidak tahu implementasinya.

Implementasi

// features/walk_guide/domain/repositories/walk_guide_repository.dart
abstract class WalkGuideRepository {
  Future<Either<Failure, void>> startSession();
  Future<Either<Failure, void>> stopSession();
  Future<Either<Failure, void>> logObstacle(ObstacleLogRequest request);
  Future<Either<Failure, List<ObstacleLog>>> getObstacleLogs({int page = 0});
}
// features/walk_guide/data/repositories/walk_guide_repository_impl.dart
class WalkGuideRepositoryImpl implements WalkGuideRepository {
  final WalkGuideRemoteDataSource _remote;
  final WalkGuideLocalDataSource _local;       // SQLite/Drift
  final ConnectivityPlus _connectivity;

  @override
  Future<Either<Failure, void>> logObstacle(ObstacleLogRequest request) async {
    try {
      if (await _isOnline()) {
        // ── Online: kirim langsung ke backend ──────────────────
        await _remote.logObstacle(request);
        return const Right(null);
      } else {
        // ── Offline: simpan di SQLite dulu ─────────────────────
        await _local.cacheObstacle(request);
        return const Right(null);  // domain layer tidak tahu bedanya
      }
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  @override
  Future<Either<Failure, List<ObstacleLog>>> getObstacleLogs({int page = 0}) async {
    if (await _isOnline()) {
      try {
        final logs = await _remote.fetchObstacleLogs(page: page);
        await _local.cacheLogs(logs);   // update cache
        return Right(logs);
      } catch (_) {
        // fallback ke cache jika request gagal
        final cached = await _local.getCachedLogs(page: page);
        return Right(cached);
      }
    }
    final cached = await _local.getCachedLogs(page: page);
    return Right(cached);
  }

  Future<bool> _isOnline() async {
    final result = await _connectivity.checkConnectivity();
    return result != ConnectivityResult.none;
  }
}
// Sync pending saat koneksi kembali
class SyncPendingLogsUseCase {
  final WalkGuideRepository _repository;

  Future<Either<Failure, void>> call() async {
    final pending = await _repository.getPendingLogs();
    for (final log in pending) {
      await _repository.syncToRemote(log);
    }
    return const Right(null);
  }
}

Diagram

  Domain Layer (BLoC/UseCase)
           │
           │ logObstacle(request)
           ▼
  «interface» WalkGuideRepository
           │ implements
           ▼
  WalkGuideRepositoryImpl     ← PROXY
  ┌──────────────────────────────┐
  │  if (isOnline)               │
  │    → RemoteDataSource (API)  │
  │  else                        │
  │    → LocalDataSource (SQLite)│
  └──────────────────────────────┘
          │              │
          ▼              ▼
   REST API          SQLite/Drift
   (online)          (offline cache)

Keuntungan dalam WalkGuide

  • Tunanetra tetap bisa menggunakan WalkGuide detection meski sinyal hilang.
  • Perubahan strategi caching tidak mempengaruhi BLoC atau use case sama sekali.
  • dartz Either<Failure, Data> memastikan error handling eksplisit di setiap layer.

Behavioral Patterns

Behavioral patterns mengatur cara objek berkomunikasi dan berinteraksi satu sama lain.


5. Observer Pattern

Lokasi: features/*/presentation/bloc/*.dart, core/services/websocket_service.dart

Masalah yang Diselesaikan

Ketika YOLO mendeteksi obstacle, puluhan komponen perlu bereaksi: kamera overlay perlu menampilkan bounding box, TTS perlu berbicara, haptic perlu bergetar, dan backend perlu dicatat. Menghubungkan semua komponen ini secara langsung akan menciptakan coupling yang sangat ketat.

Solusi

BLoC pattern adalah implementasi Observer. WalkGuideBloc berperan sebagai Subject yang menyimpan state dan memancarkan (emit) state baru setiap ada perubahan. Widget (BlocBuilder, BlocConsumer, BlocListener) berperan sebagai Observer yang otomatis bereaksi terhadap perubahan state. WebSocketService juga menggunakan Observer untuk meneruskan update lokasi real-time dari User ke Guardian.

Implementasi

// features/walk_guide/presentation/bloc/walk_guide_bloc.dart
class WalkGuideBloc extends Bloc<WalkGuideEvent, WalkGuideState> {
  final YoloDetector _yoloDetector;
  final TtsService _ttsService;
  final HapticService _hapticService;
  final WalkGuideRepository _repository;

  WalkGuideBloc({...}) : super(WalkGuideIdle()) {
    on<StartWalkGuide>(_onStart);
    on<StopWalkGuide>(_onStop);
    on<CameraFrameReceived>(_onFrame);
  }

  Future<void> _onFrame(
    CameraFrameReceived event,
    Emitter<WalkGuideState> emit,
  ) async {
    if (_yoloDetector.isRunning) return;  // throttle

    final results = await _yoloDetector.detect(event.frame);
    if (results.isEmpty) return;

    final top = _prioritize(results);

    // ─── Emit state baru → semua Observer bereaksi ────────────
    emit(WalkGuideObstacleDetected(
      label: top.label,
      direction: top.direction,
      distance: top.estimatedDistance,
      confidence: top.confidence,
      boundingBox: top.boundingBox,
    ));

    // Side effects
    await _ttsService.speakImmediate(_buildTtsMessage(top));
    _hapticService.obstacleVeryClose();
    await _repository.logObstacle(ObstacleLogRequest.from(top));
  }
}
// features/walk_guide/presentation/screens/walk_guide_screen.dart
class WalkGuideScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // ── Observer 1: rebuild kamera overlay ────────────────
        BlocBuilder<WalkGuideBloc, WalkGuideState>(
          builder: (context, state) {
            if (state is WalkGuideObstacleDetected) {
              return DetectionOverlay(
                boundingBox: state.boundingBox,
                label: state.label,
                direction: state.direction,
              );
            }
            return const SizedBox.shrink();
          },
        ),
        // ── Observer 2: side effects (navigasi, snackbar) ─────
        BlocListener<WalkGuideBloc, WalkGuideState>(
          listener: (context, state) {
            if (state is WalkGuideError) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(state.message)),
              );
            }
          },
          child: const CameraPreview(),
        ),
      ],
    );
  }
}
// core/services/websocket_service.dart — Observer untuk Guardian
class WebSocketService {
  StompClient? _stompClient;
  final StreamController<LocationData> _locationController =
      StreamController.broadcast();

  Stream<LocationData> get onLocationUpdate => _locationController.stream;

  void subscribeToUserLocation(String userId) {
    _stompClient?.subscribe(
      destination: '/topic/location/$userId',
      callback: (frame) {
        final data = LocationData.fromJson(jsonDecode(frame.body!));
        _locationController.add(data);   // notify semua Observer
      },
    );
  }
}

// guardian_map_screen.dart — Observer menerima update
class GuardianMapScreen extends StatefulWidget {
  @override
  void initState() {
    super.initState();
    sl<WebSocketService>().onLocationUpdate.listen((location) {
      setState(() => _userMarker = LatLng(location.lat, location.lng));
    });
  }
}

Diagram

       WalkGuideBloc          ← SUBJECT
    ┌─────────────────┐
    │  emit(state)    │──────────────────────────────┐
    └─────────────────┘                              │
             │                                       │
    ┌────────┴────────┐                   ┌──────────┴──────────┐
    ▼                 ▼                   ▼                     ▼
BlocBuilder      BlocListener      WebSocketService       GuardianMap
(UI rebuild)    (side effects)     (STOMP Subject)        (Observer)
    ↑                ↑                    │
 OBSERVER         OBSERVER               │ notify
                                         ▼
                                  Guardian's map
                                  updates live

Keuntungan dalam WalkGuide

  • Menambah Observer baru (misal: logging screen) tidak memerlukan perubahan pada BLoC.
  • Guardian melihat posisi User real-time di peta tanpa polling setiap detik.
  • BLoC stream bisa di-test dengan bloc_test package secara terisolasi.

6. Strategy Pattern

Lokasi (Flutter): core/ai/obstacle_analyzer.dart Lokasi (Backend): service/ObstacleAlertStrategyService.java

Masalah yang Diselesaikan

Cara WalkGuide memperingatkan user tentang obstacle berbeda-beda: ada yang butuh TTS saja, ada yang perlu TTS + getaran, ada yang hanya getaran (lingkungan bising). Guardian harus bisa mengganti mode ini dari dashboard tanpa update APK.

Solusi

ObstacleAlertStrategy interface mendefinisikan kontrak peringatan. Tiga implementasi konkret (TtsOnlyStrategy, TtsWithHapticStrategy, HapticOnlyStrategy) dapat dipertukarkan runtime. ObstacleAnalyzer sebagai Context menyimpan referensi ke strategy aktif dan mendelegasikan eksekusi tanpa tahu implementasinya.

Implementasi

// core/ai/strategy/obstacle_alert_strategy.dart
abstract class ObstacleAlertStrategy {
  Future<void> alert(DetectionResult result);
}

// ── Concrete Strategy 1 ───────────────────────────────────────
class TtsOnlyStrategy implements ObstacleAlertStrategy {
  final TtsService _ttsService;

  @override
  Future<void> alert(DetectionResult result) async {
    final msg = 'Caution! ${result.label} ${result.direction}. '
                '${result.estimatedDistance}. Please stop.';
    await _ttsService.speakImmediate(msg);
  }
}

// ── Concrete Strategy 2 ───────────────────────────────────────
class TtsWithHapticStrategy implements ObstacleAlertStrategy {
  final TtsService _ttsService;
  final HapticService _hapticService;

  @override
  Future<void> alert(DetectionResult result) async {
    await Future.wait([
      _ttsService.speakImmediate('Caution! ${result.label} ahead.'),
      _hapticService.obstaclePattern(result.estimatedDistance),
    ]);
  }
}

// ── Concrete Strategy 3 ───────────────────────────────────────
class HapticOnlyStrategy implements ObstacleAlertStrategy {
  final HapticService _hapticService;

  @override
  Future<void> alert(DetectionResult result) async {
    await _hapticService.obstaclePattern(result.estimatedDistance);
  }
}
// core/ai/obstacle_analyzer.dart — Context
class ObstacleAnalyzer {
  ObstacleAlertStrategy _strategy;
  final AiConfig _config;

  // ─── Ganti strategy runtime tanpa ubah kode lain ─────────────
  void setStrategy(ObstacleAlertStrategy strategy) {
    _strategy = strategy;
  }

  Future<void> analyze(List<DetectionResult> results) async {
    if (results.isEmpty) return;
    final top = prioritize(results);
    if (top.confidence < _config.confidenceThreshold) return;
    await _strategy.alert(top);  // delegasi ke strategy aktif
  }

  DetectionResult prioritize(List<DetectionResult> results) {
    return results.reduce((a, b) =>
        _dangerScore(a) > _dangerScore(b) ? a : b);
  }

  double _dangerScore(DetectionResult r) {
    const distanceScore = {
      'Very Close': 4.0, 'Close': 3.0, 'Medium': 2.0, 'Far': 1.0
    };
    return r.confidence * (distanceScore[r.estimatedDistance] ?? 1.0);
  }
}
// Saat AiConfig diupdate dari Guardian
void _onAiConfigUpdated(AiConfig config) {
  final strategy = switch (config.alertMode) {
    AlertMode.ttsOnly      => TtsOnlyStrategy(_ttsService),
    AlertMode.ttsAndHaptic => TtsWithHapticStrategy(_ttsService, _hapticService),
    AlertMode.hapticOnly   => HapticOnlyStrategy(_hapticService),
  };
  sl<ObstacleAnalyzer>().setStrategy(strategy);
}

Diagram

  «interface»
  ObstacleAlertStrategy
  ┌─────────────────┐
  │ + alert(result) │
  └────────┬────────┘
           │ implements
    ┌──────┼──────┐
    ▼      ▼      ▼
 TtsOnly  TTS+  Haptic
Strategy Haptic  Only
         Strat. Strategy

  ObstacleAnalyzer (Context)
  ┌────────────────────────────┐
  │ - _strategy: Strategy      │◄── setStrategy()
  │ + analyze(results)         │        ▲
  │   → _strategy.alert(top)   │        │
  └────────────────────────────┘   AiConfig.alertMode
                                   (dari Guardian)

Keuntungan dalam WalkGuide

  • Guardian bisa mengganti mode alert dari PUT /guardian/ai-config tanpa restart app.
  • Menambah strategy baru (misal: FlashLightStrategy) tidak mengubah ObstacleAnalyzer.
  • Setiap strategy mudah di-unit-test secara terisolasi.

7. Chain of Responsibility Pattern

Lokasi (Flutter): core/network/api_client.dart (Dio interceptors) Lokasi (Backend): security/JwtAuthFilter.java + Spring Security

Masalah yang Diselesaikan

Setiap HTTP request perlu melewati beberapa proses: menambahkan JWT token, menangani error secara terpusat, dan mencatat log untuk debugging. Di backend, setiap request harus divalidasi JWT sebelum mencapai controller. Tanpa pola ini, logika ini akan tersebar dan terduplikasi di mana-mana.

Solusi

Flutter: Dio interceptor chain memproses setiap request secara berurutan — AuthInterceptor → ErrorInterceptor → LogInterceptor. Setiap interceptor bisa memproses, memodifikasi, atau memblokir request, lalu meneruskan ke handler berikutnya.

Backend: Spring Security filter chain — JwtAuthFilter → SecurityConfig → Controller — memvalidasi setiap request sebelum mencapai endpoint bisnis.

Implementasi

// core/network/api_client.dart
class ApiClient {
  late final Dio _dio;

  ApiClient() {
    _dio = Dio(BaseOptions(
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
    ));

    // ─── Chain: urutan eksekusi diatur oleh urutan penambahan ────
    _dio.interceptors.addAll([
      AuthInterceptor(_dio),      // [0] attach token, refresh jika expired
      ErrorInterceptor(),         // [1] map HTTP error → domain Failure
      LogInterceptor(             // [2] print request/response
        requestBody: true,
        responseBody: kDebugMode,
      ),
    ]);
  }
}
// core/network/interceptors/auth_interceptor.dart
class AuthInterceptor extends QueuedInterceptorsWrapper {
  final Dio _dio;
  final FlutterSecureStorage _storage = const FlutterSecureStorage();

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    // ─── Attach Bearer token ke setiap request ───────────────
    final token = await _storage.read(key: 'access_token');
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);  // teruskan ke interceptor berikutnya
  }

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      // ─── Token expired: refresh otomatis lalu retry ──────────
      try {
        final refreshToken = await _storage.read(key: 'refresh_token');
        final response = await _dio.post('/auth/refresh',
            data: {'refreshToken': refreshToken});
        final newToken = response.data['data']['accessToken'];
        await _storage.write(key: 'access_token', value: newToken);

        // Retry request asli dengan token baru
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        final retried = await _dio.fetch(err.requestOptions);
        handler.resolve(retried);
        return;
      } catch (_) {
        // Refresh juga gagal → paksa logout
        GetIt.I<AuthBloc>().add(LogoutRequested());
      }
    }
    handler.next(err);  // teruskan ke ErrorInterceptor
  }
}
// core/network/interceptors/error_interceptor.dart
class ErrorInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // ─── Map HTTP error code → domain Failure object ──────────
    final failure = switch (err.response?.statusCode) {
      400 => ValidationFailure(err.response?.data['message']),
      401 => UnauthorizedFailure(),
      403 => ForbiddenFailure(),
      404 => NotFoundFailure(),
      500 => ServerFailure('Server error, please try again'),
      _   => NetworkFailure('No internet connection'),
    };
    handler.next(err.copyWith(error: failure));
  }
}
// security/JwtAuthFilter.java — Backend chain handler
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain        // ← chain berikutnya
    ) throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);  // lewati jika tidak ada token
            return;
        }

        final String jwt = authHeader.substring(7);

        try {
            final String email = jwtUtil.extractEmail(jwt);
            final UserDetails userDetails = userDetailsService.loadUserByUsername(email);

            if (jwtUtil.isTokenValid(jwt, userDetails)) {
                // ─── Set authentication context ──────────────────────
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (JwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;   // blokir — tidak diteruskan ke controller
        }

        filterChain.doFilter(request, response);  // teruskan ke SecurityConfig
    }
}

Diagram

HTTP Request dari Flutter
        │
        ▼
┌───────────────────┐
│   AuthInterceptor │  → attach Bearer token
│   [Flutter Dio]   │  → auto-refresh jika 401
└────────┬──────────┘
         │ handler.next()
         ▼
┌───────────────────┐
│  ErrorInterceptor │  → map error → domain Failure
└────────┬──────────┘
         │ handler.next()
         ▼
┌───────────────────┐
│   LogInterceptor  │  → print untuk debugging
└────────┬──────────┘
         │
         ▼
    Backend REST API
         │
         ▼
┌───────────────────┐
│  JwtAuthFilter    │  → extract & validasi JWT
│ [Spring Security] │  → set SecurityContext
└────────┬──────────┘
         │ filterChain.doFilter()
         ▼
┌───────────────────┐
│  SecurityConfig   │  → cek role: GUARDIAN vs USER
└────────┬──────────┘
         │ (jika authorized)
         ▼
┌───────────────────┐
│    Controller     │  → proses bisnis logic
└───────────────────┘

Keuntungan dalam WalkGuide

  • Token refresh otomatis — user tidak perlu login ulang saat token expired.
  • Error handling terpusat — tidak ada try-catch duplikat di setiap repository.
  • Menambah interceptor baru (misal: rate limiting, analytics) cukup ditambah ke chain.
  • Backend: satu filter untuk semua 26 endpoint, tidak perlu validasi JWT di setiap controller.

Ringkasan Matriks

# Pattern Kategori Problem Solved File Utama
1 Builder Creational Konstruksi objek kompleks dengan banyak optional field User.java, FcmService.java
2 Singleton Creational Satu instance untuk heavy resource (model AI, TTS, WebSocket) injection_container.dart
3 Facade Structural Sembunyikan kompleksitas koordinasi multi-service voice_command_handler.dart, GuardianDashboardService.java
4 Repository (Proxy) Structural Offline-first — domain layer tidak tahu sumber data *_repository_impl.dart
5 Observer Behavioral Reaktif terhadap state change tanpa coupling ketat walk_guide_bloc.dart, websocket_service.dart
6 Strategy Behavioral Alert mode bisa diganti runtime tanpa ubah core logic obstacle_analyzer.dart, ObstacleAlertStrategyService.java
7 Chain of Responsibility Behavioral Pipeline request: auth → error → log (Flutter & Backend) api_client.dart, JwtAuthFilter.java

Catatan exam: 7 patterns (≥4 ), mencakup 3 kategori GoF (≥1 per kategori ): Creational, Structural, Behavioral.