Skip to content
AB
6 min readIntermediatePractical Clean Architecture · Part 2

Clean Architecture in Flutter Without the Boilerplate

A practical Flutter architecture using models, repositories, view models, and widgets-with clear boundaries and only as much structure as the feature needs.

Ashish BhakhandPublished Updated

Clean Architecture becomes unhelpful when a small Flutter feature requires a maze of entities, data sources, repositories, use cases, mappers, DTOs, and interfaces before it can display a single value.

The underlying idea is much simpler: keep presentation, application logic, and external data concerns from collapsing into one place. Add a boundary when it protects something meaningful. Stop when the code is already clear.

This article builds a small products feature with four responsibilities:

  1. A model represents data the feature understands.
  2. A repository defines how the feature obtains that data.
  3. A view model owns the loading workflow and UI state.
  4. Widgets render state and forward user intent.

That structure follows the direction recommended in Flutter's architecture guide while leaving room to introduce use cases only when the business logic earns them.

Begin with the feature boundary#

A feature-oriented structure keeps related decisions close together:

Code
lib/
  features/
    products/
      data/
        api_product_repository.dart
      domain/
        product.dart
        product_repository.dart
      presentation/
        product_list_screen.dart
        product_list_view_model.dart

The exact folder names are negotiable. The important boundary is that presentation depends on a repository contract, while the API implementation stays at the edge.

Model the feature's language#

Start with the smallest model the application needs:

Code
class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
  });

  final String id;
  final String name;
  final double price;
}

This model is independent of Flutter widgets and transport details. If the API uses product_name or represents the price as a string, translate that at the data boundary.

Not every project needs separate API and domain models. Split them when their shapes, validation rules, or reasons to change are different. If they are genuinely identical and likely to stay that way, one model may be enough.

Put data access behind a repository#

The presentation layer needs products; it does not need to know whether they came from REST, Firebase, SQLite, or memory.

Code
abstract interface class ProductRepository {
  Future<List<Product>> fetchProducts();
}

The concrete implementation handles the API and mapping:

Code
class ApiProductRepository implements ProductRepository {
  ApiProductRepository(this._client);

  final ApiClient _client;

  @override
  Future<List<Product>> fetchProducts() async {
    final response = await _client.get('/products');
    final items = response.json as List<dynamic>;

    return items.map((item) {
      final json = item as Map<String, dynamic>;
      return Product(
        id: json['id'] as String,
        name: json['name'] as String,
        price: (json['price'] as num).toDouble(),
      );
    }).toList(growable: false);
  }
}

Now an API schema change is contained in one place. Tests can provide a fake repository without mocking an HTTP library.

Represent UI state explicitly#

Two booleans such as isLoading and hasError can accidentally describe impossible combinations. A sealed state makes the valid possibilities explicit.

Code
sealed class ProductListState {
  const ProductListState();
}

class ProductListLoading extends ProductListState {
  const ProductListLoading();
}

class ProductListLoaded extends ProductListState {
  const ProductListLoaded(this.products);

  final List<Product> products;
}

class ProductListFailed extends ProductListState {
  const ProductListFailed(this.message);

  final String message;
}

The view model coordinates the workflow:

Code
class ProductListViewModel extends ChangeNotifier {
  ProductListViewModel(this._repository);

  final ProductRepository _repository;

  ProductListState state = const ProductListLoading();

  Future<void> load() async {
    state = const ProductListLoading();
    notifyListeners();

    try {
      final products = await _repository.fetchProducts();
      state = ProductListLoaded(products);
    } catch (error) {
      state = const ProductListFailed(
        'We could not load products. Please try again.',
      );
    }

    notifyListeners();
  }
}

ChangeNotifier keeps the example framework-native. The same responsibilities can live in a Riverpod notifier, a BLoC, or another state-management tool. Architecture is about who owns each decision, not which package emits the state.

Keep widgets focused on presentation#

The screen observes state and maps each state to UI:

Code
class ProductListView extends StatelessWidget {
  const ProductListView({
    required this.viewModel,
    super.key,
  });

  final ProductListViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, _) {
        return switch (viewModel.state) {
          ProductListLoading() => const Center(
              child: CircularProgressIndicator(),
            ),
          ProductListLoaded(:final products) => ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) => ProductTile(
                product: products[index],
              ),
            ),
          ProductListFailed(:final message) => ErrorView(
              message: message,
              onRetry: viewModel.load,
            ),
        };
      },
    );
  }
}

The widget contains presentation decisions-loading indicator, list, error message-but no HTTP calls or response parsing. User intent flows back through methods such as load().

When to add a use case#

Calling the repository directly from a view model is reasonable when the workflow is simple. Add a use case when application behavior has its own weight, for example:

  • combining multiple repositories;
  • enforcing permissions or business rules;
  • sharing a workflow across multiple screens;
  • handling retries, caching, or transactional steps;
  • needing focused tests for the workflow itself.
Code
class LoadAvailableProducts {
  const LoadAvailableProducts(this._products);

  final ProductRepository _products;

  Future<List<Product>> call() async {
    final products = await _products.fetchProducts();
    return products.where((product) => product.price > 0).toList();
  }
}

Do not create a pass-through use case merely because a diagram contains a use-case circle. Add it when it gives a rule a clear home.

Small widgets, for the right reasons#

Large build methods become difficult to scan, reuse, and test. Extract a widget when it has a meaningful responsibility or identity:

Code
class ProductTile extends StatelessWidget {
  const ProductTile({required this.product, super.key});

  final Product product;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(product.name),
      trailing: Text('₹${product.price.toStringAsFixed(2)}'),
    );
  }
}

Widget classes provide useful names in diagnostics, accept keys, can have const constructors, and create clear rebuild boundaries. A small helper function that returns a widget is not inherently a performance bug, however. Extract based on clarity, identity, reuse, and profiling-not a blanket rule.

Test the seams that matter#

The repository boundary makes the view model easy to exercise:

Code
class FakeProductRepository implements ProductRepository {
  @override
  Future<List<Product>> fetchProducts() async {
    return const [
      Product(id: '1', name: 'Keyboard', price: 2499),
    ];
  }
}

void main() {
  test('load exposes products from the repository', () async {
    final viewModel = ProductListViewModel(FakeProductRepository());

    await viewModel.load();

    final state = viewModel.state as ProductListLoaded;
    expect(state.products.single.name, 'Keyboard');
  });
}

You can test the workflow without a widget tree, network, or plugin. Widget tests can focus on whether each state renders correctly.

A useful review checklist#

Before adding another layer, ask:

  • Can the business behavior be tested without Flutter or a real backend?
  • Does the UI know about JSON, status codes, or database documents?
  • Are invalid state combinations possible?
  • Does this abstraction protect a real source of change?
  • Can a new developer trace one user action without opening twenty files?

Good architecture improves the answers without making the feature exhausting to navigate.

Keep the boundaries, lose the ceremony#

A maintainable Flutter feature does not need the maximum number of layers. It needs a clear direction of dependency and an honest amount of structure.

Begin with a model, repository, state owner, and UI. Introduce use cases and additional models when real complexity appears. Keep widgets focused. Keep external formats at the edge. Test business behavior through small seams.

The best architecture is not the one that looks most impressive in a diagram. It is the one that lets your team change the product confidently six months later.

← Back to all writing