Skip to content

About

Very Good Modular App Template

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

18 Commits

Folders and files

Repository files navigation

Modular Flutter Template

A Flutter starter for apps that outgrow lib/features/.

verify license flutter

πŸ“– 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.json

You get a counter screen, and a list of posts fetched from JSONPlaceholder. That is deliberate β€” the value is the wiring around them.

Why this over folders

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.

The CLI

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 definitions

rename 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>.

What's in the box

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.

Architecture

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
Loading

The _api / _impl split

_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 data layer

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.

Navigation

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.

Startup

main.dart β†’ initializer():

  1. WidgetsFlutterBinding.ensureInitialized()
  2. DependencyInjectionConfiguration.initialize() β€” CompositionRoot over core modules, then feature modules β†’ DependencyContainer
  3. RouterConfiguration.initialize(container) β€” assembles feature routers, registers AppNavigationService
  4. runApp wraps DependencyScope β†’ RouterScope β†’ AppThemeScopeWrapper β†’ AppLocaleScopeWrapper β†’ RootApp

Conventions

  • Primary-constructor syntax β€” final class NetworkModule({final bool enableLogging = false}) implements .... Needs a recent SDK; see environment: in the root pubspec.
  • abstract interface class for every protocol in an _api package.
  • 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, not 16.0.

Local setup

git config core.hooksPath .githooks

Mirrors CI before your code leaves your machine. See .githooks/README.md.

Flavors

dev and prod on Android and iOS. Runtime config is read via String.fromEnvironment:

flutter run --flavor dev --dart-define-from-file=../config/dev.json

config/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.

Using Claude Code

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.

Contributing

See CONTRIBUTING.md. In short: flutter analyze, melos test and modular doctor must all pass.

License

MIT β€” see LICENSE.

About

Very Good Modular App Template

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages