Clean Architecture في Flutter: كيف تفصل الطبقات دون تعقيد زائد
كثير من تطبيقات Flutter تبدأ نظيفة، ثم تتحول مع الوقت إلى ملفات Widget ضخمة تحتوي على استدعاءات الشبكة وتحويل البيانات وقواعد العمل في مكان واحد. عندها يصبح أي تعديل بسيط محفوفًا بالمخاطر، واختبار المنطق شبه مستحيل دون تشغيل الواجهة. فكرة Clean Architecture موجودة لحل هذه المشكلة بالتحديد، لكنها تُطبَّق كثيرًا بشكل مبالغ فيه. في هذا المقال سنأخذ النسخة العملية منها: ما الذي نفصله، ولماذا، ومتى نتوقف.
المشكلة التي نحلّها فعلًا
الهدف ليس أن يكون عندك مجلدات كثيرة، بل أن تتغير أجزاء التطبيق لأسباب مختلفة دون أن تكسر بعضها. شكل الشاشة يتغير لأن التصميم تغير. شكل الـ JSON يتغير لأن الـ backend تغير. قاعدة العمل تتغير لأن المنتج تغير. إذا كان كل ذلك داخل Widget واحد، فكل تغيير يلمس كل شيء. فصل الطبقات معناه أن كل سبب للتغيير له مكان واحد واضح.
القاعدة الأساسية: الاعتماديات تشير إلى الداخل
القاعدة الوحيدة التي تستحق أن تحفظها هي: الكود الداخلي (قواعد العمل) لا يعرف شيئًا عن الكود الخارجي (الواجهة، الشبكة، قاعدة البيانات). الواجهة تعتمد على المنطق، والتنفيذ الفعلي للبيانات يعتمد على عقد (interface) يعرّفه المنطق، وليس العكس. بهذا تستطيع استبدال مصدر البيانات أو اختبار المنطق بـ Fake بسيط دون تشغيل أي شيء حقيقي.
الطبقات: ثلاث تكفي لمعظم التطبيقات
دليل المعمارية الرسمي في توثيق Flutter يقسّم التطبيق إلى طبقة واجهة (UI)، وطبقة بيانات (Data)، وطبقة Domain اختيارية بينهما. سنستخدم هذا التقسيم لأنه عملي ومعتمد من الفريق نفسه.
طبقة الواجهة: تتكون من View وهي الـ Widgets التي تعرض الحالة وتمرر أحداث المستخدم، و ViewModel الذي يحوّل البيانات إلى حالة جاهزة للعرض ويعرّض أوامر (commands) تستدعيها الواجهة. الـ View لا تحتوي منطق عمل، فقط شروط بسيطة للإظهار والإخفاء والتخطيط والأنيميشن.
طبقة البيانات: تتكون من Repository وهو المصدر الوحيد للحقيقة (single source of truth) لنوع معين من البيانات، ويتولى التخزين المؤقت ومعالجة الأخطاء وإعادة المحاولة، ويُخرج نماذج Domain. وفوقه Service يغلّف مصدر بيانات واحدًا مثل REST API أو ملف محلي، ولا يحتفظ بحالة.
طبقة Domain (اختيارية): نماذج الأعمال الصافية، وعند الحاجة Use Cases. لا تضفها لمجرد أنها موجودة في الرسومات التوضيحية، وسنعود لهذه النقطة بعد المثال.
مثال عملي بلغة Dart
المثال التالي توضيحي ومبسّط (قائمة منتجات)، وليس كودًا منسوخًا من مشروع بعينه. نبدأ بالـ Domain: النموذج والعقد.
class Product {
const Product({
required this.id,
required this.name,
required this.priceInCents,
});
final String id;
final String name;
final int priceInCents;
}
// العقد: الـ Domain يعرّفه، والـ Data تنفّذه.
abstract interface class ProductRepository {
Future<List<Product>> getProducts();
}لاحظ أن هذا الملف لا يستورد أي حزمة: لا flutter ولا http. هذا هو المقصود. الآن طبقة البيانات: الـ DTO يعرف شكل الـ JSON، والـ Service يتحدث مع الشبكة، والـ Repository يربط بينهما ويُخرج نموذج Domain.
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../domain/product.dart';
class ProductDto {
const ProductDto({
required this.id,
required this.name,
required this.priceInCents,
});
factory ProductDto.fromJson(Map<String, dynamic> json) => ProductDto(
id: json['id'] as String,
name: json['name'] as String,
priceInCents: json['price_in_cents'] as int,
);
final String id;
final String name;
final int priceInCents;
Product toDomain() =>
Product(id: id, name: name, priceInCents: priceInCents);
}
class ProductApiService {
ProductApiService(this._client, this._baseUrl);
final http.Client _client;
final String _baseUrl;
Future<List<ProductDto>> fetchProducts() async {
final response = await _client.get(Uri.parse('$_baseUrl/products'));
if (response.statusCode != 200) {
throw Exception('Failed to load products: ${response.statusCode}');
}
final list = jsonDecode(response.body) as List<dynamic>;
return [
for (final item in list)
ProductDto.fromJson(item as Map<String, dynamic>),
];
}
}
class ProductRepositoryImpl implements ProductRepository {
ProductRepositoryImpl(this._service);
final ProductApiService _service;
@override
Future<List<Product>> getProducts() async {
final dtos = await _service.fetchProducts();
return dtos.map((dto) => dto.toDomain()).toList();
}
}إذا غيّر الـ backend اسم الحقل من price_in_cents إلى شيء آخر، فالتعديل في ملف الـ DTO فقط. لا الـ ViewModel ولا الـ Widget يعرفان بما حدث. الآن طبقة الواجهة: الـ ViewModel يعتمد على العقد وليس على التنفيذ.
import 'package:flutter/foundation.dart';
import '../domain/product.dart';
class ProductsViewModel extends ChangeNotifier {
ProductsViewModel(this._repository);
final ProductRepository _repository;
List<Product> products = const [];
bool isLoading = false;
Object? error;
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
try {
products = await _repository.getProducts();
} catch (e) {
error = e;
} finally {
isLoading = false;
notifyListeners();
}
}
}أما الـ View فتستمع للـ ViewModel وتعرض الحالة فقط، ولا تعرف شيئًا عن http أو JSON. وفي نقطة بداية التطبيق (composition root) تُنشئ الكائنات وتمررها: Service ثم Repository ثم ViewModel، سواء بالتمرير اليدوي في الـ constructor أو بأداة حقن اعتماديات تفضّلها. الأداة هنا تفصيل، والمهم هو اتجاه الاعتماد.
والفائدة الفورية هي الاختبار: بدل مكالمة شبكة حقيقية تكتب Fake للعقد وتختبر الـ ViewModel في ثوانٍ.
class FakeProductRepository implements ProductRepository {
FakeProductRepository({this.shouldFail = false});
final bool shouldFail;
@override
Future<List<Product>> getProducts() async {
if (shouldFail) throw Exception('network down');
return const [Product(id: '1', name: 'Sample', priceInCents: 1000)];
}
}
void main() {
test('load exposes products', () async {
final vm = ProductsViewModel(FakeProductRepository());
await vm.load();
expect(vm.products, hasLength(1));
expect(vm.error, isNull);
});
test('load exposes error on failure', () async {
final vm = ProductsViewModel(FakeProductRepository(shouldFail: true));
await vm.load();
expect(vm.products, isEmpty);
expect(vm.error, isNotNull);
});
}متى تضيف Use Case ومتى لا؟
في المثال السابق لم نكتب Use Case، وهذا مقصود. لو كان الـ Use Case يستدعي دالة واحدة في الـ Repository ثم يرجع نتيجتها كما هي، فهو طبقة إضافية بلا قيمة. التوثيق الرسمي نفسه يصف طبقة الـ Domain بأنها اختيارية، ويقترحها عندما يدمج المنطق بيانات من أكثر من Repository، أو يكون معقدًا جدًا، أو يُعاد استخدامه في أكثر من ViewModel.
قاعدة قرار بسيطة: ابدأ بدون Use Cases. عندما تجد نفسك تنسخ نفس المنطق بين ViewModelين، أو يحتاج ViewModel إلى دمج Repositoryين، أنشئ Use Case حينها. تذكّر أيضًا أن الـ Repositories لا يعتمد بعضها على بعض؛ الدمج يحدث في الـ ViewModel أو في Use Case.
أخطاء شائعة
أولًا، تمرير الـ DTO مباشرة إلى الواجهة. هذا يربط شكل الـ JSON بالشاشة ويلغي فائدة الفصل. حوّل دائمًا إلى نموذج Domain عند حدود الـ Repository.
ثانيًا، وضع منطق العمل داخل الـ Widget، مثل حساب خصم أو التحقق من صلاحية الطلب داخل build. مكانه الـ ViewModel أو الـ Domain.
ثالثًا، تطبيق كل الطبقات على كل ميزة بالتساوي. شاشة إعدادات بسيطة لا تحتاج ما تحتاجه عملية دفع. قرّر مستوى الفصل حسب تعقيد الميزة وتكلفة الخطأ فيها.
رابعًا، خلط الاعتماديات: أن يستورد ملف الـ Domain حزمة flutter أو http. لو حدث ذلك فقد انكسرت القاعدة الأساسية، وهي أسهل مخالفة تُكتشف بنظرة على الـ imports.
تنظيم المجلدات
ترتيب المجلدات تفصيل ثانوي، لكن تنظيمها حسب الميزة (feature-first) يسهّل الصيانة غالبًا، لأن كل ما يخص الميزة في مكان واحد. هذا مثال توضيحي:
lib/
features/
products/
domain/
product.dart
data/
product_repository_impl.dart
ui/
products_view_model.dart
products_screen.dart
main.dart # composition root: يجمع الكائنات ويمررهاالخلاصة
Clean Architecture في Flutter ليست قالبًا يجب نسخه، بل قاعدة واحدة: المنطق لا يعتمد على التفاصيل الخارجية. اجعل الواجهة تعرض فقط، والـ ViewModel يحوّل الحالة، والـ Repository يملك البيانات، والـ Domain نقيًا بلا حزم. أضف Use Case عندما يظهر سبب حقيقي، لا قبل ذلك. وإذا أردت التوسع، ابدأ بدليل المعمارية الرسمي في توثيق Flutter ثم كيّفه على حجم مشروعك.