Flutter Clean Architecture for Beginners: Folder Structure, Assets and a Complete Example

Learn Flutter Clean Architecture the simple way: what the data, domain and presentation layers do, how to organize folders and assets, and a complete login feature example you can copy into your own app.

By Yaqoob Developer · · 9 min read

  • #Flutter
  • #Clean Architecture
  • #Folder Structure
  • #Dart
  • #Beginners
  • #Best Practices

If your Flutter project has grown into one giant lib folder where API calls, UI and logic are all mixed together, this guide is for you.

Clean Architecture is a simple idea: split your code into three layers so the UI, the business logic and the data never get mixed up. The result is an app that stays easy to read, test and change, even when it grows to 50+ screens.

In this post you will learn:

  • What the three layers are, explained with a simple example
  • The exact folder structure to use
  • How to manage images, icons and fonts properly
  • A complete login feature built step by step
  • Naming rules and common beginner mistakes

The three layers, explained with a restaurant

Think of your app as a restaurant:

  • Presentation layer = the waiter. It shows things to the customer and takes their order. In Flutter, this is your screens, widgets and state management (Provider, Bloc, Riverpod or a simple controller).
  • Domain layer = the chef. It decides what needs to happen. This is the core logic of your app, written in pure Dart, with no Flutter widgets and no API code.
  • Data layer = the storeroom. It knows where data comes from and how to get it: a REST API, Firebase or a local database.

The waiter never walks into the storeroom. He asks the chef, and the chef gets what he needs from the storeroom. In code, the flow always goes in one direction:

UI (Page) → Controller / Bloc → UseCase → Repository → DataSource (API / DB)

The folder structure

The best approach for most apps is feature-first: every feature (auth, home, profile, cart) gets its own folder, and each feature contains the three layers.

my_app/
├── assets/
│   ├── images/          # png, jpg, webp
│   ├── icons/           # svg icons
│   ├── fonts/           # custom fonts
│   └── animations/      # lottie json files
│
├── lib/
│   ├── main.dart                 # only starts the app
│   │
│   ├── app/                      # app-wide setup
│   │   ├── app.dart              # MaterialApp
│   │   ├── routes.dart           # all routes
│   │   └── theme.dart            # colors and text styles
│   │
│   ├── core/                     # shared by every feature
│   │   ├── constants/            # app_colors.dart, app_assets.dart, api_urls.dart
│   │   ├── errors/               # failures.dart, exceptions.dart
│   │   ├── network/              # http or dio client setup
│   │   ├── utils/                # validators, formatters
│   │   └── widgets/              # AppButton, AppTextField, Loader
│   │
│   └── 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
│       │   │   └── usecases/     # login_user.dart
│       │   └── presentation/
│       │       ├── controllers/  # or bloc/ or providers/
│       │       ├── pages/        # login_page.dart
│       │       └── widgets/      # login_form.dart
│       │
│       └── home/
│           ├── data/
│           ├── domain/
│           └── presentation/
│
└── pubspec.yaml

What goes in each folder

app/ holds things that belong to the whole app: your MaterialApp, routes and theme.

core/ holds code that many features share: constants, error classes, the network client, helper functions and reusable widgets such as a custom button.

features/ holds the actual features. Inside each one:

  • domain/entities: simple Dart classes that describe your data, such as User(id, name, email). No JSON code here.
  • domain/repositories: an abstract class that lists what the feature can do, such as login(). It is a contract, not the real code.
  • domain/usecases: one class per action, such as LoginUser or GetProducts.
  • data/models: a version of the entity that knows how to convert to and from JSON.
  • data/datasources: the real API or database calls.
  • data/repositories: the real implementation of the contract from the domain layer.
  • presentation/pages: full screens.
  • presentation/widgets: smaller pieces used by those screens.
  • presentation/controllers: your state management (Provider, Bloc, Riverpod or a simple ChangeNotifier).

Managing assets the clean way

Step 1: Put assets in organized folders

Keep images, icons, fonts and animations in separate folders inside assets/, as shown in the structure above. Name files in lowercase with underscores, for example app_logo.png and ic_google.svg.

Step 2: Register them in pubspec.yaml

flutter:
  uses-material-design: true

  assets:
    - assets/images/
    - assets/icons/
    - assets/animations/

  fonts:
    - family: Poppins
      fonts:
        - asset: assets/fonts/Poppins-Regular.ttf
        - asset: assets/fonts/Poppins-Bold.ttf
          weight: 700

