App Architecture & Design Patterns

Feature-Modular Project Structure

16 min Lesson 10 of 12

Feature-Modular Project Structure

As Flutter applications grow beyond toy examples, a flat folder structure — where every file sits under lib/ sorted only by type — becomes a maintenance liability. Locating all files related to a single feature requires jumping across models/, screens/, services/, and widgets/ simultaneously. The solution is a feature-modular (also called vertical slice) architecture: each feature owns its files in one cohesive directory, divided internally into data, domain, and presentation layers.

Why Flat Structures Break Down

Consider an app with authentication, a product catalogue, and a shopping cart. A type-based flat layout looks like this:

  • lib/models/ — user.dart, product.dart, cart_item.dart …
  • lib/screens/ — login_screen.dart, product_list_screen.dart, cart_screen.dart …
  • lib/services/ — auth_service.dart, product_service.dart, cart_service.dart …
  • lib/widgets/ — product_card.dart, cart_badge.dart …

Deleting the cart feature now requires hunting through every folder. Adding a team member to work only on authentication is nearly impossible without inadvertently touching unrelated files. A feature-modular layout eliminates both problems.

The Vertical Slice Layout

Each feature becomes a self-contained directory with three sub-layers:

  • data/ — repositories, data sources (remote API clients, local DB helpers), and raw model/DTO classes.
  • domain/ — entities (pure business objects), repository interfaces (abstract contracts), and use-case classes that encode a single business rule.
  • presentation/ — screens, page widgets, feature-scoped state management (Cubit/Notifier/ViewModel), and small UI-only widgets used only within this feature.

Recommended Directory Tree

lib/
├── core/                        // Shared infrastructure
│   ├── network/
│   │   └── dio_client.dart
│   ├── error/
│   │   └── failures.dart
│   └── utils/
│       └── validators.dart
│
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   │   └── auth_remote_datasource.dart
│   │   │   ├── models/
│   │   │   │   └── user_model.dart
│   │   │   └── repositories/
│   │   │       └── auth_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   └── user.dart
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository.dart   // abstract
│   │   │   └── usecases/
│   │   │       └── login_usecase.dart
│   │   └── presentation/
│   │       ├── cubit/
│   │       │   ├── auth_cubit.dart
│   │       │   └── auth_state.dart
│   │       ├── pages/
│   │       │   └── login_page.dart
│   │       └── widgets/
│   │           └── login_form.dart
│   │
│   └── cart/
│       ├── data/ ...
│       ├── domain/ ...
│       └── presentation/ ...
│
└── main.dart
Note: The features/ and core/ split is the key boundary. Code inside a feature folder is feature-private; code inside core/ is shared across all features. Never import from one feature into another — route through core/ or use dependency injection instead.

The Three Internal Layers Explained

Each internal layer has a strict dependency direction: presentation → domain ← data. The domain layer knows nothing about Flutter or HTTP; it contains only pure Dart.

  • domain/entities/ — immutable business objects with no framework coupling (no fromJson, no copyWith annotation libraries).
  • domain/repositories/ — abstract classes that declare what operations exist (e.g., Future<User> login(String email, String password)). The domain layer depends on these contracts, not implementations.
  • domain/usecases/ — one class per business action; calls the repository interface and returns a result. Keeps business logic out of the presentation layer.
  • data/models/ — extend or wrap entities with serialisation (fromJson / toJson).
  • data/repositories/ — concrete implementations of the domain repository interfaces, wiring data sources together.
  • presentation/ — widgets plus a thin state-management layer (Cubit, Riverpod Notifier, etc.) that calls use cases and exposes UI state.

Domain Layer: Entity, Repository Interface, and Use Case

// features/auth/domain/entities/user.dart
class User {
  final String id;
  final String email;
  final String displayName;

  const User({
    required this.id,
    required this.email,
    required this.displayName,
  });
}

// features/auth/domain/repositories/auth_repository.dart
abstract class AuthRepository {
  Future<User> login(String email, String password);
  Future<void> logout();
  Future<User?> getCurrentUser();
}

// features/auth/domain/usecases/login_usecase.dart
class LoginUseCase {
  final AuthRepository _repository;

  const LoginUseCase(this._repository);

  Future<User> call(String email, String password) {
    return _repository.login(email, password);
  }
}

The Core / Common Module

Code that must be reused across features lives in lib/core/. Typical residents include:

  • network/ — a configured Dio or http client, interceptors, and base URLs.
  • error/ — a sealed Failure hierarchy so every feature reports errors uniformly.
  • di/ — dependency injection setup (e.g., get_it service locator registrations).
  • router/ — route names and the GoRouter / AutoRoute configuration.
  • theme/ThemeData, colour tokens, and text styles.
  • utils/ — generic validators, formatters, and extension methods.
  • widgets/ — truly reusable UI atoms (loading spinner, error snackbar, avatar) used by two or more features.
Tip: When you find yourself copying a widget or helper into a second feature, that is the signal to promote it to core/. Keep core/ lean — only things that are genuinely shared belong there, not things that might be shared someday.

Barrel Files and Import Hygiene

Each feature can expose a single barrel file (auth.dart) that re-exports only the public surface of the feature — typically the presentation pages and the DI registration function. Internal data and domain classes stay private to the feature, preventing other features from bypassing the architecture.

Barrel File Pattern

// features/auth/auth.dart  — public API of the auth feature
export 'presentation/pages/login_page.dart';
export 'presentation/pages/register_page.dart';
export 'di/auth_di.dart';  // registers auth dependencies

// main.dart imports only the barrel:
import 'features/auth/auth.dart';
// Internal classes like AuthRepositoryImpl are NOT exported
// and therefore cannot be accidentally imported by other features.
Warning: Avoid circular imports between features. If cart needs a User object from auth, move the User entity to core/ rather than importing from one feature into another. Feature-to-feature imports are an architecture smell that defeats the purpose of modularisation.

Summary

A feature-modular structure organises code by business capability rather than technical type. Each feature folder contains its own data, domain, and presentation layers with a strict inward dependency rule. Shared infrastructure moves to core/. The result is a codebase where individual features can be developed, tested, and even extracted into packages independently — dramatically improving scalability and team collaboration as your Flutter application grows.