Overview #

Understanding Flutter architecture isn’t just about how the framework works behind the scenes — it’s also about how you as a developer structure your app’s code so it stays clean, testable, and maintainable as the project scales. When you build an app without a solid architectural foundation, you’ll quickly get trapped in spaghetti code, where business logic, API calls, databases, and UI are all mixed together in one giant widget file. To avoid this, you need to examine Flutter architecture from two different but complementary perspectives: how Flutter’s internal system itself is built, and how you should structure your app code based on industry best practices and the official guidance from the Google team.

Two Perspectives on Flutter Architecture #

Before we go any further, it’s very important to distinguish between two concepts that often get mixed up when people talk about “Flutter architecture”.

The first concept is Flutter’s Internal Architecture. This concerns how Google’s engineering team built the Flutter framework itself. It includes the division of labor between the top layer written in Dart (Framework), the middle layer written in C++ (Engine), and the bottom native layer (Platform Embedder). As an app developer, you don’t modify this internal architecture; you just use it.

The second concept is Flutter App Architecture. This concerns how you organize the source code you write to solve business problems. It includes dividing code into layers like the UI Layer, Domain Layer, and Data Layer. You have full control over this app architecture and are responsible for designing it optimally.

flowchart TD
    subgraph InternalArch["Perspective 1: Flutter Internal Architecture (How the Framework Is Built)"]
        direction TB
        FW["Framework Layer (Dart)"]
        ENG["Engine Layer (C++)"]
        EMB["Platform Embedder (Native OS)"]
        FW --> ENG
        ENG --> EMB
    end
    subgraph AppArch["Perspective 2: Flutter App Architecture (How Your Code Is Organized)"]
        direction TB
        UI["UI Layer (Widgets / ViewModels)"]
        DOM["Domain Layer (Use Cases - Optional)"]
        DAT["Data Layer (Repositories / Data Sources)"]
        UI --> DOM
        DOM --> DAT
        UI -.->|"Direct Access (If No Domain Layer)"| DAT
    end
    
    style InternalArch stroke:#0288d1,stroke-width:2px
    style AppArch stroke:#388e3c,stroke-width:2px

Both perspectives go hand in hand. Understanding the internal architecture helps you understand Flutter’s rendering limitations and potential, while understanding app architecture helps you write scalable production code for the long term.


Internal Architecture: A Layered System #

Internally, Flutter is designed using a very clean Layered System principle. The beauty of this design is the strict separation of powers: each layer has a specific responsibility contract and only communicates with the layer directly below it. Higher layers are not allowed to bypass the layers below to access hardware directly, and lower layers have no knowledge of the implementation details of the layers above.

flowchart TD
    App["Your App (Dart)"] -->|"Uses Widgets & APIs"| FW["Flutter Framework (Dart)"]
    FW -->|"dart:ui Bindings & Hooks"| ENG["Flutter Engine (C++)"]
    ENG -->|"Stable ABI & Surface"| EMB["Platform Embedder (Native)"]
    EMB -->|"Native APIs & Drivers"| OS["Operating System (Android / iOS / Desktop / Web)"]
    
    style FW stroke:#0288d1,stroke-width:2px
    style ENG stroke:#388e3c,stroke-width:2px
    style EMB stroke:#f57c00,stroke-width:2px

The main benefits of this internal layered system for you as a developer include:

  • Replaceable Design Systems: Because the Material and Cupertino layers sit at the top of the Dart framework, both layers are completely optional. If you want to build your own custom design system very different from Google’s Material Design or Apple’s Cupertino, you can write it directly on top of the built-in Widgets layer.
  • Source Code Transparency: The entire Framework is written in Dart. This means when you hit a visual bug, you can right-click a built-in widget (like ListView or Container) in your IDE and read its original source code directly. This makes debugging very transparent.
  • Hardware Portability: The C++ Engine is platform-agnostic (it doesn’t care about the OS type). When you want to run a Flutter app on a new system (like a custom Smart TV), you only need to write a small Platform Embedder to initialize the rendering surface and forward input, while your Dart app code runs unchanged.

App Architecture: Google’s Official Guidance #

Moving to the second perspective: how do you structure your own app code? Since 2023, Google has officially released Flutter app architecture guidance recommending dividing code into two to three main layers based on the principle of Separation of Concerns.

Let’s break down each layer in depth.

1. UI Layer (Interface Layer) #

