flutter
/

GetX Clean Architecture: Build Scalable Flutter Apps

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

GetX Clean Architecture: Build Scalable Flutter Apps

What is Clean Architecture?

Clean Architecture is a software design philosophy that separates the concerns of an application into distinct layers, making it more maintainable, testable, and scalable. The core idea is that the inner layers (business logic) should be independent of the outer layers (UI, frameworks, databases). GetX, with its dependency injection and state management, is a perfect fit for implementing Clean Architecture in Flutter.

Layers of Clean Architecture

  • Presentation Layer – UI components, pages, and state management (GetX controllers, widgets).
  • Domain Layer – Business logic: use cases, entities, and repository interfaces. This layer is framework‑agnostic.
  • Data Layer – Implementation of repositories: API clients, local databases, and data sources.

Dependency Rule

Dependencies point inward. The presentation layer depends on the domain layer, and the domain layer depends on the data layer only through abstract interfaces. This allows you to swap implementations (e.g., a mock repository for testing) without affecting the domain or presentation.

Folder Structure

TEXTRead-only
1
lib/
├── main.dart
├── app/
│   ├── routes/
│   │   └── app_pages.dart
│   ├── bindings/
│   │   └── app_bindings.dart
│   └── utils/
│       └── constants.dart
├── presentation/
│   ├── pages/
│   │   ├── home/
│   │   │   ├── home_page.dart
│   │   │   └── home_controller.dart
│   │   └── login/
│   │       ├── login_page.dart
│   │       └── login_controller.dart
│   └── widgets/
│       └── custom_button.dart
├── domain/
│   ├── entities/
│   │   └── user.dart
│   ├── repositories/
│   │   └── i_user_repository.dart
│   └── usecases/
│       ├── login_use_case.dart
│       └── get_user_use_case.dart
└── data/
    ├── repositories/
    │   └── user_repository_impl.dart
    ├── datasources/
    │   ├── remote/
    │   │   ├── api_service.dart
    │   │   └── user_api.dart
    │   └── local/
    │       └── local_storage.dart
    └── models/
        └── user_model.dart

Implementing with GetX

Define entities (pure Dart classes) and repository interfaces (abstract classes). Use cases contain the business logic and depend on the repository interfaces.

DARTRead-only
1
// domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;
  User({required this.id, required this.name, required this.email});
}

// domain/repositories/i_user_repository.dart
abstract class IUserRepository {
  Future<User> login(String email, String password);
  Future<User> getUser(String id);
}

// domain/usecases/login_use_case.dart
class LoginUseCase {
  final IUserRepository repository;
  LoginUseCase(this.repository);

  Future<User> execute(String email, String password) async {
    // Business logic (e.g., validation) can be added here
    if (email.isEmpty || password.isEmpty) {
      throw Exception('Email and password are required');
    }
    return await repository.login(email, password);
  }
}

Implement the repository interface, using data sources (API, database). Data sources can be GetX services.

DARTRead-only
1
// data/datasources/remote/user_api.dart
class UserApi extends GetxService {
  Future<User> login(String email, String password) async {
    // simulate API call
    await Future.delayed(Duration(seconds: 1));
    return User(id: '1', name: 'John Doe', email: email);
  }
}

// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements IUserRepository {
  final UserApi api;
  UserRepositoryImpl(this.api);

  @override
  Future<User> login(String email, String password) async {
    return await api.login(email, password);
  }
}

Controllers use use cases and expose reactive state. Pages use Obx to react to state changes.

DARTRead-only
1
// presentation/login/login_controller.dart
class LoginController extends GetxController {
  final LoginUseCase loginUseCase;
  LoginController(this.loginUseCase);

  var isLoading = false.obs;
  var error = ''.obs;

  Future<void> login(String email, String password) async {
    isLoading.value = true;
    error.value = '';
    try {
      final user = await loginUseCase.execute(email, password);
      Get.snackbar('Success', 'Welcome ${user.name}');
      // navigate to home
    } catch (e) {
      error.value = e.toString();
    } finally {
      isLoading.value = false;
    }
  }
}

// presentation/login/login_page.dart
class LoginPage extends StatelessWidget {
  final controller = Get.find<LoginController>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      body: Obx(() => Column(
        children: [
          if (controller.error.isNotEmpty) Text(controller.error),
          if (controller.isLoading.value) CircularProgressIndicator(),
          ElevatedButton(
            onPressed: () => controller.login('test@example.com', 'pass'),
            child: Text('Login'),
          ),
        ],
      )),
    );
  }
}

Use GetX bindings to wire everything together. The binding registers the repository implementation and use cases.

DARTRead-only
1
// presentation/login/login_binding.dart
class LoginBinding extends Bindings {
  @override
  void dependencies() {
    // Data layer
    Get.lazyPut<UserApi>(() => UserApi());
    Get.lazyPut<IUserRepository>(() => UserRepositoryImpl(Get.find()));
    // Domain layer
    Get.lazyPut(() => LoginUseCase(Get.find<IUserRepository>()));
    // Presentation layer
    Get.lazyPut(() => LoginController(Get.find()));
  }
}

// In routes
GetPage(
  name: '/login',
  page: () => LoginPage(),
  binding: LoginBinding(),
);

Comparison: Clean Architecture with GetX vs Standard GetX

AspectGetX Clean ArchitectureStandard GetX
Separation of concernsHigh (3 layers + use cases)Medium (UI + controller + service)
TestabilityExcellent (domain isolated)Good (services mockable)
ScalabilityVery highMedium to high
Learning curveSteeperGentle
BoilerplateModerateMinimal
Best forLarge apps, teamsSmall to medium apps

