Recipe 1 — Flutter Weather

시나리오

BLoC 공식 튜토리얼 Flutter Weather를 Flutist로 구현합니다. 사용자가 도시를 검색하면 Open Meteo API에서 날씨 데이터를 가져오고, Celsius/Fahrenheit 전환 및 HydratedBloc으로 세션 간 상태를 유지합니다. Clean Architecture를 적용해 도메인 비즈니스 로직을 HTTP 클라이언트와 UI로부터 완전히 격리합니다.

clean BLoC · Cubit Open Meteo API HydratedBloc
1
프로젝트 초기화 및 패키지 등록
TERMINAL
$ mkdir flutter_weather && cd flutter_weather
$ flutist init
▶ Do you want to create a new Flutter project? (y/n): y

$ flutist pub add equatable bloc flutter_bloc hydrated_bloc json_annotation http google_fonts
2
weather 피처 모듈 생성

--options clean으로 domain · data · presentation 3개 레이어를 한 번에 생성합니다.

TERMINAL
$ flutist create --name weather --path features --options clean

✓ features/weather/weather_domain 생성
✓ features/weather/weather_data 생성
✓ features/weather/weather_presentation 생성
✓ project.dart 업데이트
✓ flutist_gen.dart 재생성
3
project.dart 의존성 선언
project.dart
final project = Project(
  name: 'flutter_weather',
  options: const ProjectOptions(compositionRoots: ['app']),
  modules: [

    // ── Domain: 순수 비즈니스 로직, 외부 의존 없음 ─────────
    Module(
      name: 'weather_domain',
      dependencies: [package.dependencies.equatable],
      devDependencies: [package.dependencies.test],
    ),

    // ── Data: Open Meteo API 클라이언트 ────────────────────
    Module(
      name: 'weather_data',
      dependencies: [
        package.dependencies.http,
        package.dependencies.jsonAnnotation,
      ],
      devDependencies: [
        package.dependencies.test,
        package.dependencies.mocktail,
      ],
      modules: [package.modules.weatherDomain],
    ),

    // ── Presentation: WeatherCubit, ThemeCubit ─────────────
    Module(
      name: 'weather_presentation',
      dependencies: [
        package.dependencies.flutterBloc,
        package.dependencies.hydratedBloc,
        package.dependencies.googleFonts,
      ],
      devDependencies: [
        package.dependencies.blocTest,
        package.dependencies.mocktail,
      ],
      modules: [package.modules.weatherDomain],   // domain만 의존
    ),

    // ── App: composition root — data 구현체 주입 ───────────
    Module(
      name: 'app',
      modules: [
        package.modules.weatherPresentation,
        package.modules.weatherData,   // ✅ composition root만 구현체 참조 가능
      ],
    ),
  ],
);
4
Domain 레이어 — 모델 & Repository interface

weather_domainhttp·Flutter SDK를 import하지 않습니다. 순수 Dart로만 작성되며, data와 presentation이 따를 계약을 정의합니다.

features/weather/weather_domain/lib/src/weather.dart
import 'package:equatable/equatable.dart';

enum TemperatureUnits { fahrenheit, celsius }

class Temperature extends Equatable {
  const Temperature({required this.value});
  final double value;

  @override
  List<Object> get props => [value];
}

class Weather extends Equatable {
  const Weather({
    required this.condition,
    required this.location,
    required this.temperature,
    required this.lastUpdated,
  });

  final WeatherCondition condition;
  final String location;
  final Temperature temperature;
  final DateTime lastUpdated;

  @override
  List<Object> get props => [condition, location, temperature, lastUpdated];
}

enum WeatherCondition { clear, rainy, cloudy, snowy, unknown }
features/weather/weather_domain/lib/src/weather_repository.dart
import 'weather.dart';

abstract interface class WeatherRepository {
  /// 도시명으로 날씨 조회. Data 레이어가 구현한다.
  Future<Weather> getWeather(String city);
}
5
Data 레이어 — Open Meteo API 구현

weather_data는 Open Meteo Geocoding API와 Forecast API를 순서대로 호출합니다. WeatherRepository interface를 구현합니다.

features/weather/weather_data/lib/src/weather_repository_impl.dart
import 'package:http/http.dart' as http;
import 'package:weather_domain/weather_domain.dart';
import 'dart:convert';

class WeatherRepositoryImpl implements WeatherRepository {
  const WeatherRepositoryImpl({http.Client? httpClient})
      : _httpClient = httpClient ?? const http.Client();

  final http.Client _httpClient;

