Clean Architecture in Softbee separates the application into three distinct layers, each with specific responsibilities and dependencies flowing inward toward the business logic. This approach ensures that business rules remain independent of frameworks, UI, and external dependencies.
Dependency Rule: Source code dependencies point only inward. Inner layers know nothing about outer layers. The Domain layer is completely independent.
The Domain Layer is the heart of the application. It contains business entities, business rules (use cases), and repository contracts. This layer has zero dependencies on Flutter or any external packages except for utility libraries like either_dart and equatable.
Entities are pure Dart classes representing core business concepts. They contain only data and methods that operate on that data.Example: Apiary Entity (lib/feature/apiaries/domain/entities/apiary.dart)
class Apiary { final String id; final String userId; final String name; final String? location; final int? beehivesCount; final bool treatments; final DateTime? createdAt; Apiary({ required this.id, required this.userId, required this.name, this.location, this.beehivesCount, required this.treatments, this.createdAt, }); Apiary copyWith({ String? id, String? userId, String? name, String? location, int? beehivesCount, bool? treatments, DateTime? createdAt, }) { return Apiary( id: id ?? this.id, userId: userId ?? this.userId, name: name ?? this.name, location: location ?? this.location, beehivesCount: beehivesCount ?? this.beehivesCount, treatments: treatments ?? this.treatments, createdAt: createdAt ?? this.createdAt, ); }}
class Beehive extends Equatable { final String id; final String apiaryId; final int? beehiveNumber; final String? activityLevel; final String? beePopulation; final int? foodFrames; final int? broodFrames; final String? hiveStatus; final String? healthStatus; final String? hasProductionChamber; final String? observations; final DateTime? createdAt; final DateTime? updatedAt; const Beehive({ required this.id, required this.apiaryId, this.beehiveNumber, this.activityLevel, this.beePopulation, this.foodFrames, this.broodFrames, this.hiveStatus, this.healthStatus, this.hasProductionChamber, this.observations, this.createdAt, this.updatedAt, }); @override List<Object?> get props => [ id, apiaryId, beehiveNumber, activityLevel, beePopulation, foodFrames, broodFrames, hiveStatus, healthStatus, hasProductionChamber, observations, createdAt, updatedAt, ];}
Why use Equatable?
Equatable simplifies value equality comparisons. Instead of manually overriding == and hashCode, you just list the properties in props. This is especially useful for state management where comparing objects is frequent.
Repositories define contracts for data operations without specifying implementation details. They return Either<Failure, T> to handle errors functionally.Example: Apiary Repository (lib/feature/apiaries/domain/repositories/apiary_repository.dart)
Use cases encapsulate single business operations. Each use case has one responsibility, following the Single Responsibility Principle.Base UseCase Contract (lib/core/usecase/usecase.dart)
The Data Layer implements repository interfaces and handles data retrieval from APIs, databases, and caches. It knows about data sources but not about the UI.
Data sources provide raw data from external sources. They’re separated into remote (API) and local (storage/cache).Example: Apiary Remote Data Source (lib/feature/apiaries/data/datasources/apiary_remote_datasource.dart)
abstract class ApiaryRemoteDataSource { Future<List<Apiary>> getApiaries(String token); Future<Apiary> createApiary( String token, String userId, String name, String? location, int? beehivesCount, bool treatments, ); // ... other methods}class ApiaryRemoteDataSourceImpl implements ApiaryRemoteDataSource { final Dio httpClient; final AuthLocalDataSource localDataSource; ApiaryRemoteDataSourceImpl(this.httpClient, this.localDataSource); @override Future<List<Apiary>> getApiaries(String token) async { try { final response = await httpClient.get( '/api/v1/apiaries', options: Options(headers: {'Authorization': 'Bearer $token'}), ); if (response.statusCode == 200) { final List<dynamic> apiariesJson = response.data; return apiariesJson.map((json) => Apiary.fromJson(json)).toList(); } else { throw Exception( response.data['message'] ?? 'Error al obtener apiarios', ); } } on DioException catch (e) { if (e.response != null) { throw Exception( e.response!.data['message'] ?? 'Error de red: ${e.response!.statusCode}', ); } else { throw Exception('Error de conexión: ${e.message}'); } } }}
Data sources throw exceptions on errors. The repository layer catches these and converts them to Failure objects.
Repository implementations coordinate between data sources and convert exceptions to domain failures.Example: Apiary Repository Implementation (lib/feature/apiaries/data/repositories/apiary_repository_impl.dart)
While entities are in the Domain layer, models with JSON serialization live in the Data layer. In this codebase, entities often have fromJson and toJson methods directly, blending the distinction slightly for simplicity.Example: Inventory Item Model (lib/feature/inventory/data/models/inventory_item.dart)
class InventoryItem { final String id; final String itemName; final int quantity; final String unit; final String apiaryId; final String? description; final int minimumStock; final DateTime createdAt; final DateTime updatedAt; factory InventoryItem.fromJson(Map<String, dynamic> json) { return InventoryItem( id: json['id']?.toString() ?? '', itemName: json['name']?.toString() ?? '', quantity: json['quantity'] as int? ?? 0, unit: json['unit']?.toString() ?? 'unit', apiaryId: json['apiary_id']?.toString() ?? '', description: json['description'], minimumStock: json['minimum_stock'] as int? ?? 0, createdAt: DateTime.tryParse(json['created_at']?.toString() ?? '') ?? DateTime.now(), updatedAt: DateTime.tryParse(json['updated_at']?.toString() ?? '') ?? DateTime.now(), ); } Map<String, dynamic> toJson() { return { 'id': id, 'name': itemName, 'quantity': quantity, 'unit': unit, 'apiary_id': apiaryId, 'description': description, 'minimum_stock': minimumStock, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; }}
The Presentation Layer handles UI rendering, user interactions, and state management. It depends on the Domain layer but knows nothing about Data layer implementation details.
State classes are immutable data structures representing UI state.Example: Apiaries State (lib/feature/apiaries/presentation/controllers/apiaries_controller.dart:13-91)
Controllers manage state and orchestrate use cases based on user actions.Example: Apiaries Controller (simplified from lib/feature/apiaries/presentation/controllers/apiaries_controller.dart)
class ApiariesController extends StateNotifier<ApiariesState> { final GetApiariesUseCase getApiariesUseCase; final CreateApiaryUseCase createApiaryUseCase; final UpdateApiaryUseCase updateApiaryUseCase; final DeleteApiaryUseCase deleteApiaryUseCase; final AuthController authController; ApiariesController({ required this.getApiariesUseCase, required this.createApiaryUseCase, required this.updateApiaryUseCase, required this.deleteApiaryUseCase, required this.authController, }) : super(const ApiariesState()); String? get _currentUserId => authController.state.user?.id; Future<void> fetchApiaries() async { state = state.copyWith(isLoading: true, clearError: true); if (!_isAuthenticated()) { state = state.copyWith( isLoading: false, errorMessage: 'User not authenticated.', ); return; } final result = await getApiariesUseCase(NoParams()); result.fold( (failure) { state = state.copyWith( isLoading: false, errorMessage: _mapFailureToMessage(failure, 'fetching apiaries'), ); }, (allApiaries) { final userApiaries = allApiaries .where((apiary) => apiary.userId == _currentUserId) .toList(); state = state.copyWith( isLoading: false, allApiaries: userApiaries, filteredApiaries: userApiaries, ); }, ); } Future<void> createApiary( String name, String? location, int? beehivesCount, bool treatments, ) async { state = state.copyWith(isCreating: true, clearError: true); final params = CreateApiaryParams( userId: _currentUserId!, name: name, location: location, beehivesCount: beehivesCount, treatments: treatments, ); final result = await createApiaryUseCase(params); result.fold( (failure) => state = state.copyWith( isCreating: false, errorCreating: _mapFailureToMessage(failure, 'creating apiary'), ), (newApiary) { final updatedApiaries = [...state.allApiaries, newApiary]; state = state.copyWith( isCreating: false, allApiaries: updatedApiaries, filteredApiaries: updatedApiaries, successMessage: 'Apiary created successfully!', ); }, ); } String _mapFailureToMessage(Failure failure, String operation) { switch (failure.runtimeType) { case ServerFailure: return 'Server Error during $operation: ${failure.message}'; case AuthFailure: return 'Authentication Error during $operation: ${failure.message}'; case NetworkFailure: return 'Network Error during $operation: ${failure.message}'; default: return 'An unexpected error occurred during $operation.'; } }}
Riverpod providers wire up the dependency graph and expose state to the UI.Example: Apiary Providers (lib/feature/apiaries/presentation/providers/apiary_providers.dart)