Benefits of This Approach

  • Separation of concerns – UI, business logic, and data are isolated.
  • Testability – Each layer can be unit‑tested independently (mock repositories for domain).
  • Flexibility – Swap data sources (API → local) without changing domain or UI.
  • Scalability – Adding new features follows the same pattern, reducing cognitive load.
  • GetX integration – Use GetX for DI, navigation, and reactive state without boilerplate.

Best Practices

  • Keep domain pure – No Flutter or GetX imports in domain layer (except pure Dart).
  • Use interfaces for repositories – Allows easy mocking and multiple implementations.
  • Make use cases single‑purpose – One use case per business operation (e.g., LoginUseCase, GetUserUseCase).
  • Inject dependencies via constructor – Makes testing and replacement trivial.
  • Use bindings for DI – Avoid scattering Get.put inside views.
  • Use Get.lazyPut for dependencies that may not be used immediately – Improves startup performance.

Common Mistakes

  • ❌ Putting business logic in controllers – Controllers should only orchestrate use cases. ✅ Move logic to use cases.
  • ❌ Directly calling data sources from presentation – Violates dependency rule. ✅ Always go through use cases.
  • ❌ Tight coupling with GetX in domain – Reduces testability and flexibility. ✅ Keep domain GetX‑free.
  • ❌ Ignoring dependency injection – Hardcoding dependencies makes the code rigid. ✅ Use GetX DI or constructor injection.
  • ❌ Creating huge use cases – Violates single responsibility. ✅ Split into smaller use cases.

Conclusion

Combining GetX with Clean Architecture gives you a robust, testable, and maintainable Flutter application. The clear separation of layers, together with GetX's powerful DI and state management, allows you to focus on business logic while keeping the UI clean and reactive.

Try it yourself

import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() => runApp(MyApp());

// --- Domain Layer ---
class User {
  final String name;
  User(this.name);
}

abstract class IUserRepository {
  Future<User> login(String email, String password);
}

class LoginUseCase {
  final IUserRepository repository;
  LoginUseCase(this.repository);

  Future<User> execute(String email, String password) async {
    if (email.isEmpty || password.isEmpty) {
      throw Exception('Email and password required');
    }
    return await repository.login(email, password);
  }
}

// --- Data Layer ---
class FakeUserRepository implements IUserRepository {
  @override
  Future<User> login(String email, String password) async {
    await Future.delayed(Duration(seconds: 1));
    if (email == 'test@example.com' && password == '123') {
      return User('John Doe');
    } else {
      throw Exception('Invalid credentials');
    }
  }
}

// --- Presentation Layer ---
class LoginController extends GetxController {
  final LoginUseCase loginUseCase;
  LoginController(this.loginUseCase);

  var isLoading = false.obs;
  var error = ''.obs;

  Future<void> login(String email, String password) async {
    isLoading.value = true;
    error.value = '';
    try {
      final user = await loginUseCase.execute(email, password);
      Get.snackbar('Success', 'Welcome ${user.name}');
    } catch (e) {
      error.value = e.toString();
    } finally {
      isLoading.value = false;
    }
  }
}

// Binding
class LoginBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut<IUserRepository>(() => FakeUserRepository());
    Get.lazyPut(() => LoginUseCase(Get.find()));
    Get.lazyPut(() => LoginController(Get.find()));
  }
}

// Page
class LoginPage extends StatelessWidget {
  final controller = Get.find<LoginController>();
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Clean Architecture Demo')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(controller: emailController, decoration: InputDecoration(labelText: 'Email')),
            TextField(controller: passwordController, decoration: InputDecoration(labelText: 'Password'), obscureText: true),
            SizedBox(height: 20),
            Obx(() => controller.isLoading.value
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: () => controller.login(emailController.text, passwordController.text),
                    child: Text('Login'),
                  )),
            Obx(() => controller.error.isNotEmpty ? Text(controller.error, style: TextStyle(color: Colors.red)) : SizedBox.shrink()),
          ],
        ),
      ),
    );
  }
}

// App
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      initialRoute: '/login',
      getPages: [
        GetPage(name: '/login', page: () => LoginPage(), binding: LoginBinding()),
      ],
    );
  }
}

Test Your Knowledge

Q1
of 3

Which layer in Clean Architecture should be completely independent of frameworks like Flutter?

A
Presentation
B
Domain
C
Data
D
All layers
Q2
of 3

What is the main responsibility of a use case?

A
Fetch data from API
B
Display data on screen
C
Encapsulate a single business action
D
Manage dependency injection
Q3
of 3

How do you inject dependencies in GetX following Clean Architecture?

A
Using `Get.put` directly in pages
B
Using bindings and constructor injection
C
Using global variables
D
Using static methods

Frequently Asked Questions

Is Clean Architecture overkill for small apps?

It can be, but the principles (separation of concerns) always help. For very small apps, a simpler MVVM with GetX may suffice, but Clean Architecture scales seamlessly.

How do I handle state with GetX in this architecture?

Controllers hold reactive state and call use cases. The UI observes the state with Obx.

Can I use GetStorage or shared preferences in the data layer?

Yes, they belong in the data layer, accessed via repository implementations.

How to test the domain layer?

Create a mock repository that implements IUserRepository and pass it to the use case. No GetX needed.

Where do I put navigation logic?

In controllers, after a successful use case execution, use Get.toNamed or similar.

How do I handle complex dependency chains?

Use GetX bindings to wire everything. For services that depend on each other, you can use Get.find inside the binding.

Previous

getx storage

Next

getx getview getwidget

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.