Flutter GetX Architecture & Folder Structure Guidelines (GetX Pattern)
This guideline is based on the official get_cli tool and the recommended architecture from getx_pattern, adapted and supplemented for practical projects. All new pages, feature additions, and UI refactoring operations MUST strictly adhere to these guidelines.
1. Core Directory Architecture
lib/
├── app/
│ ├── modules/ # ★ Core business code organized by feature (Feature-First)
│ │ └── {module_name}/ # Specific module (e.g., home, trade, search)
│ │ ├── bindings/ # Dependency injection for the current module (Binding)
│ │ │ └── {module_name}_binding.dart
│ │ ├── controllers/ # Business logic for the current module (Controller)
│ │ │ └── {module_name}_controller.dart
│ │ ├── views/ # Main UI view for the current module (View)
│ │ │ └── {module_name}_view.dart
│ │ └── widgets/ # Private UI components specific to the current module (Widgets)
│ │ ├── {module_name}_banner_widget.dart
│ │ └── {module_name}_product_list_widget.dart
│ │
│ ├── data/ # Data layer: responsible for data fetching and persistence
│ │ ├── models/ # Globally shared data models (reusable across modules)
│ │ ├── providers/ # Data providers (Dio wrappers, API requests, local storage ops)
│ │ └── services/ # Background persistent services (exist with App lifecycle, e.g., AuthService)
│ │
│ └── routes/ # Route management (automatically maintained by get_cli)
│ ├── app_pages.dart # GetPage list: Mapping between route path ↔ View ↔ Binding
│ └── app_routes.dart # Route path constants (part of app_pages.dart)
│
├── core/ # Core layer: Global infrastructure (no business logic)
│ ├── theme/ # Themes and styles (app_theme.dart, app_colors.dart)
│ ├── values/ # Constants (strings, enums, regex, etc.)
│ └── utils/ # Utility classes (date formatting, encryption, etc.)
│
├── components/ # Global common UI components reusable across modules
│ ├── custom_button.dart
│ └── loading_dialog.dart
│
└── main.dart # Application entry point
2. Module Structure — The Four Pillars (Strict Enforcement)
When get_cli uses the get create page:name command to generate a module, it automatically creates bindings, controllers, and views subfolders. Based on this, this project additionally mandates a widgets/ directory, forming a closed loop of Four Pillars.
| No. | Directory/File | Responsibility | Key Constraints |
|---|---|---|---|
| 1 | views/{name}_view.dart | UI Skeleton: Assembles the page, references widgets. | ❌ NO network requests, dialog logic, or non-pure UI code in the View. |
| 2 | widgets/ | Module's Private Component Library: Large independent areas split from the page. | One file per large area, named {module}_{area}_widget.dart. |
| 3 | controllers/{name}_controller.dart | Business Brain: API calls, form validation, state management. | Use .obs for reactive state, keep variables private with public getters where possible. |
| 4 | bindings/{name}_binding.dart | Dependency Management Hub: Injects the Controller. | Use Get.lazyPut() to ensure instantiation only when used. |
2.1 When to create an independent Module?
- When it has independent business logic (e.g., needs its own Controller to manage independent state).
- When it is a main trunk page switched via bottom navigation bar / drawer (e.g., Home, Community, Profile).
- When a sub-feature's logic is complex enough that it shouldn't be stuffed into a single Widget of the parent module (e.g.,
publish_productundertrade).
2.2 Widget Splitting Rules
Core Principle: Prevent a single View file from getting too long (consider splitting if it exceeds 200 lines) to keep the view layer clean.
- Before building a page, analyze the design mockup to identify relatively independent large areas.
- Extract these areas into independent Widget files and place them in the sibling
widgets/directory. - The View layer is only responsible for composing these Widgets like building blocks.
Naming Convention: {module_name}_{area_name}_widget.dart
Example (Using Home Module as an example):
home/
├── bindings/home_binding.dart
├── controllers/home_controller.dart
├── views/home_view.dart ← Only the skeleton, references the widgets below
└── widgets/
├── home_header_widget.dart ← Top Logo + Search Bar
├── home_top_menu_widget.dart ← Icon menus like Leaderboard / Activity
├── home_banner_widget.dart ← Carousel Banner
├── home_announcement_widget.dart ← Announcement Bar
├── home_big_cards_widget.dart ← Large functional cards
├── home_category_tabs_widget.dart ← Business category Tab bar
└── home_product_list_widget.dart ← Content waterfall list
3. get_cli-Based Coding Standards
3.1 View Layer: Must extend GetView<T>
The View template generated by get_cli defaults to extending GetView<XxxController>. This project strictly follows this convention:
// ✅ CORRECT: Extends GetView, automatically holds the controller reference
class HomeView extends GetView<HomeController> {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Directly use controller to access business logic
Obx(() => Text(controller.userName.value)),
],
),
);
}
}
// ❌ INCORRECT: Manual Get.find, loses type safety
class HomeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = Get.find<HomeController>(); // Redundant and non-standard
...
}
}
3.2 Controller Layer: GetxController + Reactive State
class HomeController extends GetxController {
// --- Reactive State (Recommended: private variables + public getters) ---
final _isLoading = true.obs;
bool get isLoading => _isLoading.value;
final _records = <RecordModel>[].obs;
List<RecordModel> get records => _records;
// --- Lifecycle ---
@override
void onInit() {
super.onInit();
_loadData(); // Fetch data on page initialization
}
@override
void onClose() {
// Release resources: cancel subscriptions, close streams, etc.
super.onClose();
}
// --- Business Methods ---
Future<void> _loadData() async {
_isLoading.value = true;
try {
_records.value = await DataProvider.getRecords();
} finally {
_isLoading.value = false;
}
}
}
3.3 Binding Layer: Bindings Dependency Injection
class HomeBinding extends Bindings {
@override
void dependencies() {
// Lazy injection: instantiated when entering the route, automatically destroyed when leaving
Get.lazyPut<HomeController>(() => HomeController());
}
}
3.4 Routing Layer: Named Routes (Mandatory)
This project uses part files to split route definitions, which is exactly consistent with the structure generated by get_cli.
app_routes.dart (as a part file of app_pages.dart):
part of 'app_pages.dart';
abstract class Routes {
Routes._();
static const HOME = _Paths.HOME;
static const SEARCH = _Paths.SEARCH;
}
abstract class _Paths {
static const HOME = '/home';
static const SEARCH = '/search';
}
app_pages.dart:
import 'package:get/get.dart';
part 'app_routes.dart';
class AppPages {
AppPages._();
static const INITIAL = Routes.HOME;
static final routes = [
GetPage(
name: _Paths.HOME,
page: () => const HomeView(),
binding: HomeBinding(),
),
];
}
Route Navigation Rules (Strict Rules):
| Scenario | Correct Syntax | Forbidden Syntax |
|---|---|---|
| Navigate to a new page | Get.toNamed(Routes.SEARCH) | ~~Get.to(() => SearchView())~~ |
| Navigate and replace current page | Get.offNamed(Routes.HOME) | ~~Get.off(() => HomeView())~~ |
| Navigate and clear stack | Get.offAllNamed(Routes.HOME) | ~~Get.offAll(() => HomeView())~~ |
| Pass arguments | Get.toNamed(Routes.DETAIL, arguments: {'id': '123'}) | ~~Hardcoded path strings~~ |
| Retrieve arguments | final args = Get.arguments as Map<String, dynamic>; | — |
| Return with result | Get.back(result: data) | — |
4. Component Ownership: Global vs Module Private
| Global Components lib/components/ | Module Private Components modules/*/widgets/ |
|---|---|
| Zero business coupling, can be directly reused in another project. | Dependent on the current module's specific Model / Controller / Route logic. |
| e.g., Gradient buttons, network images, Loading dialogs, Empty state pages. | e.g., Specific business cards (depends on BusinessModel), custom category Tab bars. |
5. Data Layer (data/) Responsibility Division
| Subdirectory | Responsibility | Lifecycle |
|---|---|---|
| models/ | Dart data models corresponding to backend APIs. Shared across modules. | None (Pure data structure) |
| providers/ | The ONLY place allowed to make HTTP requests or read/write local storage. | Created on demand |
| services/ | Singleton services that start with the APP and reside permanently in the background. | Get.put(XxxService(), permanent: true) |
Criteria: If a Model is referenced by more than two modules, it belongs in data/models/, not as a module's private asset.
6. get_cli Common Commands Quick Reference
# Create a new module (automatically generates bindings + controllers + views + registers route)
get create page:trade
# Create a sub-module under an existing module
get create page:publish_product on trade
# Generate Controller only
get create controller:payment on trade
# Generate View only
get create view:payment_success on trade
# Generate Model from JSON
get generate model on home with assets/models/user.json
# Generate i18n translation files
get generate locales assets/locales
# Sort imports and format
get sort
pubspec.yaml Optional Configurations:
# File name separator (default is underscore, can be changed to dot)
get_cli:
separator: "_" # my_controller_controller.dart (if dot is selected: my_controller.controller.dart)
# Whether to generate sub-folders (default true)
get_cli:
sub_folder: false # Flat structure, do not generate bindings/ controllers/ views/ subdirectories
⚠️ This project maintains the default
sub_folder: trueconfiguration, which means retainingbindings/,controllers/, andviews/subfolders within each module.
7. Implementation Workflow (3 Steps for New Features)
-
Define Data (Data Layer)
- After confirming the backend API, handle the response entity class in
app/data/models/. - Encapsulate the request methods in
app/data/providers/.
- After confirming the backend API, handle the response entity class in
-
Create Module (Module Layer)
- Use
get create page:xxxor manually create thebindings/,controllers/, andviews/trio. - Immediately register the route in
routes/app_routes.dartandroutes/app_pages.dart. - Call the Provider written in step 1 within the Controller.
- Use
-
Build UI (View + Widget Layer)
- Analyze the design mockup and split the page into large areas within the
widgets/folder. - The View layer acts only as a skeleton, assembling Widgets like building blocks.
- Use or create common basic components from
lib/components/. - All event callbacks →
controller.doSomething(), do not handle logic in the View.
- Analyze the design mockup and split the page into large areas within the
8. Complex Page Splitting and Fat Controller Decomposition Guide
To address the bloating of a single GetxController (Fat Controller) caused by complex pages, we define 5 dimensions of decoupling strategies and a decision tree.
Conditions triggering this guideline:
- A single Controller being written or refactored is approaching or exceeds 300~500 lines.
- The page contains multiple independent heavy business logics (e.g., home page simultaneously containing a carousel, complex cascading lists, and dialog logic).
- Discovering a massive amount of network requests and data transformation logic directly written in the Controller.
🎯 Core Splitting Decision Tree
When encountering the need to add complex logic or refactor a giant Controller, please follow these priorities to think and execute:
🥇 Priority 1: Sink data processing logic to Provider / Repository
- Scenario: The Controller becomes huge because it's filled with HTTP request code, JSON to Model code, data cleansing/restructuring algorithms, and local storage read/writes.
- Action: Peel off all
dio.get/postand complex algorithms to theapp/data/providers/layer or independent UseCase classes. - Architectural Effect: The Controller returns to its essence as a "state dispatcher", keeping only: Call interface ➡️ Get Model (or exception) ➡️ Modify
.obsvariables to respond to UI.
🥈 Priority 2: Return pure UI state to native StatefulWidget
- Scenario: Unnecessary global/cross-component states are introduced merely to implement pure visual interactions. For example: password field clear/obscure text toggle, expand/collapse panel animation state, input field focus feedback.
- Action: Do NOT create variables like
isPasswordVisible.obsin the GetxController! Wrap the corresponding building block as aStatefulWidgetand usesetState()to manage its internal temporary closed-loop state. - Architectural Effect: Greatly reduces meaningless
obsvariables in the Controller, avoiding permanent memory residency.
🥉 Priority 3: Horizontal Slicing - Split out dedicated Sub-Controllers by UI Block
- Scenario: The current page is an aggregation page pieced together from several unrelated core business blocks (e.g., Home page's [Top Nav] + [Search Bar] + [Carousel] + [Waterfall]).
- Action:
- Don't handle everything in
HomeController. The main Controller only does external framework coordination. - For specific areas (like the Carousel), create a new
HomeBannerController. - Inject it in the current module's Binding file (
Get.lazyPut(() => HomeBannerController())). - The corresponding
home_banner_widget.dartextendsGetView<HomeBannerController>to implement self-contained data.
- Don't handle everything in
- Architectural Effect: Achieves logical isolation of in-page components, facilitating concurrent development by multiple people without code conflicts.
🏅 Priority 4: Deep Slicing - Extract into Sub-modules
- Scenario: A piece of complex logic not only has massive internal state but also involves independent page navigation flows, independent deep routing, or full-screen dialogs (e.g., a "Publish Form" page popping up in a business flow).
- Action: Use the get_cli command
get create page:XXX on YYY. This will generate a new module with its own independent Routing, Bindings, Controllers, and Views lifecycle inside the existing parent module (YYY). - Architectural Effect: Completely destroys massive feature branch states when the route is popped from the stack, preventing memory leaks.
🎖️ Priority 5: Extract into Global Service or Component
- Scenario: The complex logic is globally available and frequently reused in multiple places. For example: A globally floating player control bar, IM long connection message listener, global data settlement.
- Action:
- Pure headless service (logic): Extract into a
GetxServiceand inject it globally withpermanent: true. - Component with global UI presentation: Place in the top-level
lib/components/, letting it mount or create its own partial Controller internally to be self-sufficient.
- Pure headless service (logic): Extract into a
- Architectural Effect: Avoids writing these shared logics everywhere in the Controllers of various independent pages.
微信扫一扫