A maintainable GetX project structure
By Vivek Patel · Updated
GetX combines state, dependency registration, and routing, but these capabilities do not have to be adopted together. A maintainable GetX app keeps registration visible, limits controller lifetime, and lets repositories hide external services.
How the pieces connect
An illustrative folder structure
This structure explains the architecture. Exact generated paths are shown in the source walkthrough below.
lib/
app/routes/
features/home/
bindings/home_binding.dart
controllers/counter_controller.dart
views/home_page.dart
domain/repositories/
data/repositories/Use route bindings for route-owned dependencies
A binding can register a controller when a route is opened. Treat a registration as an ownership decision: a checkout controller usually should not live for the entire application. Reserve permanent registrations for application services. Verify lifecycle behavior when using nested navigation or retaining tabs.
Keep views reactive and controllers focused
Use Obx around the smallest meaningful region that reads reactive state. A controller should coordinate one feature, not store every application value. Cancel subscriptions and timers when the controller is disposed. Avoid embedding HTTP requests in build methods.
Keep dependency lookup at a boundary
Global lookups are convenient but obscure requirements in tests. Pass repositories through constructors so the controller can be constructed with a fake. The binding or composition root can perform Get.find while domain logic stays independent of GetX.
Know when to choose another structure
GetX can be convenient for a small team already familiar with its conventions. For a large codebase with strict dependency visibility, explicit constructor composition or a provider graph may be easier to audit. You can use GetX routing with a different state solution; routing should not force a state migration.
Inside the generated starter
Inspect controller registration and widget ownership in the GetX starter before adding a second route. A binding file and a widget initializer are two potential ownership paths, so trace which one your screen actually uses.
These source files are produced from the same templates used by the generator, with the configuration below. They are starter examples; the observations identify work still needed for a real feature.
lib/features/home/controllers/counter_controller.dart
import 'package:get/get.dart';
import '../../../domain/repositories/counter_repository.dart';
class CounterController extends GetxController {
CounterController(this.repository);
final CounterRepository repository;
final count = 0.obs;
Future<void> increment() async { final next = count.value + 1; await repository.save(next); count.value = next; }
}
lib/features/home/bindings/home_binding.dart
import 'package:get/get.dart';
import '../controllers/counter_controller.dart';
import '../../../core/dependency_injection/container.dart';
class HomeBinding extends Bindings {
@override
void dependencies() { Get.lazyPut(() => CounterController(container.counterRepository)); }
}
What to notice
CounterController accepts a repository and exposes an observable count. Its increment method saves before changing that count. The controller is small enough to instantiate with a fake repository without launching the whole application.
The generated home widget also supplies an init controller to GetBuilder. The presence of HomeBinding does not prove that every navigation route uses that binding. Before adding route-owned resources, inspect the generated router and choose one registration path deliberately.
A global repository can outlive a route controller. Decide which state should survive navigation and which should reset. Do not use permanent registration merely to make a missing dependency error disappear.
Extend it and verify the result
Add a disposable subscription to a route-owned controller and cancel it in onClose. Navigate home, open another route, and return several times. Confirm the number of active subscriptions stays constant. If a controller must be shared across routes, document its application-level owner instead.
- One screen action causes one repository write.
- Leaving the owning route releases its subscription.
- Reopening the route follows the intended reset or restore behavior.
- The controller can be tested using constructor injection.
These are exercises to add to your application tests, not claims that the starter already handles every case.
Try this configuration
Feature First · GetX · GetX DI · GetX Routing
The generator includes a working counter example with a repository contract, selected dependencies, navigation, and local setup instructions. Extend the example around your own domain before shipping.
Configure your Flutter project →For prerequisites, exact setup commands, expected behavior, and build troubleshooting, follow the flutter download-to-first-run walkthrough. Check the validation record for the configurations actually checked.
Continue reading
- A practical guide to Flutter Clean Architecture
- BLoC vs Riverpod: choose by how your team works
- Structure a Flutter Riverpod application
Primary reference
Get package documentation. The source walkthrough above describes AppScaffold’s implementation; platform documentation describes the broader APIs and recommendations.