A Flutter starter for apps that outgrow lib/features/.
π Browse the architecture, module reference and CLI walkthrough
Most Flutter "clean architecture" templates give you folders. Folders do not stop anyone importing anything. This one gives you package boundaries the analyzer enforces, and a CLI that keeps them honest as the app grows.
Every capability is two packages β an _api package holding the contract and an
_impl package holding the implementation. implementation_imports is an
analyzer error, so a package's lib/src/ is genuinely unreachable from
anywhere else. Only app may depend on an _impl.
git clone https://github.com/Yusubov-Engineering/modular-flutter-template my_app
cd my_app && flutter pub get
dart pub global activate --source path tool/cli # installs `modular` from this repo
modular rename --bundle-id com.acme.myapp --name "My App"
cd app && flutter run --flavor dev --dart-define-from-file=../config/dev.jsonYou get a counter screen, and a list of posts fetched from JSONPlaceholder. That is deliberate β the value is the wiring around them.
| Folders | This template | |
|---|---|---|
| Import a feature's internals | nothing stops you | analyzer error |
| Feature A reaching into feature B | one import away | impossible; go through _api |
| Forgot to register a module | crashes at runtime | modular doctor catches it |
| Navigating by hardcoded path | everywhere | route addresses are private to _impl |
| Adding a feature | copy, paste, rename, wire by hand | modular new feature <name> |
The cost is real: two packages per capability, and a composition root you have to keep current. Worth it above roughly ten features or two teams; probably not below that.
dart pub global activate --source path tool/cli # from the repo root
modular rename --bundle-id com.acme.myapp --name "My App" # make it yours
modular new feature user_profile # _api + _impl, fully wired
modular doctor # check the architecture rules
modular gen assets # regenerate asset definitionsrename is the first thing to run after cloning. It rewrites the Android
namespace and applicationId, the iOS bundle identifier and display name
across every build configuration, and moves the Kotlin package and its
directory to match β about thirty edits, all validated as a legal Android
package name before anything is written.
new feature does not just create packages β it wires the feature into the
workspace list, the DI module list and the router. That is the point: a feature
that is never registered compiles fine and fails at runtime.
doctor checks what the compiler cannot:
β module-not-registered: GhostFeatureModule is never registered, so resolving
its API will throw at runtime.
app/lib/bootstrap/dependency_injection_configuration.dart
The CLI lives in tool/cli, inside this repo, so the version you
run always matches the template you cloned. Without activating it, run
dart run tool/cli/bin/modular.dart <command>.
| Layer | Package | Notes |
|---|---|---|
| app | app |
Composition root: DI config, router config, flavors, adapters |
| base | app_localization |
ARB translations in 7 languages, locale notifier + scope, persisted |
app_network_contract |
AppResponse + parser β the seam you replace for your API |
|
| core | design_system (+ assets) |
Tokens, theming, a few example components |
dependency_injection |
DependencyContainer / DependencyModule, get_it impl |
|
router |
Route/guard/shell abstractions, go_router impl |
|
network |
Envelope-agnostic REST client, dio impl |
|
storage |
Standard + secure key/value storage | |
logger |
talker impl with an in-app log screen |
|
biometric_auth |
Face ID / Touch ID | |
state_manager |
From-scratch State/Event/Effect controllers, no dependencies | |
analytics |
Analytics + crash reporting over Firebase. Optional: the example app does not use it | |
speech |
On-device speech recognition over speech_to_text. Optional: the example app does not use it |
|
app_linter |
The shared ruleset β change rules there, never per package | |
| tooling | tool/cli |
The modular CLI: scaffolding + doctor β see The CLI |
| features | counter |
A counter, as _api + _impl. The shape new feature generates. |
posts |
The same shape with a data layer: list + details over GET /posts. |
Everything lives in this one repository, as one pub workspace: a single
flutter pub get at the root resolves every package, and packages depend on
each other by name, with no source or version:
dependencies:
dependency_injection_api:
router_api:Changing a core module is an ordinary edit, checked against every package that
uses it in the same commit. Optional modules (analytics, speech) are
workspace members too, so an app that doesn't need one can delete its folder and
its line in the root pubspec.yaml.
design_system ships the token system in full but only six components. Build
your own on the tokens rather than extending a component library you did not
design. Colour ramps, spacing, radius and type scales are all trimmed to a small
illustrative set β add steps as your product needs them.
flowchart TD
subgraph AppLayer ["/app"]
app["app<br/>composition root Β· flavors<br/>DI config Β· router config"]
end
subgraph FeaturesLayer ["/features"]
counter["counter<br/>api + impl"]
posts["posts<br/>api + impl Β· data layer"]
end
subgraph BaseLayer ["/base"]
direction LR
app_localization["app_localization<br/>ARB Β· locale scope"]
app_network_contract["app_network_contract<br/>AppResponse Β· parser"]
end
subgraph CoreLayer ["/core"]
direction LR
design_system["design_system<br/>+ assets"]
dependency_injection["dependency_injection<br/>api + impl (get_it)"]
router["router<br/>api + impl (go_router)"]
network["network<br/>api + impl (dio)"]
storage["storage<br/>api + impl"]
biometric_auth["biometric_auth<br/>api + impl"]
logger["logger<br/>api + impl (talker)"]
state_manager["state_manager"]
analytics["analytics<br/>api + impl (Firebase) Β· optional"]
speech["speech<br/>api + impl Β· optional"]
app_linter["app_linter<br/>lint rules only"]
end
AppLayer ==>|"wires every _impl module"| FeaturesLayer
AppLayer ==>|"configures core modules"| CoreLayer
AppLayer ==>|"supplies the response contract"| BaseLayer
FeaturesLayer -->|"localization"| BaseLayer
FeaturesLayer -->|"depends on _api contracts only"| CoreLayer
BaseLayer -.->|"app_network_contract β network_api"| CoreLayer
classDef layerBox fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray:5 5
classDef appStyle fill:#eef2ff,stroke:#4f46e5,stroke-width:2px,color:#1e1b4b
classDef featureStyle fill:#fdf4ff,stroke:#a21caf,stroke-width:1.5px,color:#701a75
classDef baseStyle fill:#f5f3ff,stroke:#8b5cf6,stroke-width:1.5px,color:#4c1d95
classDef coreStyle fill:#f0fdfa,stroke:#0f766e,stroke-width:1.5px,color:#134e4a
class AppLayer,FeaturesLayer,BaseLayer,CoreLayer layerBox
class app appStyle
class counter,posts featureStyle
class app_localization,app_network_contract baseStyle
class design_system,dependency_injection,router,network,storage,biometric_auth,logger,state_manager,analytics,speech,app_linter coreStyle
_api says what a capability can do; _impl says how it does it.
Each feature publishes exactly one protocol, resolved from the container:
final counter = context.locator<CounterApi>();
context.navigation.pushRoute(counter.launcher.details(count: 7));Your repositories, DTOs and error types live inside _impl/lib/src/ and are
invisible across every boundary above. The template does not prescribe them.
The _api/_impl split is about module boundaries, not about what happens
inside a module. posts is the worked example: PostDto decodes the wire
format, PostsRemoteDataSource owns the paths, PostsRepositoryImpl maps every
AppNetworkException onto a sealed PostsFailure, and the controllers see
nothing but models and failures.
posts_impl/lib/src/
domain/ post Β· posts_repository Β· posts_failure β what the feature means
data/ post_dto Β· remote data source Β· repository β how it is fetched
All of it lives under lib/src/, so none of it is reachable from another
package β which is the point. Copy the shape if it suits your feature, or don't;
the template prescribes the boundary, not the layers behind it.
Route addresses are private to _impl. A launcher returns an
AppRouteRequest instead of navigating, so the caller picks the verb and
launchers stay const and dependency-free:
// From another module β through the launcher.
context.navigation.goRoute(counter.launcher.counter());
// Inside the owning module β route info directly.
context.navigation.pushRoute(
const AppRouteRequest(routeInfo: CounterRouteInfo.counter),
);This is also why RouterConfiguration can resolve feature facades before
AppNavigationService exists.
main.dart β initializer():
WidgetsFlutterBinding.ensureInitialized()DependencyInjectionConfiguration.initialize()βCompositionRootover core modules, then feature modules βDependencyContainerRouterConfiguration.initialize(container)β assembles feature routers, registersAppNavigationServicerunAppwrapsDependencyScopeβRouterScopeβAppThemeScopeWrapperβAppLocaleScopeWrapperβRootApp
- Primary-constructor syntax β
final class NetworkModule({final bool enableLogging = false}) implements .... Needs a recent SDK; seeenvironment:in the root pubspec. abstract interface classfor every protocol in an_apipackage.- Lint rules live in
core/app_linter, shared by every package. Change rules there, never per package. - Design-system tokens, never raw values β
context.spacing.spacingXl, not16.0.
git config core.hooksPath .githooksMirrors CI before your code leaves your machine. See .githooks/README.md.
dev and prod on Android and iOS. Runtime config is read via
String.fromEnvironment:
flutter run --flavor dev --dart-define-from-file=../config/dev.jsonconfig/dev.json and config/prod.json ship pointing at JSONPlaceholder so the
posts demo runs out of the box. Point API_BASE_URL at your own backend β and
replace AppResponseParser in base/app_network_contract with your envelope.
CLAUDE.md documents the architecture, the commands and the editing traps for AI assistants working in a generated project. It is picked up automatically by Claude Code β keep it in your clone.
See CONTRIBUTING.md. In short: flutter analyze,
melos test and modular doctor must all pass.
MIT β see LICENSE.