flutter_simple_architecture 3.0.0
flutter_simple_architecture: ^3.0.0 copied to clipboard
A robust Flutter bootstrapping tool and project template emphasizing a clean, layer-first architecture with strict state management.
FSA (Flutter Simple Architecture) #
A robust Flutter bootstrapping tool and project template emphasizing a clean, layer-first architecture with strict state management and a centralized design system.
FSA CLI #
The project includes a CLI tool (fsa) to automate project creation and component scaffolding. Running fsa without any arguments will start an interactive menu to guide you.
For detailed architectural standards, command deep-dives, and engineering philosophy, see the FSA CLI Engineering Specification.
Installation #
Install from GitLab (Recommended)
To install fsa globally directly from the repository:
fvm dart pub global activate --source git https://gitlab.com/godoytristanh/fsa.git
Install from Local Source
If you have cloned the repository locally:
fvm dart pub global activate --source path .
Note: Make sure your Dart SDK's bin directory is in your system's PATH (usually ~/.pub-cache/bin).
Local Usage
If you prefer not to install it globally, you can run it directly from the root:
fvm dart bin/fsa.dart <command>
Commands #
-
Create a new project:
fsa create <project_name> <org_name>Bootstraps a new Flutter project with the FSA architecture. (Interactive if arguments are omitted).
- Clean Template: Removes default counter app boilerplate.
- Automated Config: Pre-configures
hooks_riverpod,freezed, andl10n. - Environment Ready: Includes
.fvmrcandanalysis_options.yaml. - Analysis Passing: Generates code that passes
flutter analyzeimmediately.
-
Audit project architecture:
fsa auditPerforms a high-speed scan of the codebase to identify architectural violations (e.g., direct provider access in views, missing repository guards, raw Material widget usage).
-
Generate a Page:
fsa generate page <name> [--type=async|sync|static]Creates a routeable page in
lib/presentation/<name>/. (Interactive if name is omitted).- Async (Default): Full
AppViewBuilder+Notifier+Union State. For complex, async features. - Sync:
AppViewBuilder+Notifier+Data Class. For synchronous features (forms, counters). - Static: Stateless
Scaffoldonly. For informational pages.
- Async (Default): Full
-
Generate a Smart Component:
fsa generate smart <path/to/name> [--type=async|sync]Creates a complex component with its own independent state management. Useful for logic-heavy widgets nested within pages. (Interactive if path is omitted).
-
Generate a Dumb Component:
fsa generate dumb <path/to/name>Creates a single-file
StatelessWidgetin thewidgets/subfolder of the specified path. (Interactive if path is omitted). -
Generate a Repository:
fsa generate repository <name>Creates a new repository class in
lib/domain/repositories/that extendsBaseRepository. -
Generate an Entity:
fsa generate entity <name>Creates a new
freezeddata class inlib/domain/entities/. -
Generate a Localization Key:
fsa generate l10n <key> <value>Appends a key-value pair to
app_en.arband triggers generation.
Running fsa generate with no subcommands will trigger an interactive selection menu.
Why Layer-First? (Architectural Philosophy) #
FSA makes a deliberate, opinionated choice: Layer-First Architecture over Feature-First.
The Problem with Feature-First (The Subjective Trap) #
In Feature-First architecture (e.g., features/login/, features/profile/), boundaries are subjective. Teams often waste hours in "Architectural Debates":
- "Does this logic belong to the Login feature or the Auth service?"
- "Where do I put a widget that is used by both Profile and Settings?"
- "We have a circular dependency because Feature A needs a repository from Feature B."
This leads to Feature Fragmentation, where business logic is scattered and duplicated, making the project harder to audit and maintain as it scales.
The Power of Layer-First (The Objective Foundation) #
Layer-First is objective. There is zero ambiguity about where a file belongs:
- A Data Contract is in
domain/repositories. - A UI Screen is in
presentation/. - A Network Interceptor is in
core/network.
This ensures a Standardized Dependency Flow: UI -> Domain -> Data. It forces developers to think about the application as a unified system rather than a collection of isolated islands.
Solving the "Dev UX" Problem #
The #1 complaint about Layer-First architecture is the Developer Experience (Dev UX):
- Boilerplate Fatigue: Creating 5 files in 3 different directories just to add one screen.
- Navigation Friction: Constant jumping between deeply nested folders.
The FSA Solution: CLI Automation #
FSA solves this by using the fsa CLI to bridge the gap. We provide the Speed of Feature-First with the Integrity of Layer-First.
| Feature-First (Subjective) | Standard Layer-First (Tedious) | FSA (CLI + Layered) |
|---|---|---|
| Fast start, messy end. | Slow start, clean end. | Fast start, clean end. |
| Ambiguous boundaries. | Clear boundaries. | Clear boundaries. |
| Manual wiring. | Manual wiring. | Automated wiring via CLI. |
Example: The 1-Second Vertical Slice
To add a new "Profile" screen, you don't manually create folders and files. You run:
fsa generate page profile
The CLI instantly scaffolds the entire vertical slice across layers:
lib/presentation/profile/view.dart(UI Layer)lib/presentation/profile/ui_state.dart(State Layer)lib/presentation/profile/ui_state_notifier.dart(Logic Layer)
By automating the "Manual Labor" of layering, FSA allows developers to focus 100% on Business Logic while the architecture remains Strict, Objective, and Industrial-Grade.
Observability & Security #
1. Secure Session Observability #
FSA includes a "Black Box" logging system. The LoggerService maintains an in-memory buffer of the last 100 Breadcrumbs (Sanitized events like State updates, Network requests, and Navigation).
- Data Scrubbing: All logs pass through
LogSanitizer, which recursively redacts sensitive keys (e.g.,password,token,email). - Crash Autopsy: When an uncaught exception occurs, FSA automatically prints a Secure Crash Report containing the exception, stack trace, and the sanitized session timeline.
2. Environment Configuration #
Manage different environments using --dart-define=ENV=dev|stg|prod.
- Provider: Access via
envProvider. - Config: Defined in
lib/core/config/env.dart.
3. Persistent Storage #
A unified StorageService provides type-safe access to:
- SharedPreferences: For non-sensitive settings (Theme, Onboarding).
- SecureStorage: For sensitive data (Auth tokens).
4. Connectivity Monitoring #
The ConnectivityService automatically monitors internet status. If the connection drops, it emits a global SideEffect.warning to notify the user instantly on any screen.
5. App Bootstrap Orchestrator #
To prevent the "White Screen of Death" and ensure high polish, FSA uses a BootstrapService and SplashView.
- Sequence: Automatically initializes
StorageService,ApiClient, andEnvironmentbefore the user enters the app. - Silent Login: The bootstrap sequence includes a
trySilentLogin()check to automatically restore the user's session if a valid token exists. - Fail-Safe: If a critical service fails to initialize, the
SplashViewdisplays a detailed error and a retry mechanism.
Core Infrastructure Pillars #
1. Authentication Blueprint #
FSA provides a pre-wired, secure Authentication flow out of the box.
- Session Management: Handled via
authProvider. It managesinitial,unauthenticated, andauthenticatedCase states. - Auto-Injection: The
AuthInterceptorautomatically attaches the Bearer token to everyDiorequest if the user is logged in. - Security: Auth tokens are persisted using
StorageService(utilizingflutter_secure_storagefor sensitive data). - Auto-Logout: If any API request returns a
401 Unauthorized, the interceptor triggers an automatic global logout and redirects to the login flow.
2. Infinite Pagination Mixin #
Standardize large list handling using the AppPaginationMixin<T, State>.
- Features: Automates
pageNumber,hasMore, andisLoadingMorelogic. - Usage: Mix it into any
Notifierto instantly add pagination capabilities to your state.
3. Persistent Theme Orchestrator #
Allow users to switch between Light, Dark, and System modes with persistence.
- Provider: Access via
themeProvider. - Persistence: User preferences are automatically saved to
StorageServiceand restored on app launch.
4. Semantic Form Validation (DSL) #
Upgrade your form logic with a declarative Validation DSL.
- Rules: Define rules like
ValidationRule.required(),ValidationRule.email(), orValidationRule.minLength(8). - Integration:
AppFormMixinautomatically scans your rules, validates fields, and updates theUIStatewith field-specific error messages.
UI & Layout #
1. Adaptive Layout System #
FSA provides a standardized way to handle Mobile, Tablet, and Desktop breakpoints.
AppResponsiveWidget: A layout builder that automatically switches betweenmobile,tablet, anddesktopversions of a screen.ScreenTypeExtensions: Accessible viacontext.screenTypeorcontext.isMobile/isTablet/isDesktop.
2. Standardized Skeleton/Shimmer #
Every AppViewBuilder supports a buildLoading method. By utilizing the built-in Shimmer and SkeletonBox widgets, you can create branded skeleton UIs that automatically inherit the app's theme.
Architectural Principles #
1. Page Types (Screens) #
The CLI supports three strict page archetypes. Choose the one that matches your feature's complexity.
CRITICAL RULE: Async views (and AppViewBuilder in general) MUST ONLY be used if the view performs an initial asynchronous task or calculation (e.g., fetching data from an API, loading from a database, or running a heavy computation on initialization). If a view does not require an initial async load, use Sync (for local logic) or Static (for no logic).
Async Page (Default)
- Command:
fsa generate page <Name> - Structure:
AppViewBuilder+Notifier+Data ClasswithStatus Enum. - When to use:
- EXCLUSIVELY when the view performs an initial async calculation or data fetch.
- You need to handle loading spinners, error states, or retry logic for that initial task.
- You want to preserve existing data while refreshing (e.g., keeping the list visible while a new fetch is in progress).
Sync Page
- Command:
fsa generate page <Name> --type=sync - Structure:
AppViewBuilder+Notifier+Data Class(Sync State). - When to use:
- The view has logic/interactions but DOES NOT perform an initial async load.
- State management is synchronous and local (e.g., Form validation, Counter, Toggle settings).
- You do not need a loading spinner or terminal error handling.
Static Page
- Command:
fsa generate page <Name> --type=static - Structure:
StatelessWidget(Scaffold only). - When to use:
- Purely informational pages (About Us, Terms of Service).
- Static navigation menus.
- NO logic, NO state, NO providers (except Theme/L10n).
2. Component Types (Widgets) #
Dumb Component (Preferred)
- Command:
fsa generate dumb <path/Name> - Structure:
StatelessWidgetorHookWidgetin awidgets/folder. - Rule:
- Must receive ALL data via constructor arguments.
- Must communicate actions via
VoidCallbackorFunction(T)callbacks. - NEVER access a Provider or Notifier directly.
- Hooks Policy: Dumb components are the ONLY designated place for
flutter_hooks(e.g.,useTextEditingController,useTabController, oruseAnimationController) to manage local widget-level lifecycle state.
Smart Component
- Command:
fsa generate smart <path/Name> [--type=async|sync] - Structure:
AppViewBuilder+Notifier+State(nested folder). - Rule:
- Use ONLY when the component must manage its own independent lifecycle (e.g., a
UserAvatarthat fetches its own image by ID, independent of the page). - Async: Uses
Data ClasswithStatus Enum. - Sync: Uses
Data Classfor local state.
- Use ONLY when the component must manage its own independent lifecycle (e.g., a
3. State Management #
- Manual Riverpod Providers: We strictly use
NotifierProvider.autoDisposeandProvider.autoDisposewithout code generation. No exceptions. Use non-autoDispose only for global persistent services (e.g., Auth, Database) after careful consideration. - UI States: Every async component MUST use a
freezeddata class with aStatus Enum(e.g.,initial,loading,success,error) to represent its UI state. This prevents data loss when transitioning between states (e.g., you can keeptodoswhile the status isloading). - Data Class Pattern: We use
freezedwith an abstract class for all state objects and domain entities. - View Isolation Rule (CRITICAL):
view.dartfiles (the UI layer) ARE FORBIDDEN from usingref.watch(),ref.read(), orref.listen()of any provider. All data dependencies MUST be managed by theUIStateNotifierand exposed via theUIState. - No Hooks in Views: Files extending
AppViewBuilderSHALL NOT useflutter_hooks. All initialization logic must be handled in the Notifier'sbuild()method or via an explicitload()action triggered by the router/parent. - Notifier Reactivity:
UIStateNotifieris the ONLY designated place to useref.watch()to react to changes in domain providers or repositories. - Strict Privacy:
UIStateNotifierproviders (e.g.,homeUIStateProvider) are private to their respective features and MUST ONLY be consumed by their correspondingview.dart. No other provider or component shall depend on a UI Notifier. - Clean View Rule:
view.dartfiles SHALL NOT contain private helper classes, private widgets, or methods that return widgets (e.g.,_buildItem). All sub-widgets MUST be extracted to awidgets/folder within the feature or moved tolib/core/widgetsif reusable.
4. Error Handling & Side Effects #
- Functional Programming: Powered by
fpdart. Repositories returnEither<Failure, T>. - The Guard Wrapper: Asynchronous operations are wrapped in a
guard()function. - Transactions & Rollback: Multi-step operations (e.g., File Upload + Database Save) utilize
runTransaction([...])to ensure atomicity. If a step fails, all previous steps are rolled back in reverse order. - Repository Caching: The
BaseRepositoryprovidesfetchWithCache<T>, which implements a robust Cache-Then-Network strategy, allowing for offline-first capabilities with zero extra boilerplate.
Global Side-Effect Management #
Non-interactive events (Success dialogs, Error alerts, etc.) are managed via a centralized, context-less system.
- The Trigger: Notifiers emit a
SideEffectusing thesideEffectProvider:ref.read(sideEffectProvider.notifier).notify( const SideEffect.success(title: 'Done', message: 'Task added!') ); - The Handler: The root
MyAppwidget (lib/main.dart) listens to this provider globally. - The Display: When an effect is emitted,
main.dartuses theNavigationServiceto find the current context and displays the appropriateAppDialogautomatically.
This ensures that developers never have to manually manage showDialog() or BuildContext for standard alerts, and it prevents duplicate dialogs from appearing when multiple components are active.
Riverpod 3 Implementation Guide #
To ensure consistency and avoid common mistakes, follow these strict rules for Riverpod 3 within FSA:
1. Manual Providers (NO CODE GEN) #
We do not use @riverpod or build_runner for providers. Every provider must be explicitly defined using the NotifierProvider or Provider factories.
2. The Notifier Pattern (UI Layer) #
UI State is managed by a Notifier<TUIState>.
// lib/presentation/feature/ui_state_notifier.dart
class FeatureUIStateNotifier extends Notifier<FeatureUIState> {
@override
FeatureUIState build() {
// 1. Reactive dependencies go here (watch other providers)
final domainData = ref.watch(domainDataProvider);
// 2. Initial state logic
if (domainData.isEmpty) return const FeatureUIState(status: FeatureUIStatus.initial);
return FeatureUIState(status: FeatureUIStatus.success, data: domainData);
}
// 3. Actions use ref.read() for non-reactive dependencies (repositories/services)
Future<void> performAction() async {
state = state.copyWith(status: FeatureUIStatus.loading); // Terminal state change
final repository = ref.read(repositoryProvider);
final result = await repository.fetchData();
result.fold(
(failure) => state = state.copyWith(
status: FeatureUIStatus.error,
errorMessage: failure.message,
),
(data) => state = state.copyWith(
status: FeatureUIStatus.success,
data: data,
),
);
}
// 4. Side effects (Snackbars/Dialogs) use sideEffectProvider
void showNotification() {
ref.read(sideEffectProvider.notifier).notify(
const SideEffect.info(title: 'Update', message: 'Action triggered!')
);
}
}
// ALWAYS use .autoDispose for UI-bound providers
final featureUIStateProvider =
NotifierProvider.autoDispose<FeatureUIStateNotifier, FeatureUIState>(() {
return FeatureUIStateNotifier();
});
3. AsyncNotifier vs Notifier #
- Prefer
Notifiercombined with a Data ClassUIState(Freezed) and aStatus Enumfor most UI screens. This gives you explicit control overinitial,loading,success, anderrorstates while preserving data across transitions. - Use
AsyncNotifieronly for simple data fetching where the standardAsyncValue(loading/error/data) is sufficient without custom UI transitions or the need to preserve complex state during refreshes.
4. Ref Usage Summary #
| Location | Method | Why |
|---|---|---|
build() |
ref.watch() |
To react to changes in domain/service providers and rebuild the state. |
| Methods | ref.read() |
To access repositories or "trigger" actions on other notifiers without creating a subscription. |
UI (view.dart) |
FORBIDDEN | The UI must only interact with state and notifier provided by AppViewBuilder. |
5. Design System #
- Sealed Widgets: Base components (
AppButton,AppText,AppDialog,AppTextField,AppChip) use sealed classes and factory constructors to enforce semantic consistency. - Pure Inheritance: Widgets are designed as "Naked Shells" that return standard Material components (e.g.,
ElevatedButton,TextField,RawChip). They automatically inherit 100% of their styling from the globalThemeDatadefined inlib/core/theme/theme.dart. - Zero Manual Coding: Apart from Typography variants, build methods do not contain
Theme.of(context)calls or manual color/border logic.
Project Structure #
doc/: Production-grade engineering specifications.bin/: CLI entry point.lib/core/: Global logic, infrastructure (network, configuration), and Design System.lib/domain/: Business logic contracts and models.lib/presentation/: UI screens and self-contained smart components.
Getting Started #
- Ensure FVM is installed.
- Run
fvm flutter pub get. - Generate boilerplate:
fvm flutter pub run build_runner build --delete-conflicting-outputs. - Run the app:
fvm flutter run --dart-define=ENV=dev.