The UI Layer has the single responsibility of presenting data to the physical screen and handling user interaction. Following the MVVM (Model-View-ViewModel) pattern, this layer is divided into two components:

  • View (Widget): The visual interface component built using Dart widget composition. Views are passive and declarative; they must not contain business logic, must not make direct API calls, and must not manipulate raw data. Their only job is to render UI based on the current state and forward user interactions to the ViewModel.
  • ViewModel (State Holder): The component responsible for processing user interactions (commands), communicating with the Data Layer, and exposing ready-to-consume state for the View. In Flutter, you can implement a ViewModel using various state management approaches like ChangeNotifier, Bloc, Cubit, or Riverpod.

Let’s look at an example of a clean View implementation below:

// CORRECT: The View is only responsible for displaying UI based on state
class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Reading state from the ViewModel using provider
    final viewModel = context.watch<ProfileViewModel>();

    return Scaffold(
      appBar: AppBar(title: const Text('User Profile')),
      body: Center(
        child: switch (viewModel.state) {
          ProfileLoading() => const CircularProgressIndicator(),
          ProfileLoaded(:final user) => ProfileContent(user: user),
          ProfileError(:final message) => ErrorView(message: message),
        },
      ),
    );
  }
}

And here’s the accompanying ViewModel example:

// CORRECT: The ViewModel holds UI state and bridges to the Data Layer
class ProfileViewModel extends ChangeNotifier {
  final UserRepository _userRepository;
  
  ProfileState _state = const ProfileLoading();
  ProfileState get state => _state;

  ProfileViewModel(this._userRepository);

  // Command called by the View during initialization
  Future<void> loadProfile(String userId) async {
    _state = const ProfileLoading();
    notifyListeners();

    try {
      final user = await _userRepository.getUser(userId);
      _state = ProfileLoaded(user: user);
    } catch (e) {
      _state = ProfileError(message: e.toString());
    }
    notifyListeners();
  }
}

2. Logic / Domain Layer (Business Layer - Optional) #

The Domain Layer is an optional layer inserted between the UI Layer and the Data Layer. It purely contains app business logic independent of both the UI and the data source. The main components here are Use Cases or Interactors.

You need a Domain Layer if:

  • Your app has complex client-side business calculation rules (e.g., calculating insurance premiums based on medical history).
  • A workflow involves coordinating data from several repositories at once.

Let’s look at an example Use Case implementation:

// CORRECT: Use case coordinates business logic from several repositories
class GetRecommendedProductsUseCase {
  final ProductRepository _productRepository;
  final UserRepository _userRepository;

  GetRecommendedProductsUseCase(this._productRepository, this._userRepository);

  Future<List<Product>> execute(String userId) async {
    // 1. Fetch user data
    final user = await _userRepository.getUser(userId);
    // 2. Fetch all available products
    final allProducts = await _productRepository.getProducts();

    // 3. Run the client-side business logic filter
    return allProducts
        .where((product) => user.preferences.contains(product.category))
        .take(10)
        .toList();
  }
}
When Should You Avoid the Domain Layer? If your app mostly does simple CRUD operations (fetching data from an API and displaying it as-is on screen), adding a Domain Layer is a form of over-engineering. In such cases, the ViewModel in the UI Layer is allowed to call the Repository in the Data Layer directly without a Use Case intermediary.

3. Data Layer (Data Layer) #

The Data Layer is fully responsible for managing the app’s data operations. This layer acts as the gateway for all external information, in and out. Its main components are:

  • Repository: A class that acts as the single gateway (Single Source of Truth) for layers above to access specific data. The Repository decides when to fetch data from the internet server and when to fetch it from local storage.
  • Data Sources: Low-level classes that perform raw I/O operations with one specific external data source, like making an HTTP Request to a REST API (Remote Data Source) or reading an SQLite database file (Local Data Source).

Here’s an example of clean data coordination inside a Repository:

// CORRECT: Repository coordinates multiple data sources and holds the SSOT
class UserRepository {
  final UserRemoteDataSource _remoteDataSource;
  final UserLocalDataSource _localDataSource;

  UserRepository({
    required UserRemoteDataSource remoteDataSource,
    required UserLocalDataSource localDataSource,
  })  : _remoteDataSource = remoteDataSource,
        _localDataSource = localDataSource;

