Clean Architecture¶
A deep dive into Clean Architecture principles as implemented in TrueLedger.
What is Clean Architecture?¶
Clean Architecture is a software design philosophy introduced by Robert C. Martin (Uncle Bob) that emphasizes:
- Independence of Frameworks: Business logic doesn't depend on external libraries
- Testability: Business logic can be tested without UI, database, or external dependencies
- Independence of UI: UI can change without affecting business logic
- Independence of Database: Database can be swapped without affecting business logic
- Independence of External Agencies: Business logic doesn't know about the outside world
The Dependency Rule¶
The overriding rule that makes this architecture work:
Source code dependencies must point only inward, toward higher-level policies.
┌─────────────────────────────────────┐
│ Presentation Layer │ ← Frameworks & Drivers
│ (UI, Providers, Widgets) │ (Most volatile)
└──────────────┬──────────────────────┘
│ depends on
┌──────────────▼──────────────────────┐
│ Domain Layer │ ← Business Rules
│ (Use Cases, Entities, Interfaces) │ (Most stable)
└──────────────┬──────────────────────┘
│ implemented by
┌──────────────▼──────────────────────┐
│ Data Layer │ ← Interface Adapters
│ (Repositories, Data Sources) │ (Medium volatility)
└─────────────────────────────────────┘
Layers in Detail¶
Presentation Layer (Outermost)¶
Purpose: Handle user interface and user interactions
Components: - Screens (Flutter widgets) - Providers (Riverpod state management) - UI components
Rules: - Can depend on Domain layer - Cannot depend on Data layer directly - No business logic - Delegates all operations to use cases
Example:
class DashboardScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch provider (from Presentation layer)
final dashboardAsync = ref.watch(dashboardProvider);
return dashboardAsync.when(
loading: () => CircularProgressIndicator(),
error: (err, stack) => ErrorView(err),
data: (dashboard) => DashboardView(dashboard),
);
}
}
Domain Layer (Core)¶
Purpose: Contain all business logic and rules
Components: - Entities: Core business models (Budget, Transaction, etc.) - Use Cases: Single-purpose business operations - Repository Interfaces: Contracts for data access - Services: Complex business logic (e.g., AI insights)
Rules: - Pure Dart (no Flutter dependencies) - No implementation details - Defines interfaces, doesn't implement them - Most stable layer (changes least frequently)
Example:
// Entity
class Budget {
final int id;
final String category;
final int monthlyLimit;
Budget({required this.id, required this.category, required this.monthlyLimit});
}
// Repository Interface
abstract class IFinancialRepository {
Future<List<Budget>> getBudgets();
Future<void> addBudget(Budget budget);
}
// Use Case
class GetBudgetsUseCase extends UseCase<List<Budget>, NoParams> {
final IFinancialRepository repository;
GetBudgetsUseCase(this.repository);
@override
Future<Result<List<Budget>>> call(NoParams params) async {
try {
final budgets = await repository.getBudgets();
return Success(budgets);
} catch (e) {
return Failure(DatabaseFailure(e.toString()));
}
}
}
Data Layer (Infrastructure)¶
Purpose: Implement data access and persistence
Components: - Repository Implementations: Concrete implementations of domain interfaces - Data Sources: SQLite database, file system, etc. - Migrations: Database schema evolution
Rules: - Implements Domain interfaces - Handles all data persistence - No business logic - Can use platform-specific code
Example:
class FinancialRepositoryImpl implements IFinancialRepository {
@override
Future<List<Budget>> getBudgets() async {
final db = await AppDatabase.db;
final results = await db.query('budgets');
return results.map((r) => Budget.fromMap(r)).toList();
}
@override
Future<void> addBudget(Budget budget) async {
final db = await AppDatabase.db;
await db.insert('budgets', budget.toMap());
}
}
Benefits of Clean Architecture¶
1. Testability¶
Each layer can be tested independently:
// Test use case without UI or database
test('GetBudgetsUseCase returns budgets', () async {
final mockRepo = MockFinancialRepository();
final useCase = GetBudgetsUseCase(mockRepo);
when(() => mockRepo.getBudgets())
.thenAnswer((_) async => [Budget(...)]);
final result = await useCase(NoParams());
expect(result.isSuccess, true);
});
2. Flexibility¶
Easy to swap implementations:
// Development: Use in-memory repository
final financialRepositoryProvider = Provider<IFinancialRepository>((ref) {
return InMemoryFinancialRepository();
});
// Production: Use SQLite repository
final financialRepositoryProvider = Provider<IFinancialRepository>((ref) {
return FinancialRepositoryImpl();
});
3. Maintainability¶
Changes are isolated to specific layers:
- UI redesign? Only touch Presentation layer
- Change database? Only touch Data layer
- New business rule? Only touch Domain layer
4. Scalability¶
Easy to add new features without affecting existing code:
New Feature:
1. Add entity to Domain
2. Add methods to repository interface
3. Implement in Data layer
4. Create use case
5. Add UI in Presentation layer
Common Patterns¶
Repository Pattern¶
Abstracts data access:
// Domain defines the contract
abstract class IFinancialRepository {
Future<List<Transaction>> getTransactions();
}
// Data implements the contract
class FinancialRepositoryImpl implements IFinancialRepository {
@override
Future<List<Transaction>> getTransactions() async {
// Implementation details
}
}
Use Case Pattern¶
Encapsulates business operations:
abstract class UseCase<T, Params> {
Future<Result<T>> call(Params params);
}
class GetTransactionsUseCase extends UseCase<List<Transaction>, NoParams> {
final IFinancialRepository repository;
GetTransactionsUseCase(this.repository);
@override
Future<Result<List<Transaction>>> call(NoParams params) async {
// Business logic here
}
}
Result Pattern¶
Type-safe error handling:
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final AppFailure failure;
const Failure(this.failure);
}
Anti-Patterns to Avoid¶
❌ Skipping Layers¶
// DON'T: UI directly accessing database
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final db = await AppDatabase.db; // ❌ Wrong!
final data = await db.query('transactions');
return ListView(...);
}
}
❌ Business Logic in UI¶
// DON'T: Business logic in widget
class MyScreen extends StatelessWidget {
void addTransaction() {
if (amount > 0 && category.isNotEmpty) { // ❌ Business logic!
// Save to database
}
}
}
❌ Domain Depending on Data¶
// DON'T: Use case depending on concrete repository
class MyUseCase {
final FinancialRepositoryImpl repository; // ❌ Concrete implementation!
}
// DO: Use case depending on interface
class MyUseCase {
final IFinancialRepository repository; // ✅ Interface!
}
Next Steps¶
- Project Structure - File organization
- Data Flow - How data moves through layers
- State Management - Riverpod patterns
- Adding Features - Practical guide