Indentation matters in YAML. Use two spaces and never tabs. After editing, save the file and run flutter pub get.

Step 3: Keep every path in one class

Create lib/core/constants/app_assets.dart:

class AppAssets {
  AppAssets._();

  // Images
  static const logo = 'assets/images/app_logo.png';
  static const onboarding = 'assets/images/onboarding.png';

  // Icons
  static const google = 'assets/icons/ic_google.svg';
}

Step 4: Use it anywhere

Image.asset(AppAssets.logo, height: 80)

Now if a file name changes, you update it in one place instead of searching through 30 files. You also get auto-complete and no more typo bugs.

Do the same for colors in lib/core/constants/app_colors.dart:

import 'package:flutter/material.dart';

class AppColors {
  AppColors._();

  static const primary = Color(0xFF0057FF);
  static const background = Color(0xFFF7F8FA);
  static const textDark = Color(0xFF1A1A1A);
}

A complete example: the login feature

Let's build a login feature layer by layer. Replace my_app in the imports with your own project name from pubspec.yaml.

1. Entity (domain layer)

lib/features/auth/domain/entities/user.dart

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

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

A plain Dart class. It does not know anything about JSON, APIs or Flutter.

2. Repository contract (domain layer)

lib/features/auth/domain/repositories/auth_repository.dart

import 'package:my_app/features/auth/domain/entities/user.dart';

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

This only says what the feature can do. It does not say how.

3. UseCase (domain layer)

lib/features/auth/domain/usecases/login_user.dart

import 'package:my_app/features/auth/domain/entities/user.dart';
import 'package:my_app/features/auth/domain/repositories/auth_repository.dart';

class LoginUser {
  final AuthRepository repository;

  LoginUser(this.repository);

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

One use case does one job. Because the class has a call method, you can use it like a function: loginUser(email, password).

4. Model (data layer)

lib/features/auth/data/models/user_model.dart

import 'package:my_app/features/auth/domain/entities/user.dart';

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> toJson() {
    return {'id': id, 'name': name, 'email': email};
  }
}

The model extends the entity and adds JSON conversion. Only the data layer knows about JSON.

5. DataSource (data layer)

lib/features/auth/data/datasources/auth_remote_datasource.dart

import 'package:my_app/features/auth/data/models/user_model.dart';

abstract class AuthRemoteDataSource {
  Future<UserModel> login(String email, String password);
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  @override
  Future<UserModel> login(String email, String password) async {
    // In a real app, call your API here using http or dio.
    await Future.delayed(const Duration(seconds: 1));

    if (password.length < 6) {
      throw Exception('Invalid email or password');
    }

    return UserModel.fromJson({
      'id': '1',
      'name': 'Flutter Learner',
      'email': email,
    });
  }
}

For this tutorial we fake the API with a one-second delay. Later, you only replace the code inside this method with a real API call, and nothing else in the app has to change. That is the real power of Clean Architecture.

6. Repository implementation (data layer)

lib/features/auth/data/repositories/auth_repository_impl.dart

import 'package:my_app/features/auth/data/datasources/auth_remote_datasource.dart';
import 'package:my_app/features/auth/domain/entities/user.dart';
import 'package:my_app/features/auth/domain/repositories/auth_repository.dart';

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  AuthRepositoryImpl(this.remoteDataSource);

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

This is where the contract from step 2 gets its real code. In bigger apps, this is also where you decide whether to read from the API or from a local cache.

7. Controller (presentation layer)

lib/features/auth/presentation/controllers/login_controller.dart

import 'package:flutter/foundation.dart';
import 'package:my_app/features/auth/domain/entities/user.dart';
import 'package:my_app/features/auth/domain/usecases/login_user.dart';

class LoginController extends ChangeNotifier {
  final LoginUser loginUser;

  LoginController(this.loginUser);

  bool isLoading = false;
  String? error;
  User? user;

  Future<void> login(String email, String password) async {
    isLoading = true;
    error = null;
    notifyListeners();

    try {
      user = await loginUser(email, password);
    } catch (e) {
      error = e.toString().replaceFirst('Exception: ', '');
    }

    isLoading = false;
    notifyListeners();
  }
}