  Future<User> getUser(String userId) async {
    // 1. Try fetching data from the local database/cache first
    final localUser = await _localDataSource.getCachedUser(userId);
    if (localUser != null) {
      return localUser;
    }

    // 2. If not available locally, fetch from the server via API
    final remoteUser = await _remoteDataSource.fetchUser(userId);

    // 3. Save the latest data to local storage for future caching
    await _localDataSource.cacheUser(remoteUser);

    return remoteUser;
  }
}

Unidirectional Data Flow (UDF) #

To prevent state inconsistencies where one part of the UI shows different data from another part, Flutter architecture applies the Unidirectional Data Flow (UDF) principle.

In UDF architecture, data flows strictly through a one-way cycle:

  1. State flows down (Data -> UI): When data changes in the Data Layer (e.g., a new item is added to the shopping cart), the change is sent to the ViewModel, which then updates its UI state object. This new state flows down to the View (Widget), triggering a declarative visual rebuild.
  2. Events flow up (UI -> Data): When the user interacts with the screen (e.g., pressing the “Remove Item” button), the View must not directly mutate the data in memory. Instead, the View sends an interaction signal as an Event up to the ViewModel. The ViewModel calls the appropriate Command function on the Repository in the Data Layer. The Repository mutates the underlying data, and the cycle returns to step one.
flowchart TD
    subgraph DataFlow["Data & Event Flow (UDF)"]
        direction TB
        Repo["Data Layer (Repository)"] -->|"1. Send New Data (State)"| VM["UI Layer (ViewModel)"]
        VM -->|"2. Update Display"| View["UI Layer (View / Widget)"]
        View -->|"3. Trigger Interaction (Event)"| VM
        VM -->|"4. Call Data Mutation"| Repo
    end
    
    style Repo stroke:#e91e63,stroke-width:2px
    style VM stroke:#0288d1,stroke-width:2px
    style View stroke:#4caf50,stroke-width:2px

By applying UDF, your code becomes highly predictable because you know exactly where data mutations happen (only in the Data Layer/Repository) and how those mutations are distributed to every visual component without confusing shortcuts.


Single Source of Truth (SSOT) #

The next crucial principle you must apply is Single Source of Truth (SSOT). This concept states that for every specific type of data in your app, there should be exactly one authoritative object that stores and manages that data.

Let’s compare the common data duplication mistake with the correct SSOT solution:

// ANTI-PATTERN: Storing a shopping cart data copy in a local widget.
// If you navigate to another page, the cart data on this page will be lost or out of sync.
class CartScreen extends StatefulWidget {
  const CartScreen({super.key});

  @override
  State<CartScreen> createState() => _CartScreenState();
}

class _CartScreenState extends State<CartScreen> {
  List<CartItem> _localCartItems = []; // ✗ Local state duplication prone to desync

