Flutter App Architecture Skill
This skill provides comprehensive guidelines for structuring Flutter applications using layered architecture, proper data flow, and best practices for maintainability and testability.
Architecture
- Separate your features into a UI Layer (presentation), a Data Layer (business data and logic), and, for complex apps, consider adding a Domain (Logic) Layer between UI and Data layers to encapsulate business logic and use-cases.
- You can organize code by feature: The classes needed for each feature are grouped together. For example, you might have an auth directory, which would contain files like auth_viewmodel.dart (or, depending on your state management approach: auth_controller.dart, auth_provider.dart, auth_bloc.dart), login_usecase.dart, logout_usecase.dart, login_screen.dart, logout_button.dart, etc. Alternatively, you can organize by type or use a hybrid approach.
- Only allow communication between adjacent layers; the UI layer should not access the data layer directly, and vice versa.
- Introduce a Logic (Domain) Layer only for complex business logic that does not fit cleanly in the UI or Data layers.
- Clearly define the responsibilities, boundaries, and interfaces of each layer and component (Views, View Models, Repositories, Services).
- Further divide each layer into components with specific responsibilities and well-defined interfaces.
- In the UI Layer, use Views to describe how to present data to the user; keep logic minimal and only UI-related.
- Pass events from Views to View Models in response to user interactions.
- In View Models, contain logic to convert app data into UI state and maintain the current state needed by the view.
- Expose callbacks (commands) from View Models to Views and retrieve/transform data from repositories.
- In the Data Layer, use Repositories as the single source of truth (SSOT) for model data and to handle business logic such as caching, error handling, and refreshing data.
- Only the SSOT class (usually the repository) should be able to mutate its data; all other classes should read from it.
- Repositories should transform raw data from services into domain models and output data consumed by View Models.
- Use Services to wrap API endpoints and expose asynchronous response objects; services should isolate data-loading and hold no state.
- Use dependency injection to provide components with their dependencies, enabling testability and flexibility.
Data Flow and State
- Follow unidirectional data flow: state flows from the data layer through the logic layer to the UI layer, and events from user interaction flow in the opposite direction.
- Data changes should always happen in the SSOT (data layer), not in the UI or logic layers.
- The UI should always reflect the current (immutable) state; trigger UI rebuilds only in response to state changes.
- Views should contain as little logic as possible and be driven by state from View Models.
Use Cases / Interactors
- Introduce use cases/interactors in the domain layer only when logic is complex, reused, or merges data from multiple repositories.
- Use cases depend on repositories and may be used by multiple view models.
- Add use cases only when needed; refactor to use use-cases exclusively if logic is repeatedly shared across view models.
Extensibility and Testability
- All architectural components should have well-defined inputs and outputs (interfaces).
- Favor dependency injection to allow swapping implementations without changing consumers.
- Test view models by mocking repositories; test UI logic independently of widgets.
- Design components to be easily replaceable and independently testable.
Best Practices
- Strongly recommend following separation of concerns and layered architecture.
- Strongly recommend using dependency injection for testability and flexibility.
- Recommend using MVVM as the default pattern, but adapt as needed for your app's complexity.
- Use key-value storage for simple data (e.g., configuration, preferences) and SQL storage for complex relationships.
- Use optimistic updates to improve perceived responsiveness by updating the UI before operations complete.
- Support offline-first strategies by combining local and remote data sources in repositories and enabling synchronization as appropriate.
- Keep views focused on presentation and extract reusable widgets into separate components.
- Use
StatelessWidgetwhen possible and avoid unnecessaryStatefulWidgets. - Keep build methods simple and focused on rendering.
- Choose state management approaches appropriate to the complexity of your app.
- Keep state as local as possible to minimize rebuilds and complexity.
- Use
constconstructors when possible to improve performance. - Avoid expensive operations in build methods and implement pagination for large lists.
- Keep files focused on a single responsibility and limit file length for readability.
- Group related functionality together and use
finalfor fields and top-level variables when possible. - Prefer making declarations private and consider making constructors
constif the class supports it. - Follow Dart naming conventions and format code using
dart format. - Use curly braces for all flow control statements to ensure clarity and prevent bugs.
- Prefer explicit typing and generics on public APIs (for example, prefer typed command signatures such as
Command0<void>rather than untyped/dynamic signatures) to improve clarity and type safety. - For small immutable domain or data models, prefer using
abstract classwithconstconstructors andfinalfields where it improves readability and enforces immutability. - Use descriptive constant names for resources and table identifiers (for example prefer
_todoTableNameover compact prefixes like_kTableTodo) to improve clarity across examples and migrations.