Shuhaib
Flutter والمعمارية البرمجية
Aug 14, 20268 دقائق قراءة

بناء مصادقة Flutter باستخدام المعمارية النظيفة — من التفكير المنطقي إلى الاختبارات

بدلاً من مجرد نسخ بنية المجلدات، تعلم لماذا وُجدت Clean Architecture عبر البدء بتسجيل دخول بسيط عبر Firebase وحل مشاكل برمجية حقيقية: تبديل الواجهات الخلفية، عزل الأخطاء، واختبارات الوحدة في أقل من 100 ميلي ثانية.

SK

Shuhaib KT

Flutter & Mobile Engineer

الشروحات البرمجية والتحليلات العميقة للكود أدناه معروضة بصيغتها الإنجليزية الأصلية لضمان الدقة التقنية.

Most architecture tutorials start with a finished diagram: 'Clean Architecture has Presentation, Domain, and Data layers. Here is what each folder means.' But in production engineering, architecture isn't created by copying folder structures—it is forced upon us by real problems.

In this post, we will build a complete Flutter authentication flow by starting with a naive Firebase implementation, encountering friction as requirements change, and asking: What happens if Firebase changes? How do we swap backends? How do we test this without hitting live servers? Each question will force us to introduce another clean boundary.

1. The Problem First: Starting Naively

When starting a new Flutter project, the fastest way to build login is calling Firebase directly inside your UI widget:

dart
// Naive implementation directly inside Flutter Widget
onPressed: () async {
  try {
    final credential = await FirebaseAuth.instance.signInWithEmailAndPassword(
      email: emailController.text,
      password: passwordController.text,
    );
    Navigator.pushReplacementNamed(context, '/home');
  } on FirebaseAuthException catch (e) {
    if (e.code == 'invalid-credential') {
      showToast('Invalid credentials provided');
    }
  }
}

While this works for a weekend prototype, it creates severe production friction as your app grows:

  • Tight Coupling: Your UI widget is directly bound to Firebase SDK internals.
  • Backend Migration Nightmare: If your team migrates to a custom Node.js/Go REST API (Dio) or Supabase next year, you have to rewrite half your UI widgets.
  • Leaky Abstractions: Firebase-specific error strings ('invalid-credential') contaminate your UI presentation layer.
  • Impossible to Unit Test: You cannot unit test login logic without initializing native Firebase apps and running real network requests.

2. Defining the Domain Contract

To decouple business rules from infrastructure details, we create a pure Dart Domain layer containing no Flutter or third-party SDK dependencies:

text
Domain Layer Structure
├── Entity           (Core business objects like User)
├── Value Objects    (Encapsulated primitives like Email & Password)
├── Failures         (Pure business error representations)
├── Repository       (Abstract interface contracts)
└── UseCases         (Executable application operations)

Why does the Domain layer own the Repository interface? According to the Dependency Inversion Principle, high-level business rules should not depend on low-level database details. Both must depend on abstractions defined by the business domain.

3. Value Objects: Preventing Invalid State

Instead of passing raw `String` variables into business logic, we encapsulate validation rules into self-validating Value Objects. A string like `'invalid-email'` should never even reach a use case or network request:

dart
class Email {
  final String value;
  private Email._(this.value);

  static Result<Email, EmailFailure> create(String input) {
    if (input.isEmpty) return Result.failure(EmailFailure.empty());
    if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(input)) {
      return Result.failure(EmailFailure.invalidFormat());
    }
    return Result.success(Email._(input));
  }
}

4. UseCase: Orchestrating Business Validation

The `LoginUseCase` coordinates inputs and enforces validation sequencing before calling the repository contract:

dart
class LoginUseCase {
  final AuthenticationRepository repository;
  LoginUseCase(this.repository);

  Future<Result<User, AuthenticationFailure>> execute(String rawEmail, String rawPassword) async {
    final emailResult = Email.create(rawEmail);
    final passwordResult = Password.create(rawPassword);

    // AND reasoning: Both Email and Password MUST be valid before hitting repository
    if (emailResult.isFailure) return Result.failure(AuthenticationFailure.invalidEmail());
    if (passwordResult.isFailure) return Result.failure(AuthenticationFailure.invalidPassword());

    return await repository.login(
      email: emailResult.value,
      password: passwordResult.value,
    );
  }
}