  @override
  Future<Weather> getWeather(String city) async {
    // 1. Geocoding: 도시명 → 위경도
    final geoRes = await _httpClient.get(
      Uri.https('geocoding-api.open-meteo.com', '/v1/search', {'name': city, 'count': '1'}),
    );
    final geoJson = jsonDecode(geoRes.body) as Map<String, dynamic>;
    final results = geoJson['results'] as List?;
    if (results == null || results.isEmpty) throw Exception('City not found');

    final location = results[0];
    final lat = location['latitude'] as double;
    final lng = location['longitude'] as double;

    // 2. Forecast: 위경도 → 현재 날씨
    final wRes = await _httpClient.get(
      Uri.https('api.open-meteo.com', '/v1/forecast', {
        'latitude': '$lat', 'longitude': '$lng',
        'current_weather': 'true',
      }),
    );
    final wJson = jsonDecode(wRes.body) as Map<String, dynamic>;
    final current = wJson['current_weather'] as Map<String, dynamic>;

    return Weather(
      location: location['name'] as String,
      temperature: Temperature(value: (current['temperature'] as num).toDouble()),
      condition: _codeToCondition(current['weathercode'] as int),
      lastUpdated: DateTime.now(),
    );
  }

  WeatherCondition _codeToCondition(int code) {
    if (code == 0) return WeatherCondition.clear;
    if (code <= 3) return WeatherCondition.cloudy;
    if (code <= 67) return WeatherCondition.rainy;
    if (code <= 77) return WeatherCondition.snowy;
    return WeatherCondition.unknown;
  }
}
6
Presentation 레이어 — WeatherCubit

weather_presentationWeatherRepository interface에만 의존합니다. WeatherRepositoryImpl이 무엇인지 모릅니다. HydratedCubit으로 세션 간 상태를 유지합니다.

features/weather/weather_presentation/lib/src/weather_cubit.dart
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:weather_domain/weather_domain.dart';

part 'weather_state.dart';

class WeatherCubit extends HydratedCubit<WeatherState> {
  WeatherCubit(this._weatherRepository) : super(WeatherState());

  final WeatherRepository _weatherRepository;  // interface만 알고 있음

  Future<void> fetchWeather(String city) async {
    if (city.isEmpty) return;
    emit(state.copyWith(status: WeatherStatus.loading));
    try {
      final weather = await _weatherRepository.getWeather(city);
      emit(state.copyWith(status: WeatherStatus.success, weather: weather));
    } catch (_) {
      emit(state.copyWith(status: WeatherStatus.failure));
    }
  }

  void toggleUnits() {
    final units = state.temperatureUnits == TemperatureUnits.fahrenheit
        ? TemperatureUnits.celsius
        : TemperatureUnits.fahrenheit;
    emit(state.copyWith(temperatureUnits: units));
  }

  @override
  WeatherState fromJson(Map<String, dynamic> json) =>
      WeatherState.fromJson(json);

  @override
  Map<String, dynamic> toJson(WeatherState state) => state.toJson();
}
7
App — Composition Root에서 주입

appWeatherRepositoryImpl을 알고 있습니다. Presentation은 interface만 바라봅니다.

app/lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:weather_data/weather_data.dart';          // ✅ 구현체
import 'package:weather_presentation/weather_presentation.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider(
      create: (_) => WeatherRepositoryImpl(),   // 구현체 주입
      child: BlocProvider(
        create: (ctx) => WeatherCubit(ctx.read<WeatherRepositoryImpl>()),
        child: const WeatherAppView(),
      ),
    );
  }
}
8
전체 동기화 및 아키텍처 검증
TERMINAL
$ flutist generate

✓ [Clean] weather_data → weather_domain: OK (data → domain)
✓ [Clean] weather_presentation → weather_domain: OK (presentation → domain)
✗ weather_domain → weather_data: 역방향 의존성 — generate 중단!
   → weather_domain에서 http import를 제거하세요

$ flutist generate   # 수정 후
✓ 모든 아키텍처 규칙 통과
✓ pubspec.yaml 4개 동기화 완료

최종 구조

flutter_weather/
  app/                               # Composition Root
    lib/app.dart                     # WeatherRepositoryImpl 주입
  features/
    weather/
      weather_domain/                # Weather, Temperature, WeatherCondition
        lib/src/weather.dart         # TemperatureUnits enum
        lib/src/weather_repository.dart  # abstract interface
      weather_data/                  # Open Meteo API 구현
        lib/src/weather_repository_impl.dart
      weather_presentation/          # WeatherCubit, ThemeCubit, WeatherPage
        lib/src/weather_cubit.dart
        lib/src/weather_state.dart
        lib/src/weather_page.dart
  flutist/
    flutist_gen.dart

# 의존성 방향:
# weather_data        → weather_domain  ✅ (data → domain)
# weather_presentation → weather_domain  ✅ (presentation → domain)
# app                 → weather_data + weather_presentation  ✅ (composition root)
# weather_domain      → (아무것도 없음)  ✅ 핵심 원칙