We use a simple ChangeNotifier so beginners don't need any extra packages. You can swap it for Bloc, Provider or Riverpod later, and the domain and data layers stay exactly the same.

8. Page (presentation layer)

lib/features/auth/presentation/pages/login_page.dart

import 'package:flutter/material.dart';
import 'package:my_app/features/auth/data/datasources/auth_remote_datasource.dart';
import 'package:my_app/features/auth/data/repositories/auth_repository_impl.dart';
import 'package:my_app/features/auth/domain/usecases/login_user.dart';
import 'package:my_app/features/auth/presentation/controllers/login_controller.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  // Connect the layers: DataSource → Repository → UseCase → Controller
  late final LoginController _controller = LoginController(
    LoginUser(AuthRepositoryImpl(AuthRemoteDataSourceImpl())),
  );

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: ListenableBuilder(
          listenable: _controller,
          builder: (context, _) {
            return Column(
              children: [
                TextField(
                  controller: _emailController,
                  decoration: const InputDecoration(labelText: 'Email'),
                ),
                const SizedBox(height: 12),
                TextField(
                  controller: _passwordController,
                  obscureText: true,
                  decoration: const InputDecoration(labelText: 'Password'),
                ),
                const SizedBox(height: 24),
                if (_controller.isLoading)
                  const CircularProgressIndicator()
                else
                  ElevatedButton(
                    onPressed: () => _controller.login(
                      _emailController.text,
                      _passwordController.text,
                    ),
                    child: const Text('Login'),
                  ),
                const SizedBox(height: 16),
                if (_controller.error != null)
                  Text(
                    _controller.error!,
                    style: const TextStyle(color: Colors.red),
                  ),
                if (_controller.user != null)
                  Text('Welcome, ${_controller.user!.name}!'),
              ],
            );
          },
        ),
      ),
    );
  }
}

9. main.dart

lib/main.dart

import 'package:flutter/material.dart';
import 'package:my_app/features/auth/presentation/pages/login_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: LoginPage(),
    );
  }
}

Run the app. Type any email and a password of 6 or more characters, and you'll see the welcome message. Type a shorter password and you'll see the error. Every layer did its own job.

How the request travels

When the user taps Login:

  1. LoginPage calls controller.login().
  2. LoginController calls the LoginUser use case.
  3. LoginUser calls AuthRepository.login().
  4. AuthRepositoryImpl calls AuthRemoteDataSource.login().
  5. The data source returns a UserModel, which travels back up as a User.
  6. The controller calls notifyListeners(), and the page shows the result.

In a bigger app, you would create these objects once using a dependency injection package such as get_it, instead of inside the page. For learning, wiring them by hand makes the flow easy to see.

Naming rules to follow

  • Files and folders: snake_case, for example login_page.dart and user_model.dart.
  • Classes: PascalCase, for example LoginPage and UserModel.
  • Variables and functions: camelCase, for example isLoading and loginUser.
  • Asset files: lowercase with underscores, no spaces, for example app_logo.png.
  • Clear suffixes: end file names with what they are: _page, _widget, _model, _repository, _datasource, _controller.

Common beginner mistakes

Calling the API directly from a widget. The UI should only talk to the controller. API code belongs in the data source.

Importing Flutter in the domain layer. Entities, repositories and use cases should be pure Dart. If you see package:flutter in a domain file, move that code to the presentation layer.

Putting JSON code in entities. fromJson and toJson belong in models, not entities.

Writing asset paths as raw strings everywhere. Use the AppAssets class so a renamed file doesn't break your app in ten places.

Using full Clean Architecture for a tiny app. For a 2–3 screen practice app, simple pages/, widgets/, models/ and services/ folders are enough. Use Clean Architecture for client projects, apps that will grow, or when you work in a team.

Quick summary

  • Presentation shows the UI and handles user actions.
  • Domain holds the core logic in pure Dart.
  • Data fetches and saves data from APIs or databases.
  • Organize by feature first, then by layer inside each feature.
  • Keep assets in organized folders, register them in pubspec.yaml, and reference them through one AppAssets class.

Start with one feature, such as login, and build it fully. Once it works, copy the same pattern for every new feature, and your project will stay clean no matter how big it gets.

If this guide helped you, share it with a friend who is learning Flutter, and drop your questions in the comments below.

Keep reading

More articles