Clean Architecture — Flutter المتقدم
المعمارية النظيفة (Clean Architecture) في Flutter
تطبيق صغير يعمل جيداً بكل شيء داخل الـ Widgets. لكن عندما يكبر: شاشة تستدعي API مباشرة، وتحفظ في قاعدة بيانات، وتحسب الأسعار، وتعرض النتائج — يصبح أي تعديل مخيفاً ولا يمكن اختبار المنطق دون تشغيل الواجهة. المعمارية تحدد من يعرف من.
الطبقات الثلاث وقاعدة الاعتماد
Presentation (Widgets + إدارة الحالة)
│ تعتمد على
▼
Domain (الكيانات + حالات الاستخدام + واجهات المستودعات) ← لا تعرف Flutter ولا HTTP
▲ تنفّذ واجهاتها
│
Data (نماذج JSON + مصادر البيانات + تنفيذ المستودعات)قاعدة الاعتماد: الطبقات الخارجية تعتمد على الداخلية، لا العكس. Domain في المركز ولا يستورد شيئاً من Data أو Flutter؛ لذلك يمكن اختباره بـ Dart نقي وبسرعة.
مكونات كل طبقة
| الطبقة | المكوّن | المثال |
|---|---|---|
| Domain | Entity | User — كائن العمل النقي |
| Domain | Repository (واجهة) | abstract class UserRepository — "ماذا" نحتاج دون "كيف" |
| Domain | Use Case | GetUser — عملية عمل واحدة |
| Data | Model | UserModel مع fromJson/toJson |
| Data | Data Source | UserRemoteDataSource (HTTP)، UserLocalDataSource (تخزين محلي) |
| Data | Repository (تنفيذ) | يقرر: من الشبكة أم من الذاكرة المحلية؟ ويحوّل الاستثناءات إلى أخطاء مفهومة |
| Presentation | الحالة | Riverpod/Bloc يستدعي Use Cases ويعرض النتيجة |
الأخطاء كقيم: Either
المثال الكامل يعيد Either<Failure, User> (من حزمة dartz أو fpdart): إما فشل معروف النوع أو نتيجة — بدل رمي استثناءات تعبر الطبقات. الواجهة تُجبر على معالجة الحالتين:
final result = await getUser('42');
result.fold(
(failure) => showError(failure.message),
(user) => showProfile(user),
);أضف إلى pubspec: dartz (أو fpdart)، وعرّف Failure في core/error.
حقن الاعتماديات
المستودع يحتاج مصدر بيانات، وحالة الاستخدام تحتاج المستودع. بدل إنشائها داخل الـ Widgets، نسجّلها في مكان واحد (get_it أو Providers في Riverpod) ونمرر الواجهات. في الاختبارات نمرر تنفيذاً وهمياً.
هل أحتاج كل هذا؟
لا دائماً. تطبيق من ثلاث شاشات لا يحتاج 15 مجلداً. ابدأ بفصل بسيط: واجهة / منطق / بيانات، وأضف الطبقات عندما يكبر التطبيق ويكثر المطورون. التنظيم حسب الميزة (features/auth، features/cart) أفضل من حسب النوع عند الكبر.
تمرين
أضف إلى المثال حالة استخدام GetAllUsers واكتب لها اختباراً بـ Dart نقي يمرر مستودعاً وهمياً يعيد قائمة ثابتة — دون أي Widget.
// ===== Domain Layer =====
// Entity
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// Repository Interface
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String id);
Future<Either<Failure, List<User>>> getAllUsers();
}
// Use Case
class GetUser {
final UserRepository repository;
GetUser(this.repository);
Future<Either<Failure, User>> call(String id) {
return repository.getUser(id);
}
}
// ===== Data Layer =====
// Model (يرث من Entity)
class UserModel extends User {
UserModel({
required String id,
required String name,
required String email,
}) : super(id: id, name: name, email: email);
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
}
// Repository Implementation
class UserRepositoryImpl implements UserRepository {
final RemoteDataSource remoteDataSource;
final LocalDataSource localDataSource;
final NetworkInfo networkInfo;
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> getUser(String id) async {
if (await networkInfo.isConnected) {
try {
final user = await remoteDataSource.getUser(id);
await localDataSource.cacheUser(user);
return Right(user);
} on ServerException {
return Left(ServerFailure());
}
} else {
try {
final user = await localDataSource.getCachedUser(id);
return Right(user);
} on CacheException {
return Left(CacheFailure());
}
}
}
}
// ===== Presentation Layer =====
// BLoC
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUser getUser;
UserBloc({required this.getUser}) : super(UserInitial()) {
on<GetUserEvent>((event, emit) async {
emit(UserLoading());
final result = await getUser(event.userId);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
});
}
}اختبر فهمك