Flutter'da Clean Architecture: Domain, Data ve Presentation

Clean Architecture, uygulamayı bağımsız katmanlara ayırır ve bağımlılıkların yalnızca içe doğru akmasını (Dependency Rule) sağlar. Flutter'da tipik yapı üç ana katmandan oluşur: Domain (iş mantığı, framework bağımsız), Data (API ve veritabanı implementasyonları) ve Presentation (UI ve state management).

Klasör Yapısı

lib/
├── core/
│   ├── error/          # Failure, Exception sınıfları
│   ├── network/        # ApiClient, NetworkInfo
│   └── usecase/        # UseCase base class
├── features/
│   └── products/
│       ├── domain/
│       │   ├── entities/       # Product, Category
│       │   ├── repositories/   # ProductRepository (abstract)
│       │   └── usecases/       # GetProducts, AddToCart
│       ├── data/
│       │   ├── models/         # ProductModel extends Product
│       │   ├── datasources/    # RemoteDataSource, LocalDataSource
│       │   └── repositories/   # ProductRepositoryImpl
│       └── presentation/
│           ├── bloc/           # ProductsCubit, ProductsState
│           ├── pages/          # ProductsPage, ProductDetailPage
│           └── widgets/        # ProductCard, FilterBar
└── injection.dart

Domain Katmanı: Entity ve Repository Arayüzü

// features/products/domain/entities/product.dart
// Pure Dart — hiçbir framework bağımlılığı yok
class Product {
  final String id;
  final String title;
  final String description;
  final double price;
  final double? originalPrice;
  final String imageUrl;
  final int stockCount;
  final String categoryId;

  const Product({
    required this.id,
    required this.title,
    required this.description,
    required this.price,
    this.originalPrice,
    required this.imageUrl,
    required this.stockCount,
    required this.categoryId,
  });

  bool get isOnSale => originalPrice != null && originalPrice! > price;
  double get discountPercent =>
      isOnSale ? ((originalPrice! - price) / originalPrice! * 100) : 0;
}

// features/products/domain/repositories/product_repository.dart
abstract class ProductRepository {
  Future<Either<Failure, List<Product>>> getProducts({
    String? categoryId,
    String? searchQuery,
    int page = 1,
    int pageSize = 20,
  });

  Future<Either<Failure, Product>> getProductById(String id);
  Future<Either<Failure, void>> refreshProducts();
  Stream<List<Product>> watchFavorites();
}

Use Case: Tek Sorumlu İş Mantığı

// core/usecase/usecase.dart
abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

class NoParams extends Equatable {
  @override List<Object?> get props => [];
}

// features/products/domain/usecases/get_products.dart
class GetProducts extends UseCase<List<Product>, GetProductsParams> {
  final ProductRepository repository;
  GetProducts(this.repository);

  @override
  Future<Either<Failure, List<Product>>> call(GetProductsParams params) =>
      repository.getProducts(
        categoryId: params.categoryId,
        searchQuery: params.searchQuery,
        page: params.page,
      );
}

class GetProductsParams extends Equatable {
  final String? categoryId;
  final String? searchQuery;
  final int page;

  const GetProductsParams({this.categoryId, this.searchQuery, this.page = 1});

  @override List<Object?> get props => [categoryId, searchQuery, page];
}

Data Katmanı: Model ve Repository Implementasyonu

// Model — JSON dönüşümlerini bilir, Entity'yi genişletir
class ProductModel extends Product {
  const ProductModel({
    required super.id,
    required super.title,
    required super.description,
    required super.price,
    super.originalPrice,
    required super.imageUrl,
    required super.stockCount,
    required super.categoryId,
  });

  factory ProductModel.fromJson(Map<String, dynamic> json) => ProductModel(
    id: json['id'] as String,
    title: json['title'] as String,
    description: json['description'] as String,
    price: (json['price'] as num).toDouble(),
    originalPrice: json['originalPrice'] != null
        ? (json['originalPrice'] as num).toDouble() : null,
    imageUrl: json['imageUrl'] as String,
    stockCount: json['stockCount'] as int,
    categoryId: json['categoryId'] as String,
  );

  Map<String, dynamic> toJson() => {
    'id': id, 'title': title, 'description': description,
    'price': price, 'originalPrice': originalPrice,
    'imageUrl': imageUrl, 'stockCount': stockCount, 'categoryId': categoryId,
  };
}

// Repository implementasyonu
@LazySingleton(as: ProductRepository)
class ProductRepositoryImpl implements ProductRepository {
  final ProductRemoteDataSource _remote;
  final ProductLocalDataSource _local;
  final NetworkInfo _network;

  ProductRepositoryImpl(this._remote, this._local, this._network);

  @override
  Future<Either<Failure, List<Product>>> getProducts({
    String? categoryId, String? searchQuery, int page = 1, int pageSize = 20,
  }) async {
    if (await _network.isConnected) {
      try {
        final models = await _remote.getProducts(
          categoryId: categoryId, searchQuery: searchQuery,
          page: page, pageSize: pageSize,
        );
        await _local.cacheProducts(models);
        return Right(models);
      } on ServerException catch (e) {
        return Left(ServerFailure(e.message));
      }
    } else {
      try {
        final cached = await _local.getCachedProducts(categoryId: categoryId);
        return Right(cached);
      } on CacheException {
        return const Left(CacheFailure('Önbellek verisi bulunamadı'));
      }
    }
  }
}

Presentation: Cubit ile State Management

// State
@freezed
class ProductsState with _$ProductsState {
  const factory ProductsState.initial() = _Initial;
  const factory ProductsState.loading() = _Loading;
  const factory ProductsState.loaded({
    required List<Product> products,
    required bool hasMore,
    required int currentPage,
  }) = _Loaded;
  const factory ProductsState.error(String message) = _Error;
}

// Cubit
@injectable
class ProductsCubit extends Cubit<ProductsState> {
  final GetProducts _getProducts;
  String? _currentCategory;

  ProductsCubit(this._getProducts) : super(const ProductsState.initial());

  Future<void> load({String? categoryId, bool refresh = false}) async {
    if (state is _Loading) return;
    emit(const ProductsState.loading());

    _currentCategory = categoryId;
    final result = await _getProducts(
      GetProductsParams(categoryId: categoryId, page: 1),
    );

    result.fold(
      (failure) => emit(ProductsState.error(failure.message)),
      (products) => emit(ProductsState.loaded(
        products: products,
        hasMore: products.length == 20,
        currentPage: 1,
      )),
    );
  }

  Future<void> loadMore() async {
    final current = state;
    if (current is! _Loaded || !current.hasMore) return;

    final result = await _getProducts(
      GetProductsParams(
        categoryId: _currentCategory,
        page: current.currentPage + 1,
      ),
    );

    result.fold(
      (failure) => emit(ProductsState.error(failure.message)),
      (newProducts) => emit(ProductsState.loaded(
        products: [...current.products, ...newProducts],
        hasMore: newProducts.length == 20,
        currentPage: current.currentPage + 1,
      )),
    );
  }
}

Clean Architecture'ın getirdiği kural: Domain katmanı hiçbir zaman Data veya Presentation'a bağımlı olmamalı. Bu kural, iş mantığını framework ve veritabanı değişikliklerinden yalıtır. Başlangıçta fazla sınıf gibi görünse de test edilebilirlik ve uzun vadeli bakım açısından bu yatırım kendini öder.