DEV Community

vmodal_ai
vmodal_ai

Posted on

Clean Architecture in Flutter for Large-Scale Applications

Clean Architecture in Flutter for Large-Scale Applications

As a Flutter application grows, putting networking, business rules, persistence, and UI logic into the same feature quickly becomes difficult to maintain.

Clean Architecture helps separate responsibilities so that business logic remains independent from frameworks and external services.

The Core Idea

A practical Flutter structure is:

Presentation
     |
     v
Domain
     |
     v
Data
     |
     v
External Services
Enter fullscreen mode Exit fullscreen mode

The dependency direction should point toward the domain:

UI -> BLoC -> Use Case -> Repository Interface
                              ^
                              |
                    Repository Implementation
                              |
                         API / Database
Enter fullscreen mode Exit fullscreen mode

Project Structure

For a large application, feature-first organization works well:

lib/
  core/
    error/
    network/
    storage/
    utils/

  features/
    authentication/
      data/
      domain/
      presentation/

    products/
      data/
      domain/
      presentation/

    orders/
      data/
      domain/
      presentation/
Enter fullscreen mode Exit fullscreen mode

Domain Layer

The domain layer contains business concepts and rules.

Example entity:

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}
Enter fullscreen mode Exit fullscreen mode

A use case represents one business action:

class GetUser {
  final UserRepository repository;

  GetUser(this.repository);

  Future<User> call(String id) {
    return repository.getUser(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

The domain layer should not depend on Flutter widgets, HTTP clients, or database implementations.

Repository Interfaces

Define contracts in the domain layer:

abstract class UserRepository {
  Future<User> getUser(String id);
}
Enter fullscreen mode Exit fullscreen mode

The domain knows what it needs, but not how the data is obtained.

Data Layer

The data layer implements the domain contract.

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remote;

  UserRepositoryImpl(this.remote);

  @override
  Future<User> getUser(String id) async {
    final model = await remote.getUser(id);
    return model.toEntity();
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps infrastructure details outside the business layer.

Data Sources

class UserRemoteDataSource {
  final ApiClient client;

  UserRemoteDataSource(this.client);

  Future<UserModel> getUser(String id) async {
    final json = await client.get('/users/$id');

    return UserModel.fromJson(json);
  }
}
Enter fullscreen mode Exit fullscreen mode

A local data source can implement the same idea:

class UserLocalDataSource {
  Future<UserModel?> getUser(String id) async {
    // Read from local database.
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Presentation Layer

The UI should communicate with application state rather than directly calling APIs.

For example:

User taps screen
      |
      v
BLoC event
      |
      v
Use Case
      |
      v
Repository
      |
      v
Data Source
      |
      v
API
Enter fullscreen mode Exit fullscreen mode

This makes UI components easier to test.

Dependency Injection

Dependencies should be assembled at the application boundary.

Conceptually:

final apiClient = ApiClient();
final remote = UserRemoteDataSource(apiClient);
final repository = UserRepositoryImpl(remote);
final getUser = GetUser(repository);
Enter fullscreen mode Exit fullscreen mode

A dependency injection package can automate this for larger projects.

Error Handling

Avoid leaking low-level exceptions throughout the application.

Create application-level failures:

sealed class Failure {
  const Failure();
}

class NetworkFailure extends Failure {
  const NetworkFailure();
}

class UnauthorizedFailure extends Failure {
  const UnauthorizedFailure();
}

class ServerFailure extends Failure {
  const ServerFailure();
}
Enter fullscreen mode Exit fullscreen mode

The presentation layer can then decide how to display each failure.

When Clean Architecture Helps

It is particularly valuable when you have:

  • multiple teams
  • many features
  • complex business rules
  • multiple data sources
  • offline support
  • long application lifetimes
  • extensive automated testing

For a tiny application, full Clean Architecture may add unnecessary ceremony.

Testing Strategy

Test each layer independently:

Entity / Use Case
      ↓
Repository
      ↓
Data Source
      ↓
BLoC
      ↓
Widget
Enter fullscreen mode Exit fullscreen mode

The domain layer can usually be tested without Flutter bindings.

Common Mistakes

Too many abstractions

Do not create interfaces simply because a tutorial says every class needs one.

Create abstractions where they provide real flexibility or testability.

Business logic inside widgets

Avoid:

onPressed: () async {
  final response = await http.get(...);
  // business logic
}
Enter fullscreen mode Exit fullscreen mode

Move this into a use case or application service.

Repository doing everything

A repository should coordinate data access, not become a giant business-logic class.

Conclusion

Clean Architecture is most useful when it helps a large application remain understandable as requirements grow.

The goal is not to create the maximum number of folders. The goal is to isolate business rules from UI and infrastructure so that each part can evolve independently.

Useful Links

SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutter

SDK Android: https://github.com/v-modal/vmodal_sdk_android

Discord: https://discord.gg/K72z28KUx

Top comments (0)