5. Data Layer & Dependency Inversion

Now we build the Data layer adapters that implement the Domain contract. Contrast how easy it is to swap a Firebase SDK with a REST API backend:

text
                ┌────────────────────────────────────────┐
                │        Domain Layer (Pure Dart)        │
                │         AuthenticationRepository       │
                └───────────────────▲────────────────────┘
                                    │ (Implements Contract)
                ┌───────────────────┴────────────────────┐
                │         Data Layer (Adapters)          │
                │     AuthenticationRepositoryImpl       │
                └───────┬────────────────────────┬───────┘
                        │                        │
       ┌────────────────┴──────┐       ┌─────────┴──────────────┐
       │ FirebaseDataSource    │  OR   │ RestApiDataSource      │
       │ (FirebaseAuth SDK)    │       │ (Dio / HTTP Client)    │
       └───────────────────────┘       └────────────────────────┘

6. DTO Model vs Domain Entity

Backend APIs return specific JSON schemas or SDK user objects (`FirebaseUser` or HTTP JSON map). We deserialize payloads into a `UserModel` in the data layer and map it to a pure `User` domain entity:

dart
// Data Layer Model
class UserModel {
  final String uid;
  final String email;
  UserModel({required this.uid, required this.email});

  User toDomain() => User(id: uid, email: email);
}

7. Exception → Failure Mapping

Firebase throws `FirebaseAuthException` with code `'invalid-credential'`, while REST APIs return `HTTP 401 Unauthorized`. Neither string codes nor HTTP status numbers belong in Domain or UI code. The repository maps infrastructure exceptions into clean domain failures:

dart
// Repository Implementation Exception Mapping
try {
  final userModel = await remoteDataSource.login(email, password);
  return Result.success(userModel.toDomain());
} on FirebaseAuthException catch (e) {
  if (e.code == 'invalid-credential') {
    return Result.failure(AuthenticationFailure.invalidCredentials());
  }
  return Result.failure(AuthenticationFailure.serverError());
} on DioException catch (e) { // Easily handles REST API status 401
  if (e.response?.statusCode == 401) {
    return Result.failure(AuthenticationFailure.invalidCredentials());
  }
  return Result.failure(AuthenticationFailure.networkError());
}

8. Testing the Architecture without Live Backends

Because our domain rules and use cases depend on pure Dart interfaces, we can write unit tests that execute in under 100ms using a `FakeAuthenticationRepository` without initializing Firebase or spinning up servers:

dart
void main() {
  test('should return invalidEmail failure when email input is malformed', () async {
    final fakeRepo = FakeAuthenticationRepository();
    final useCase = LoginUseCase(fakeRepo);

    final result = await useCase.execute('invalid-email-string', 'Password123!');

    expect(result.isFailure, true);
    expect(fakeRepo.wasLoginCalled, false); // Proves network wasn't even touched!
  });
}

9. Repository Unit Tests with Remote Data Source Fakes

Next, we test that `AuthenticationRepositoryImpl` correctly transforms raw network exceptions into domain failures:

dart
test('should map invalid-credential exception to AuthenticationFailure.invalidCredentials', () async {
  final fakeDataSource = FakeRemoteDataSource(shouldThrowInvalidCredentials: true);
  final repo = AuthenticationRepositoryImpl(fakeDataSource);

  final result = await repo.login(validEmail, validPassword);

  expect(result.failure, isA<InvalidCredentialsFailure>());
});

10. CI/CD Integration & Engineering Summary

By decoupling each layer, your CI/CD pipeline runs hundreds of unit tests in seconds on every `git push`, blocking invalid code before it ever reaches staging or production builds.

“Architecture isn't about memorizing folder structures. It's about building software where every boundary is justified by a real problem—making code easy to change, resilient to backend shifts, and effortless to test.”
الوسوم:#Flutter#Clean Architecture#Testing#Firebase#REST API#Dart
SK

بقلم Shuhaib KT

Flutter & Mobile Engineer

مهندس Full Stack متخصص في تطوير الواجهات الخلفية السحابية القابلة للتوسع (FastAPI/NestJS/AWS)، ومواقع Next.js الفائقة السرعة، وتطبيقات الهاتف الذكي المعمارة بنظام Offline-First عبر Flutter.

مقالات ذات صلة