Skip to content

The presentation layer of each feature is responsible for defining the user interface and glueing everything together to display that UI. Generally, that will be a series of Flutter widgets. However, since the domain and data layers are headless, you could adapt it for a CLI app as well.

In the context of a Flutter application, the presentation layer will:

  • Define an entry point for the feature, or parts of the feature.
  • Define the various screens and widgets needed to implement a feature.
  • Define the blocs that tie your domain layer to your widgets.

Each feature, or part of a feature, should be defined by a module. If a feature has many screens, or complex subcomponents, each one can define its own module. A module is responsible for defining and setting up the dependencies required by a feature, or part of a feature. This achieves three goals:

  1. Each module clearly defines all of its dependencies. No surprises.
  2. It moves registration of dependency injection for that part of a feature to a common location.
  3. It enables the use of deferred imports, which are important for splitting web bundles into smaller parts and enabling Android dynamic modules.

Construct the dependencies with Provider. Several packages can achieve the goals above, and we standardize on one so that every module in every feature reads the same way. A module looks like this:

/// The module that loads the cart screen and its dependencies.
class CartListModule extends StatelessWidget {
/// Constructs the module that loads a user's cart.
const CartListModule({
required this.id,
required this.cartsRepository,
required this.productsRepository,
required this.onProductTapped,
required this.onCheckoutStarted,
super.key,
});
/// The repository for carts.
final ICartsRepository cartsRepository;
/// The repository for products.
final IProductsRepository productsRepository;
/// The id of the cart to load.
final String id;
/// Called when the user taps a product. The app decides where that goes.
final void Function(String productId) onProductTapped;
/// Called when the user starts checkout.
final VoidCallback onCheckoutStarted;
@override
Widget build(BuildContext context) {
// The carts and products repositories are used on many screens, so assume
// they have been constructed in a Provider at a level above.
return Provider(
create: (context) => GetCartByIdQuery(
cartsRepository: cartsRepository,
productsRepository: productsRepository,
),
child: BlocProvider<CartListCubit>(
create: (context) {
return CartListCubit(
getCartByIdQuery: context.read(),
cartsRepository: cartsRepository,
);
},
child: CartListScreen(id: id),
),
);
}
}

Localizations can be either shared or per-feature. There are pros and cons to both approaches. To keep it simple, start with a Flutter package in the shared folder, and use it amongst all of your features in the presentation layer.

If your app is more complex and very large, it may be worthwhile for each feature to define their own localizations. However, this introduces additional complexity.

A widget that needs its own Cubit but depends on another feature’s domain is not a separate feature. It belongs in the owning feature’s presentation package and is exported through the barrel file.

For example, a CartBadge that displays the item count and manages its own loading state lives in cart_presentation:

cart_presentation/lib/cart_badge/bloc/cart_badge_cubit.dart
class CartBadgeCubit extends Cubit<CartBadgeState> {
CartBadgeCubit({required ICartsRepository cartsRepository})
: _cartsRepository = cartsRepository,
super(const CartBadgeInitial());
final ICartsRepository _cartsRepository;
Future<void> loadItemCount(String cartId) async {
emit(const CartBadgeLoading());
try {
final cartSummary = await _cartsRepository.getCartById(cartId);
emit(CartBadgeLoaded(itemCount: cartSummary.productIds.length));
} catch (error, stackTrace) {
addError(error, stackTrace);
emit(CartBadgeError(message: error.toString()));
}
}
}

Any app or other feature’s presentation layer can import and use CartBadge. If a widget grows complex enough to require its own domain logic, it becomes its own feature.

A single feature may expose multiple independent entry points. For example, favorites_presentation might have a list screen and a detail screen that apps import separately. Each subfeature gets its own barrel file, and the primary barrel re-exports everything:

  • Directoryfeatures/favorites/
    • Directoryfavorites_presentation/
      • Directorylib/
        • Directoryfavorites_list/
        • Directoryfavorites_detail/
        • favorites_list.dart (subfeature barrel)
        • favorites_detail.dart (subfeature barrel)
        • favorites_presentation.dart (primary barrel, re-exports all)

This is essential for deferred imports. An app that wants to lazy-load the favorites detail screen imports only its subfeature barrel with a deferred prefix:

import 'package:favorites_presentation/favorites_detail.dart'
deferred as favorites_detail;

With a single barrel file, you can’t defer-load part of a package, because importing anything pulls in everything. Subfeature barrels enable fine-grained code splitting for web bundles and Android dynamic modules. For more on barrel files generally, see barrel files.

Next, navigation covers how a module moves the user to another feature without importing it.