  void _addItem(CartItem item) {
    setState(() {
      _localCartItems.add(item);
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return Container(); // render UI...
  }
}

As a solution, you should move data ownership to a single data layer class (Repository) and distribute it using reactive data flow mechanisms:

// CORRECT: Using CartRepository as the Single Source of Truth for the entire app
class CartRepository {
  final ValueNotifier<List<CartItem>> _itemsNotifier = ValueNotifier<List<CartItem>>([]);
  
  // Exposing ValueListenable so the UI can observe changes dynamically
  ValueListenable<List<CartItem>> get items => _itemsNotifier;

  void addItem(CartItem item) {
    // Adding a new item and triggering a change notification
    _itemsNotifier.value = [..._itemsNotifier.value, item];
  }

  void removeItem(String itemId) {
    _itemsNotifier.value = _itemsNotifier.value.where((item) => item.id != itemId).toList();
  }
}

By centralizing the shopping cart data in CartRepository, the cart page, the item count badge icon on the home page, and the checkout page will always show 100% consistent data because they all read from the same source of truth.


Separation of Concerns in Practice #

The core of good architecture is discipline in separating responsibilities (Separation of Concerns). Every class you write should focus on doing one thing very well.

The table below summarizes the responsibility boundaries for each main component of your app architecture:

ComponentMain ResponsibilityStrictly Forbidden
View (Widget)Arranging visual layout, handling decorative rendering, and forwarding user input.Calculating prices, calling HTTP APIs, writing database queries, holding business logic.
ViewModelManaging screen-specific UI state, processing user input, bridging UI with Data.Making raw HTTP connections, managing database files, storing global cross-feature state.
RepositoryProviding a clean data access API, managing local cache, mediating multiple data sources.Storing visual data (like button colors), triggering UI pop-up dialogs directly.
Data SourceRunning raw queries to local databases, sending raw HTTP requests to API servers.Formatting currency, deciding app business logic.

Let’s look at a real comparison of separating business logic from widgets:

// ANTI-PATTERN: Doing discount calculations and currency formatting inside the Widget build
class ProductCard extends StatelessWidget {
  final Product product;
  const ProductCard({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    // DON'T do calculation & formatting logic here!
    final finalPrice = product.originalPrice * (1 - product.discountRate);
    final formattedPrice = 'Rp ${finalPrice.toStringAsFixed(0)}';

    return Card(
      child: Text('Price: $formattedPrice'),
    );
  }
}

The code above makes it difficult to unit test the discount logic in the future, because you’d have to initialize the entire Flutter widget framework just to test simple multiplication math.

Here’s the correct approach, moving that logic into the ViewModel or a data representation Model:

// CORRECT: Calculation logic moved to the ViewModel or Model data representation
class ProductViewModel extends ChangeNotifier {
  final Product _product;
  ProductViewModel(this._product);

  double get finalPrice => _product.originalPrice * (1 - _product.discountRate);
  
  String get formattedPrice {
    // Clean formatting logic, testable separately without UI
    return 'Rp ${finalPrice.toStringAsFixed(0)}';
  }
}

// Inside the View Widget, we just call the ready-made property
class ProductCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<ProductViewModel>();
    return Card(
      child: Text('Price: ${viewModel.formattedPrice}'),
    );
  }
}

Clean architecture implementation should be visually reflected in your project’s directory structure. There are two folder structure approaches commonly used in the industry:

1. Layer-based Structure #

This approach groups code files by their architectural technical layer. It’s very suitable for small to medium-scale projects because the code structure is easy for new developers to understand.

lib/
  ├── main.dart
  ├── app.dart
  ├── ui/                             # UI Layer
  │   ├── home/
  │   │   ├── home_screen.dart
  │   │   └── home_view_model.dart
  │   └── profile/
  │       ├── profile_screen.dart
  │       └── profile_view_model.dart
  ├── domain/                         # Business Layer (Optional)
  │   ├── models/
  │   │   └── user.dart
  │   └── use_cases/
  │       └── get_recommended_products.dart
  └── data/                           # Data Layer
      ├── repositories/
      │   └── user_repository.dart
      └── data_sources/
          ├── remote/
          │   └── user_api_data_source.dart
          └── local/
              └── user_database_data_source.dart

2. Feature-based Structure #

This approach groups code files by app functional features (e.g., the auth feature, the shopping cart feature, the profile feature). Inside each feature folder, files are then divided by architecture layer. This structure is highly recommended for large-scale projects worked on by many parallel teams because it minimizes code merge conflicts.

lib/
  ├── main.dart
  ├── app.dart
  └── features/
      ├── auth/                       # Auth Feature
      │   ├── presentation/           # Auth Feature UI Layer
      │   │   ├── login_screen.dart
      │   │   └── login_view_model.dart
      │   ├── domain/                 # Auth Feature Business Layer
      │   └── data/                   # Auth Feature Data Layer
      └── cart/                       # Shopping Cart Feature
          ├── presentation/
          ├── domain/
          └── data/

You’re free to choose either approach, as long as you apply it consistently across the entire project.

Summary #

  • Two Perspectives — Flutter architecture can be viewed from the internal side (how the framework is designed with a layered system) and the app side (how you structure your project code).
  • Three App Layers — Google recommends dividing code into the UI Layer (View & ViewModel), Domain Layer (Use Cases for complex business logic), and Data Layer (Repository & Data Sources).
  • Separation of Concerns — Every component must have one specific responsibility. Never put API calls, database queries, or business math calculations inside Widgets.
  • Unidirectional Data Flow (UDF) — Data must flow one way: state flows from the bottom up (Data to UI) to render visuals, while interaction events are sent from the top down (UI to Data) for mutations.
  • Single Source of Truth (SSOT) — Avoid duplicating data storage in local widgets that are prone to desync. Keep authoritative data in a single Repository and distribute it using reactive flows.
  • Directory Structure Choices — Use a layer-based structure for small-to-medium projects for ease of navigation, or a feature-based structure for large-scale apps for team scalability.

← Previous: Engine, Framework & Embedder   Next: Framework Layer →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact