Flutter Zero: A go-zero-Inspired MVI-BLoC Scaffold That Generates Your Entire Feature Module
My main job is Android development, and I've worked on several Flutter projects over the past few years. I wonder if you've had a similar feeling: every time you start a new project, you have to reconfigure the base libraries, set up the architecture, and write all that boilerplate code that looks almost identical. BLoC's State and Event definitions, freezed annotations, Repository CRUD wrappers, DI registration, route configuration... it's all manual labor every time.
I studied the Go language for a while before, and there's an open-source framework called go-zero that I really like. It generates template code through the goctl command-line tool—API, RPC, database operations all done with a single command, and the entire project skeleton requires no handwriting. At the time, I thought, why doesn't Flutter have something like this?
I looked around and found some options, but they were either not encapsulated thoroughly enough (just generating empty folders), had moved toward commercial paid template routes, or lacked documentation to the point of being unusable. Since there wasn't a satisfactory one, I decided to build it myself. Partly for learning, and partly to make it easier for myself when starting new projects—though it can't compare to commercial products, it's sufficient for my own use.
Because I come from an Android background, the overall encapsulation leans toward Android development habits. The chosen MVI architecture is also in line with Android's MVI: Intent (Event) drives State changes, the View consumes State unidirectionally, and side effects are distributed through an independent channel.
The entire Flutter Zero toolchain consists of four repositories: the CLI tool fluzer (published on pub.dev), the Mason template source flutter_zero_template, the example app flutter_zero_app, and a bilingual documentation site. This article isn't going to explain the source code line by line; instead, it focuses on the most noteworthy designs within the MVI-BLoC architecture in the project generated by the template. If you're using flutter_bloc for medium-to-large projects, or are interested in how BLoC can extend capabilities through Mixin composition, I hope this can provide some reference.
A Brief Overview of the CLI
Install fluzer and create a project:
dart pub global activate fluzer
fluzer create my_app
Enter the project directory and generate a feature module:
cd my_app
fluzer new user_center
A single command generates a complete set of files under lib/features/user_center/: BLoC (bloc + state + event, freezed structure), Repository (extending BaseRepository), Effect handler, Page (with BlocProvider + EffectListener already assembled), and a DI registration module (UserCenterModule). It also automatically inserts UserCenterModule.register(getIt) into injection_base.dart, so you don't need to manually modify any files.
The CLI also provides the gen-l10n command, which automatically generates type-safe L10nCode calling code based on Flutter's native .arb files—this will be detailed later.
Alright, that's it for the CLI. Let's get to the main topic.
Core Architecture: The Responsibility Split of Four Mixins
First, take a look at the overall architecture diagram to get a general idea:
┌──────────────────────────────────────────────────────┐
│ UI Layer (Page) │
│ BlocProvider ─→ BlocBuilder (Consumes State) │
│ ─→ EffectListener (Consumes Effect, Chain of Responsibility)│
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ BLoC Layer │
│ ┌─────────────┬──────────────┬────────────────────┐ │
│ │BlocAwait │BlocEffect │BlocCancelToken │ │
│ │Mixin │Mixin │Mixin │ │
│ │(await event)│(Side-effect Stream)│(Dio CancelToken) │ │
│ ├─────────────┴──────────────┴────────────────────┤ │
│ │ BlocErrorHandlerMixin │ │
│ │ (runCatching → Result<T>) │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ Data Layer │
│ Repository (BaseRepository, only holds Dio) → Dio → Server│
│ Exceptions: Raw Exception thrown directly, framework does not normalize│
└──────────────────────────────────────────────────────┘
In traditional BLoC writing, an on<LoginSubmitted> is stuffed with business logic, error handling, loading state switching, Toast prompts... you can't even understand it yourself later. Flutter Zero's approach is to split four orthogonal capabilities into independent Mixins, each doing only one thing, and composing them to form a complete BLoC:
// flutter_zero_app/lib/core/bloc/ directory, four files
class HomeBloc extends Bloc<HomeEvent, HomeState>
with
BlocAwaitMixin<HomeEvent, HomeState>,
BlocEffectMixin<HomeState>,
BlocCancelTokenMixin<HomeState>,
BlocErrorHandlerMixin<HomeState> {
// Only pure business logic remains
}
The four Mixins are independent of each other and can be combined as needed. CounterBloc doesn't need network requests, so it doesn't need to mix in BlocCancelTokenMixin; the simplest Cubit can also use the latter three.
Let's break them down one by one, explaining what problem each solves, how it's designed, and what boundary cases are worth noting.
BlocAwaitMixin — Letting the UI await BLoC Events
flutter_bloc's add(Event) is fire-and-forget. Once the event is sent, the outside world has no idea when the BLoC has finished processing it or what the result is. This problem is most glaring in RefreshIndicator.onRefresh—it needs a Future<void> return value to decide when the pull-down animation should stop.
A common solution is to add an isRefreshing field in the State, and the UI layer judges based on this field. But this mixes two things together: loading status is UI state and should be expressed by State; but "when the operation ends" is a control flow issue and should be expressed by a Future. Moreover, this solution has a hidden danger—if the refresh logic has an early return (e.g., hasReachedMax returns directly), isRefreshing will never be set back to false.
BlocAwaitMixin's approach is more direct: use a Completer to bridge the BLoC's internal asynchronous operations and the UI layer's Future.
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart (full implementation ~90 lines)
mixin BlocAwaitMixin<Event, State> on Bloc<Event, State> {
final Map<String, List<Completer<void>>> _awaitCompleters = {};
// Called by UI layer: sends event + returns Future
Future<void> runAwait({
required Event event,
required String key,
Duration timeout = const Duration(seconds: 30),
}) {
final completer = Completer<void>();
_awaitCompleters.putIfAbsent(key, () => []).add(completer);
add(event);
return completer.future.timeout(timeout);
}
// Called inside BLoC: event processing finished, complete all Futures waiting on this key
void completeAwait(String key) {
final completers = _awaitCompleters.remove(key);
for (final c in completers ?? <Completer<void>>[]) {
if (!c.isCompleted) c.complete();
}
}
}
The UI layer usage is very clean:
// flutter_zero_app/lib/features/home/presentation/bloc/home_bloc.dart
// BLoC provides a refresh() method, returning Future<void>
Future<void> refresh() => runAwait(
event: const HomeEvent.refresh(),
key: _awaitKeyHomeRefresh,
);
// Page layer directly awaits
RefreshIndicator(
onRefresh: () => context.read<HomeBloc>().refresh(),
child: ListView(...),
)
Or directly in the UI layer:
// Or in the UI layer
// Page layer directly awaits
RefreshIndicator(
onRefresh: () => context.read<HomeBloc>.runAwait(
event: const HomeEvent.refresh(),
key: 'home_refresh',
),
child: ListView(...),
)
That's not all. Manually calling completeAwait has a hidden danger—if the BLoC internally skips the finally block due to an early return, or an exception is caught but the finally block forgets to call it, the Completer hangs forever.
Initially, I thought writing good documentation reminders would suffice, but later I remembered a concept from go-zero: the framework should eliminate all things that "might be forgotten." So I added the onAwait method:
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart
void onAwait<E extends Event>(
String key,
Future<void> Function(E event, Emitter<State> emit) handler,
) {
on<E>((event, emit) async {
try {
await handler(event, emit);
} finally {
completeAwait(key); // Whether normal, exception, or early return, it will reach here
}
});
}
Compare the difference between the two approaches:
// Manual management — easy to miss
on<RefreshPosts>((event, emit) async {
try {
final posts = await repository.fetch();
emit(state.copyWith(posts: posts));
} catch (e) {
emit(state.copyWith(error: e.toString()));
} finally {
completeAwait('refresh'); // Missing this line = pull-down animation spins forever
}
});
// onAwait automatically cleans up — no such thing as "forgetting"
onAwait<RefreshPosts>('refresh', (event, emit) async {
final posts = await repository.fetch();
emit(state.copyWith(posts: posts));
// try/finally handled by the framework, completeAwait is called even on exception
});
There are a few boundary designs worth mentioning.
The value is a List, not a single Completer. The type of BlocAwaitMixin._awaitCompleters is Map<String, List<Completer<void>>>. Why not Map<String, Completer<void>>? Because the same key might be awaited concurrently. For example, if a page quickly switches to the background and back, didChangeDependencies triggers two refreshes. If the value were a single Completer, the second would overwrite the first, and the first Future would never complete. Using a List to collect them, completeAwait iterates and completes all.
Default 30-second timeout. The timeout parameter of runAwait defaults to 30 seconds. This safety net prevents extreme edge cases: for instance, if an Error (not an Exception) is thrown inside the event handler, Dart's try/catch might not catch it (depending on the Dart version and error type), and the Completer would hang permanently. The timeout is the final fallback.
Actively complete all pending Completers on close. The close() method is overridden to iterate through all remaining Completers and call completeError, preventing Future leaks:
// flutter_zero_app/lib/core/bloc/bloc_await_mixin.dart
@override
Future<void> close() {
for (final entry in _awaitCompleters.entries) {
for (final completer in entry.value) {
if (!completer.isCompleted) {
completer.completeError(
StateError('BLoC closed before operation ${entry.key} completed'),
);
}
}
}
_awaitCompleters.clear();
return super.close();
}
When a page exits, the BLoC is disposed → close is called → all uncompleted Completers throw a StateError. If the caller is still awaiting, at least it won't hang indefinitely.
BlocEffectMixin — Completely Separating Side Effects from State
In MVI, things like Toast prompts, dialogs, and page navigations are "one-time side effects." They have no direct relationship with business state—"showing a Toast" is not a UI state; it's an action, done and over with.
The traditional approach stuffs side effects into the State object—for example, state.copyWith(toastMessage: 'Operation successful'), and then the UI layer detects this non-null field and shows a Toast, while also sending an event to clear it. This pattern has several problems:
- Single-slot overwrite. State is immutable and can only express the "current" side effect. If two Toasts are emitted in quick succession, the second overwrites the first, and the first is swallowed.
- Requires manual cleanup. After showing the Toast, that field must be cleared, otherwise the Widget will show it again on rebuild. If not cleaned properly, it's a bug.
- Semantic confusion. "What is currently displayed" is state, "to show a prompt" is an action. Stuffing them into the same object is using state to simulate an action.
Flutter Zero's approach is to completely extract side effects from State and place them on an independent Stream:
// flutter_zero_app/lib/core/bloc/bloc_effect_mixin.dart (~50 lines)
mixin BlocEffectMixin<S> on BlocBase<S> {
final StreamController<UIEffect> _effectController =
StreamController<UIEffect>.broadcast();
Stream<UIEffect> get effectStream => _effectController.stream;
void emitEffect(UIEffect effect) => _effectController.add(effect);
@override
Future<void> close() {
unawaited(_effectController.close());
return super.close();
}
}
Using broadcast instead of a regular StreamController allows it to be subscribed to by multiple listeners simultaneously. It also naturally avoids the "overwrite" problem—each add is an independent event, not overwritten by the next.
UIEffect is not a sealed class, but an open abstract class:
// flutter_zero_app/lib/core/effect/ui_effect.dart
abstract class UIEffect {
const UIEffect();
}
The framework has three built-in subtypes:
ToastEffect: A one-time prompt. Three priority fields—message(direct text/server message, highest priority),l10nCode(internationalized localization key),code(HTTP status code or custom sentinel code, fallback mapping)DialogEffect: A dialog box. Only carriestype(business identifier string) andextra(optional additional data), does not define specific UILoadingEffect: Global loading.show: true/falsecontrols visibility,extracan pass status text
There's a deliberate design choice here—using an abstract class instead of a sealed class.
The benefit of sealed is compile-time exhaustive checking; when switching, the compiler ensures all subtypes are handled. But the cost is: all Effect subclasses must be declared within the same Dart library. What does that mean? If your feature module wants to add a BannerEffect to represent a top banner prompt, with a sealed class, you'd have to modify the framework file ui_effect.dart.
With an abstract class, any feature can define its own exclusive side-effect type in its own directory by extends UIEffect. Zero modifications to the framework code.
So how does the consumer differentiate and handle different types of Effects? Not with a switch, but with a chain of responsibility:
// flutter_zero_app/lib/core/effect/effect_listener.dart (~100 lines)
typedef EffectHandle = bool Function(BuildContext context, UIEffect effect);
class EffectListener<B extends BlocBase<S>, S> extends StatelessWidget {
const EffectListener({
required this.child,
this.effectsHandles = const [],
super.key,
});
final List<EffectHandle> effectsHandles;
final Widget child;
@override
Widget build(BuildContext context) {
// Chain order: business handles first, framework fallbacks last
// The first handler returning true wins, chain stops
final handles = [
...effectsHandles, // Business custom
defaultToastHandle, // Framework fallback: Toast
defaultDialogHandle, // Framework fallback: Dialog
defaultLoadingHandle, // Framework fallback: Loading
];
final bloc = context.read<B>();
if (bloc is! BlocEffectMixin<S>) {
throw StateError(
'$B must use BlocEffectMixin<$S> to support effects.',
);
}
return _EffectStreamListener<S>(
bloc: bloc,
effectsHandles: handles,
child: child,
);
}
}
_EffectStreamListener internally subscribes to bloc.effectStream. Every time an Effect is received, it iterates through the handles array, and the first one returning true wins:
// flutter_zero_app/lib/core/effect/effect_listener.dart
void _onEffect(UIEffect effect) {
for (final handle in widget.effectsHandles) {
if (handle.call(context, effect)) {
return; // Hit and stop
}
}
}
Business layer usage:
// flutter_zero_app/lib/features/counter/presentation/effects/counter_effect_handle.dart
bool counterEffectHandle(BuildContext context, UIEffect effect) {
if (effect is ToastEffect && effect.l10nCode == 'counterMaxReached') {
// Business custom handling for this specific Toast
showDialog(...);
return true; // Claimed, subsequent handlers (including framework fallbacks) won't execute
}
return false; // Not recognized, pass it on
}
The three framework fallback handlers each have their own responsibilities:
defaultToastHandle: Ifmessagehas a value, display it directly;l10nCodedoes not render by default—only logs a warning in debug mode, prompting you to claim it with a business handle or runfluzer gen-l10nfor automatic wiring;codeuses a localized fallback message (like "Request error: 404"); if all three are missing, a generic fallback is used. Ultimately, all delegate to the injectedToastService(abstract class ToastService), whose implementation can be EasyLoading, Toastification, or any third-party library—whichever is injected in the DI layer is used.defaultDialogHandle: Renders a minimal generic AlertDialog with only a close button. It doesn't do complex business dialog rendering; it just ensures unclaimed DialogEffects aren't silently discarded. Real business dialogs should be rendered by business handles.defaultLoadingHandle: Delegates to the injectedLoadingService. In the bloc,emitEffect(const LoadingEffect(show: true))controls the global loading with a single line.
In reality, the only things you really need to handle yourself are two types: Dialog pop-up side effects, and Toasts carrying l10nCode (either write your own handle to claim them, or let gen-l10n auto-wire them). Toasts with message/code and Loading are all handled by the framework by default.
Abstract class + Chain of Responsibility + Strategy Injection—this combination allows the Effect system to achieve one thing: adding any new side-effect type only requires the business layer to extends UIEffect and add a handler to the chain, with zero changes to the framework code. This isn't called "flexibility"; it's called "it must be designed this way to be justifiable."
There's a detail that's easy to overlook—there's a runtime check in EffectListener.build():
if (bloc is! BlocEffectMixin<S>) {
throw StateError('$B must use BlocEffectMixin<$S> to support effects.');
}
Every BLoC generated by the framework automatically includes BlocEffectMixin, but if someone manually creates a BLoC and forgets to add this Mixin before wrapping it with EffectListener, this line will throw an exception directly during debugging, rather than failing silently at runtime—those "why isn't my emitEffect doing anything" bugs are the hardest to troubleshoot.
BlocCancelTokenMixin — Tying Network Request Lifecycles to the Page
This Mixin has the least code among the four, but its practical value is not low at all. The core idea: each BLoC maintains a Map of named CancelTokens, and all in-progress requests are automatically cancelled when the page is destroyed.
// flutter_zero_app/lib/core/bloc/bloc_cancel_token_mixin.dart (~100 lines including docs)
mixin BlocCancelTokenMixin<State> on BlocBase<State> {
final Map<String, CancelToken> _tokens = {};
CancelToken token([String key = 'default']) {
_tokens[key]?.cancel(); // First cancel the old token with the same key
return _tokens[key] = CancelToken(); // Then create a new one
}
void cancel([String key = 'default']) => _tokens.remove(key)?.cancel();
void cancelAll() {
for (final t in _tokens.values) { t.cancel(); }
_tokens.clear();
}
@override
Future<void> close() {
cancelAll();
return super.close();
}
}
There are three usage scenarios, corresponding to three methods:
Scenario 1: Automatic deduplication. Debouncing in a search box. The user types quickly in the search box, "a" → "ab" → "abc" → "abcd", triggering a search request every second. Without CancelToken, all four requests are sent out. If the network is slow, later requests might return first, and earlier ones later—resulting in the "abc" results overwriting the "abcd" results.
// Usage inside flutter_zero_app/lib/features/search
Future<void> _onSearch(SearchQueryChanged event, Emitter emit) async {
final result = await runCatching(
() => repository.search(
event.query,
cancelToken: token('search'), // Reuses the 'search' key every time
),
);
// token('search') internally cancels the previous one first, then creates a new one
// So only the result of the last request will be processed
}
The brilliance of this design is that there's no need to explicitly manage cancel logic in the business code. The internal implementation of token('search')—"cancel the old one first, then create a new one"—means every call is safe: no zombie requests are created, and there's no need to manually write cancel('search').
Scenario 2: Automatic cleanup on page exit. The BLoC's close() lifecycle is tied to page exit. A user clicks from a list page into a detail page; the list page's BLoC is disposed → close is called → cancelAll cancels all unfinished requests. This prevents a subtle memory leak: the page is already destroyed, but an old network response comes back and tries to setState—the classic "zombie callback."
Scenario 3: User-initiated cancellation. For example, a page uploading a large file, where the user clicks a cancel button:
void onCancelUpload() => cancel('upload');
Operations are isolated by key—token('list') and token('upload') do not affect each other.
Additionally, BlocCancelTokenMixin's generic parameter only has one <State>. It doesn't need to know the Event type because it only operates on the Map of CancelTokens and doesn't call add(event). This means even a pure Cubit without Events can use it.
BlocErrorHandlerMixin — From try/catch to the Result Pattern
This Mixin is the lightest of the four, only 50 lines, doing just one thing—using Result<T> to explicitly represent the three outcomes of "success/failure/cancellation."
Full implementation:
// flutter_zero_app/lib/core/bloc/bloc_error_handler_mixin.dart (full 50 lines)
mixin BlocErrorHandlerMixin<S> on BlocBase<S> {
bool isCancelled(Object error) =>
error is DioException && error.type == DioExceptionType.cancel;
/// Wraps the execution result of action into a Result.
/// * Success → Success
/// * Exception → Failure (carried as-is, no wrapping)
/// * Active cancellation → Cancel
/// * Other exceptions (Error, etc.) → Failure (no message, uses fallback text)
Future<Result<T>> runCatching<T>(Future<T> Function() action) async {
try {
return Success(await action());
} on Object catch (e) {
if (isCancelled(e)) return const Cancel();
if (e is Exception) return Failure<T>(e);
return Failure(Exception('unknown exception'));
}
}
}
The method name runCatching pays homage to Kotlin—catch, wrap, return, with straightforward semantics.
The design of Result<T>:
// flutter_zero_app/lib/core/result/result.dart
sealed class Result<T> { const Result(); }
final class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
final class Failure<T> extends Result<T> {
const Failure(this.exception);
final Exception exception; // Note: it's Exception, not any framework custom exception
}
final class Cancel<T> extends Result<T> {
const Cancel();
}
// Extension method
extension ResultWhen<T> on Result<T> {
R? when<R>({
required R Function(T value) success,
required R Function(Exception ex) failure,
R Function()? cancel, // Optional cancel branch
}) {
switch (this) {
case Success<T>(:final value): return success(value);
case Failure<T>(:final exception): return failure(exception);
case Cancel<T>(): return cancel?.call();
}
}
}
In many projects, cancellation and failure are mixed together—handled in the same catch branch, with no distinction in code between "an error occurred" and "the user actively cancelled." But their semantics are completely different: a failure requires prompting the user and updating error UI; a cancellation should be silently ignored, doing nothing. Result sets Cancel as a third subtype, equal to Success and Failure, using when() to force the caller to handle them separately:
// flutter_zero_app/lib/features/home/presentation/bloc/home_bloc.dart
final result = await runCatching(() => _fetchPage(0));
result.when(
success: (items) => emit(state.copyWith(pagination: ...)),
failure: (ex) {
emit(state.copyWith(pagination: ..., hasError: true, error: ex.errorMessage));
emitEffect(ex.toToastEffect()); // Pop up error Toast
},
// The cancel branch deliberately provides no callback — nothing needs to be done for a cancelled request
);
Note that the cancel parameter in when() is optional (R Function()?). This isn't laziness—in a vast number of business scenarios, cancellation truly requires no handling. If it were mandatory, every BLoC would have to write cancel: () {}, adding meaningless noise. But the framework also provides the isCancel getter and isSuccess/isFailure, so if a scenario does need to handle cancellation (e.g., needing to restore a button's state after cancellation), it can be manually checked.
Why Not Built-in Exception Normalization
A common practice in many industry frameworks is to hang an ErrorHandler in the BLoC base layer, normalizing all exceptions into a custom exception type tree: DioException comes in, gets dispatched by type into TimeoutException(408), NetworkException(-5), AuthException(401/403), ServerException... and then paired with a ServerMessageExtractor strategy class—because different backends have different error message field names, some called message, some msg, some error, so the framework maintains a list of candidate keys to guess from.
Flutter Zero did not do this. The problem with such frameworks lies precisely in them being too "thoughtful":
Normalization is making business assumptions on behalf of the user. Sentinel codes like TimeoutException(408) and NetworkException(-5) are defined by the framework author, not by any backend standard. In a user's project, whether a timeout should be counted as 408 or a custom code, whether a 401 should redirect to a login page or show a prompt—these are all business decisions. If the framework decides for the user, the user has to accommodate the framework in return.
ServerMessageExtractor is essentially betting on the backend format. If the candidate key list guesses correctly, it's called "good compatibility"; if not, the user has to write their own strategy class to correct the framework—creating a problem first and then providing hooks to solve it. This isn't design; it's self-congratulation.
Wrapping itself loses information. The (message, code, originalError) triplet of a custom exception looks neat, but what the user really wants is often a specific field from the original exception. Wrapping it in another layer means having to unwrap it.
Flutter Zero's approach is: Failure carries the Exception as-is. The Repository throws whatever it throws, the BLoC catches whatever it catches. The framework does no translation, only providing a bridge:
// flutter_zero_app/lib/core/effect/ui_effect.dart
extension ExceptionToToast on Exception {
/// Text for display. `Exception('msg')` takes `msg`; returns null if no text.
String? get errorMessage {
final s = toString();
const prefix = 'Exception: ';
final body =
s.startsWith(prefix) ? s.substring(prefix.length).trim() : s.trim();
// A bare `Exception()` is considered no text, uses fallback.
if (body.isEmpty || body == 'Exception') return null;
return body;
}
/// Generates a ToastEffect from the exception.
ToastEffect toToastEffect() => ToastEffect(message: errorMessage);
}
That's it. Exception('Login failed') is thrown, ex.errorMessage gets "Login failed", ex.toToastEffect() directly becomes a Toast. For businesses needing custom localization or error codes, they can construct ToastEffect(l10nCode: ...) themselves or define custom exception classes carrying more information—all paths are open, the framework doesn't block them.
Precisely because it doesn't normalize, the semantics of runCatching are so clean: Success is Success, Exception is Failure, Cancel is Cancel, and nothing else matters. It's the lightest of the four Mixins, but the problem it solves—converging try/catch boilerplate into a type-safe three-state branch—is not compromised at all.
Other Components of the Architecture
Mixins are the skeleton, but a complete architectural skeleton needs muscles filled in. The following modules, together with the Mixins, form the project's infrastructure.
The Three-File Template Method for DI
Dependency injection uses get_it, but directly scattering a bunch of getIt.registerSingleton(...) calls is unmaintainable. Flutter Zero splits DI into three layers:
flutter_zero_app/lib/core/di/
get_it_instance.dart # Global singleton: final getIt = GetIt.instance
injection_base.dart # Template method base class: defines registration order
injection.dart # Subclass implementation: user only modifies this
injection_base.dart locks the registration order using the template method pattern:
// flutter_zero_app/lib/core/di/injection_base.dart
abstract class InjectionBase {
Future<void> registerAll() async {
await registerBaseDependencies(); // 1. Infrastructure (split by layer)
await registerFeatureModules(); // 2. Feature modules (automatically maintained by CLI)
await registerUserDependencies(); // 3. User custom extensions
await getIt.allReady(); // 4. Wait for all async singletons to be ready
}
@protected
Future<void> registerFeatureModules() async {
// fluzer new xxx will automatically insert XxxModule.register(getIt); here
SharesRepositories.register(getIt); // Shared repository registration
HomeModule.register(getIt); // Generated for home
CounterModule.register(getIt); // Generated for counter
// ...
}
}
The key lies in registerFeatureModules(). When the fluzer CLI executes fluzer new user, it doesn't require the developer to manually add a line here—it uses AST manipulation to automatically append UserModule.register(getIt) at the end of the method body. Using CodeMod's InsertAtMethodEndTransform ensures idempotency: if the line already exists, it won't be inserted again.
In injection.dart, infrastructure registration is further split by layer:
Future<void> registerBaseDependencies() async {
await _registerStorageLayer(); // SharedPreferences + SecureStorage
await _registerAuthLayer(); // TokenStorage (cache-first read)
_registerNotifiersLayer(); // ToastService + DeskTopToastService + LoadingService
await _registerLocalizationLayer(); // LocaleProvider (restore language from storage)
await _registerThemeLayer(); // ThemeProvider (restore theme from storage)
}
If you look closely, you'll notice the network layer is missing from the list. Dio, along with its interceptors, is not registered in registerBaseDependencies, but is manually registered by each environment's main entry point after registerAll():
// flutter_zero_app/lib/main_dev.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Injection().registerAll();
getIt.registerLazySingleton<Dio>(
() => Dio(BaseOptions(baseUrl: 'http://192.168.1.100:8080'))
..interceptors.addAll([
AuthInterceptor(tokenStorage: getIt<TokenStorage>()),
LocaleInterceptor(localeProvider: getIt<LocaleProvider>()),
PrettyDioLogger(requestHeader: true, requestBody: true),
]),
);
runApp(const App());
}
The reason is practical: multi-environment baseUrl. Dev connects to a local debug server, staging to pre-release, production to live—baseUrl is an environment property, not an infrastructure property. One entry file per main_xxx.dart, environment differences are clear at a glance, and interceptors can even be added or removed per environment (only dev mounts PrettyDioLogger, production doesn't).
One method per layer; during testing, you only need to mock one layer. For example, when testing the network layer, you can register a Mock Dio separately. The Notifiers layer registers three services: EasyLoading's Toast implementation, Toastification's desktop Toast implementation (for Flutter Web/Desktop), and the Loading service—the UI layer chooses which to inject based on the platform.
Repository Ownership Rules
There are two places to put Repositories in the project: core/data/repositories/ and features/<name>/data/repositories/. How to decide where to put them?
The rule is simple: a Repository used by only one feature goes in that feature's own data directory; once it's used by two or more features, it moves up to core and is registered uniformly through SharesRepositories.
The core purpose of this rule is to prevent features from importing each other's internal implementations. Suppose UserRepository is used by both LoginFeature and SettingsFeature. If it were placed in login/data/, then settings would need to import login's internal module—the module boundary would be broken. Placing it in core means both features only depend on core, unaware of each other's existence.
BaseRepository looks like this:
// flutter_zero_app/lib/core/network/base_repository.dart (full implementation)
abstract class BaseRepository {
const BaseRepository({required this.dio});
final Dio dio;
}
That's it, nothing more. It doesn't parse responses, doesn't validate status codes, doesn't throw any framework exceptions. Its purpose is twofold: to give all Repositories a unified base type, and to serve as a DI injection point.
Not doing parsing in the base class follows the same logic as not normalizing exceptions—{code, message, data} is the format of "the backends I've seen," not a standard. Built-in methods like parseList and parseBusinessResponse in the base class, using closures to inject parsing logic, look flexible but essentially force users to fill in blanks within the framework's drawn boxes. Parsing should stay where it belongs—inside the public methods of each Repository. You parse however you want; if you want an HTTP 200 but a business failure to throw Exception('Out of stock'), just throw it, and runCatching and ExceptionToToast will catch it. So it's placed under core/network/, living up to its name.
freezed Part File Convention
Each feature's BLoC uses several files to form a compilation unit, following a fixed pattern:
flutter_zero_app/lib/features/user_center/presentation/bloc/
user_center_bloc.dart # Main file
├── part 'user_center_bloc.freezed.dart'; # Generated code
├── part 'user_center_event.dart'; # Event definitions
└── part 'user_center_state.dart'; # State definitions
user_center_event.dart # part of 'user_center_bloc.dart';
user_center_state.dart # part of 'user_center_bloc.dart';
user_center_bloc.freezed.dart # Generated by build_runner
The content of event.dart and state.dart is extremely clean—no extra imports, no extra part declarations, just the single line part of 'user_center_bloc.dart' plus the freezed class definition. All external dependency imports are centralized in the main file.
The benefit of this is not just tidiness. freezed's code generation depends on the correctness of imports—if state.dart imports something on its own, the generator might fail due to missing dependencies. Centralizing all imports in one entry point ensures the generated .freezed.dart can correctly resolve all type references.
State and Event definitions also use freezed:
// flutter_zero_app/lib/features/home/presentation/bloc/home_state.dart
part of 'home_bloc.dart';
@freezed
abstract class HomeState with _$HomeState {
const factory HomeState({
@Default(PaginationState<PostModel>()) PaginationState<PostModel> pagination,
@Default(false) bool simulateError,
}) = _HomeState;
const HomeState._();
}
// flutter_zero_app/lib/features/home/presentation/bloc/home_event.dart
part of 'home_bloc.dart';
@freezed
abstract class HomeEvent with _$HomeEvent {
const factory HomeEvent.fetch() = HomeFetch;
const factory HomeEvent.refresh() = HomeRefresh;
const factory HomeEvent.loadMore() = HomeLoadMore;
const factory HomeEvent.toggleError() = HomeToggleError;
}
The benefit of using sealed unions for Events is that each const factory corresponds to an independent subclass, and in the BLoC, on<HomeFetch>(...) uses Dart's exhaustiveness check to ensure all events are handled.
PaginationState<T> in HomeState is a generic Freezed class:
@Freezed(genericArgumentFactories: true)
sealed class PaginationState<T> with _$PaginationState<T> {
const factory PaginationState({
@Default([]) List<T> items,
@Default(0) int currentPage,
@Default(20) int pageSize,
@Default(false) bool isLoading,
@Default(false) bool isLoadingMore,
@Default(false) bool isRefreshing,
@Default(false) bool hasReachedMax,
@Default(false) bool hasError,
String? error,
@Default(false) bool hasLoadMoreError,
String? loadMoreError,
}) = _PaginationState;
const PaginationState._();
bool get isEmpty => items.isEmpty;
bool get isNotEmpty => items.isNotEmpty;
bool get isAnyLoading => isLoading || isLoadingMore || isRefreshing;
}
Note the @Freezed(genericArgumentFactories: true) annotation; this is how freezed 3.x handles generics. Without this annotation, freezed's generated copyWith method wouldn't know how to handle generic parameters. This detail isn't very prominent in freezed's documentation, but you'll encounter it in actual development whenever you use a generic State.
Lightweight Routing Design
Routing uses go_router, but the configuration is very simple:
// flutter_zero_app/lib/router/app_router.dart
class AppRoutes {
AppRoutes._();
static const String home = '/';
static const String counter = '/counter';
static const String search = '/search';
static const String login = '/login';
static const String settings = '/settings';
}
class AppRouter {
static final GoRouter router = GoRouter(
initialLocation: AppRoutes.home,
debugLogDiagnostics: true,
routes: [
GoRoute(path: AppRoutes.home, builder: (_, __) => const HomePage()),
GoRoute(path: AppRoutes.counter, builder: (_, __) => const CounterPage()),
// ...
],
);
}
The fluzer new xxx command does not have the functionality to automatically register routes; routes need to be added manually after the command finishes. The reasons are:
- Business modules may need to carry route parameters.
- The design of nested routes prevents automatic code positioning for route injection.
A Few Design Highlights Worth Mentioning Separately
The previous sections were a breakdown of individual modules. Below are a few design ideas that run through the entire project.
1. Precise Granularity of Generic Constraints
Look at the generic declarations of the four Mixins:
mixin BlocAwaitMixin<Event, State> on Bloc<Event, State> { ... }
mixin BlocEffectMixin<S> on BlocBase<S> { ... }
mixin BlocCancelTokenMixin<State> on BlocBase<State> { ... }
mixin BlocErrorHandlerMixin<S> on BlocBase<S> { ... }
Note the host type in the on clause: BlocAwaitMixin constrains to Bloc<Event, State> (needs to call add(event)), while the other three constrain to the more lenient BlocBase<S>. This distinction is deliberate—if the latter three also constrained to Bloc<Event, State>, a pure Cubit (which only has State, no Event) couldn't use them. The number of generic parameters for each Mixin exactly matches the number it actually uses, not one more.
2. Type-Safe Generation with gen-l10n
The fluzer gen-l10n command is the most complex design in the CLI, but using it in the business layer is extremely simple. The input is Flutter's native .arb files:
{
"counterMaxReached": "Counter has reached the maximum value",
"@counterMaxReached": { "description": "Prompt when the counter reaches the upper limit" },
"hello": "Hello {name}",
"@hello": { "placeholders": { "name": { "type": "String" } } }
}
The output is three automatically generated files. l10n_code.dart generates type-safe L10nCode constants or factory constructors for each ARB key:
// Auto-generated (parameterless key)
const counterMaxReached = L10nCode(name: 'counterMaxReached');
// Auto-generated (key with parameters, factory constructor with type checking)
factory L10nCode.hello(String name) =>
L10nCode(name: 'hello', parameters: {'name': name});
Paired with the extension methods in l10n_code_ext.dart, sending Toasts in the BLoC is completely type-safe:
// Toast types have four semantics: S(success) / E(error) / I(info) / W(warning)
emitEffect(ToastEffect(
l10nCode: L10nCode.counterMaxReached.typeW().toString()
));
// Parameterized ones are also type-safe
emitEffect(ToastEffect(
l10nCode: L10nCode.hello('Dboy').typeI().toString()
));
No need to hand-write string keys. After refactoring the .arb file, run gen-l10n once, and all spelling mistakes are caught at compile time. Behind the scenes, the CLI uses bracket counting to scan the Dart AST, extracts all abstract members, and uses the L10nParamType registry to handle parameter serialization—all this complexity is completely transparent to the business layer.
The Design Intent of L10nCode: Decoupling Internationalization Identifiers from Translation Logic
The above explained how gen-l10n generates L10nCode, but it didn't explain why this thing is needed.
In a typical Flutter project, the call path for internationalized text is: the UI layer gets the translated string via AppLocalizations.of(context)!.someKey, then displays it. This path has two implicit constraints: first, a BuildContext is required; second, the translation action happens at the call site. For the UI layer, this is fine—Widgets naturally hold context, and using the translation directly works.
But the BLoC layer cannot. BLoC has no context, and should not have context. If you write AppLocalizations.of(context)! inside a BLoC, you're binding business logic to Flutter's widget tree, making unit testing impossible.
An intuitive alternative is: pass the translated string directly in the BLoC. For example, if a Repository throws an exception, the BLoC catches it, manually maps the exception message to a Chinese text, and then calls emitEffect(ToastEffect(message: 'Network connection failed')). This works, but introduces another problem—what about multiple languages? If the user switches to English, the hardcoded Chinese text in the BLoC is completely wrong.
What L10nCode solves is this: In the BLoC layer, only pass the identifier of "which message to display," not the specific translated text. The translation action is deferred to the UI layer, executed in an environment with context.
// BLoC layer: only passes the identifier, no translation logic involved
emitEffect(ToastEffect(l10nCode: L10nCode.counterMaxReached.typeW().toString()));
// Same for parameterized ones: only pass identifier + parameters, let the UI layer assemble
emitEffect(ToastEffect(l10nCode: L10nCode.hello('Dboy').typeI().toString()));
L10nCode itself is a value object. Its toString() returns a serializable string (like l10n:counterMaxReached?type=W), which is passed through the Stream to the UI layer's EffectListener. And defaultToastHandle does not render l10nCode by default—it only logs a warning in debug mode, reminding you that this key hasn't been claimed yet. What actually makes it work is fluzer gen-l10n: this command uses AST patching to write the generated helper functions directly into the l10nCode branch of default_toast_effect_handle.dart. After that, Toasts carrying l10nCode can fetch the corresponding translated text based on the current locale. It's essentially the CLI completing the "business handle claiming" step for you, and it's idempotent—running it multiple times yields the same result.
This design brings several benefits:
Non-UI layers can also trigger internationalized prompts. The BLoC doesn't need to know the current language, doesn't need to know where the translation files are, and doesn't even need to know Flutter exists. It only needs to know that "this business scenario should display the counterMaxReached message," leaving the rest to the UI layer. Backend-pushed messages can also be mapped to L10nCodes—a WebSocket receives an error_code: 1001, the BLoC converts it to L10nCode.error1001, and the UI layer translates it according to the locale.
Switching languages doesn't require restarting the BLoC. Because the translation action happens in the UI layer's handle, every time an Effect flows through the chain of responsibility, it's re-translated according to the current locale. After the user switches languages, the next Toast automatically uses the new language, without needing to rebuild the BLoC or refresh the state.
Serializable, transmittable across processes. L10nCode's toString()/parse() mechanism allows it to be passed in a Stream without losing type information. Parameters of parameterized L10nCodes (like L10nCode.hello('Dboy')) are encoded into the query string and fully restored upon parsing. This means, theoretically, Notifications and DeepLinks could also carry L10nCodes, parsed and displayed in the UI layer—not limited to the BLoC → EffectListener path.
Why ToastEffect Uses l10nCode Instead of Directly Passing Translated Text
Following the above logic, a question might arise: since ToastEffect has three fields (message, l10nCode, code), why not unify them all using message to pass the text? Just get the translated string in the BLoC layer and stuff it in?
The core reason for this design is the boundary of layered responsibilities.
Translation is a UI layer concern, not a business logic layer concern. The BLoC's responsibility is to state "what business event happened"—for example, "the counter reached its maximum"—and it shouldn't care whether "this prompt is 7 characters or 10 in Chinese." If translation were placed in the BLoC layer, the BLoC would need to depend on AppLocalizations, need to hold a context, and need to mock the entire internationalization module in unit tests. All because it's trying to do something that isn't its job.
Using l10nCode instead of translated text essentially says: The BLoC is only responsible for "semantics"; the UI layer is responsible for "expression."
// This is correct: BLoC passes a semantic identifier
emitEffect(const ToastEffect(l10nCode: 'counterMaxReached'));
// This is wrong: BLoC is making a translation decision for the UI layer
emitEffect(const ToastEffect(message: 'Counter has reached the maximum value'));
Both approaches might produce the exact same runtime result, but they are completely different at the architectural level. The former leaves "how to express" to the UI layer's handler; the latter makes the decision for the UI layer.
So what is the message field for? It has one clear, irreplaceable scenario: server-returned text. For example, a backend business exception directly carries a user-facing message—"Out of stock, only 3 items left"—the translation of this text is done by the server, not the client. The BLoC receives it and passes it through message as-is, and the UI layer displays it verbatim. The message field is reserved for this "server-already-translated" scenario.
Thus, the responsibility boundaries of ToastEffect's three fields are:
message: Server-translated text, displayed directly. Used for backend-determined text.l10nCode: Client-side localization identifier, translated by the UI layer according to locale. Used for frontend-determined text.code: Error code fallback. Used for scenarios where there's an error code but no specific text; the UI layer maps the code to a generic prompt.
These three priority levels guarantee one principle: In any scenario, the BLoC never needs to guess the translation result for the current language.
3. Why BLoC Doesn't Directly Call ToastService and LoadingService — Dependency Inversion
A lot of space above was spent explaining the mechanism of the Effect system, but there's an architectural "why" that hasn't been expanded on—why does the BLoC trigger Toasts and Loading through Effects, instead of directly holding references to ToastService and LoadingService?
The most intuitive way to write it might be:
// If written like this, it would be very "convenient"
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final ToastService toastService; // Direct injection
final LoadingService loadingService; // Direct injection
Future<void> _onFetch(event, emit) async {
loadingService.show(); // Direct call
try {
final data = await repository.fetch();
emit(state.copyWith(items: data));
toastService.showSuccess('Load successful'); // Direct call
} catch (e) {
toastService.showError('Load failed'); // Direct call
} finally {
loadingService.hide(); // Direct call
}
}
}
The problem with this approach isn't that "it doesn't run"—it runs, and it looks more direct. The problem lies on several levels.
First problem: The BLoC's responsibility boundary is broken.
The BLoC's core job is managing business state—receiving events, calling repositories, producing new states. Is a Toast prompt business state? No. Is a Loading animation business state? Also no. They are UI presentations, matters of the "presentation layer." When a BLoC directly calls toastService.showSuccess('Load successful'), it's no longer a pure state manager—it's starting to tell the UI layer "how you should display," which is no different in essence from directly manipulating Widgets in the BLoC.
The Effect mechanism corrects this: the BLoC is only responsible for emitting declarative intents like LoadingEffect(show: true) and ToastEffect(message: 'Load successful'). As for "whether to display Loading using EasyLoading or Toastification," or "whether to display Toast using SnackBar or a third-party component"—the BLoC is completely unaware and doesn't need to be.
// BLoC only expresses intent, doesn't specify implementation
emitEffect(const LoadingEffect(show: true));
final result = await runCatching(() => repository.fetch());
result.when(
success: (data) {
emit(state.copyWith(items: data));
emitEffect(const ToastEffect(
l10nCode: 'homeRefreshSuccess', // Semantic identifier
));
},
failure: (ex) => emitEffect(ex.toToastEffect()),
);
emitEffect(const LoadingEffect(show: false));
The second problem is testability.
For a BLoC directly injecting ToastService, in a unit test, you must mock all methods of the entire Toast service—showSuccess, showError, showInfo, dismissAll... And what you're testing is "did the BLoC call the correct Toast method at the correct time," not "did the BLoC emit the correct State and Effect."
With the Effect mechanism, unit tests become like this:
blocTest<HomeBloc, HomeState>(
'should emit loading effect and success toast on fetch',
build: () => HomeBloc(repository: mockRepository),
act: (bloc) => bloc.add(const HomeEvent.fetch()),
expect: () => [
// State assertion: only cares about business state
isA<HomeState>().having((s) => s.pagination.isLoading, 'isLoading', true),
isA<HomeState>().having((s) => s.items.length, 'items', greaterThan(0)),
],
verify: (_) {
// Effect assertion: only cares about what Effect was emitted, not who handles it
verify(() => mockRepository.fetchPosts(page: 0)).called(1);
},
);
You don't need to mock ToastService, don't need to mock LoadingService, don't even need to mock BuildContext. You only care about two things: did the State change correctly, and was the Effect emitted. As for how the Effect is ultimately rendered—that's a matter for UI layer integration tests.
The third problem: Replaceability.
If the project initially uses EasyLoading as the Toast library, and later wants to switch to Toastification or Flutter's native SnackBar, what happens?
The direct ToastService injection approach: you need to find every BLoC, modify its constructor parameters and all call sites. If the project has 30 features, each with one BLoC—30 files to change.
The Effect mechanism approach: the framework's defaultToastHandle is hardcoded to delegate to ToastService, but ToastService is an abstract class:
// flutter_zero_app/lib/core/notifiers/toast_service.dart
abstract class ToastService {
void showSuccess(String msg);
void showError(String msg);
void showInfo(String msg);
void showWarning(String msg);
void dismissAll();
Widget build(BuildContext context, Widget child); // Host Widget
}
Switching implementations only requires changing one line of registration code in the DI layer:
// Before: EasyLoading implementation
getIt.registerLazySingleton<ToastService>(
() => EasyLoadingToastService(),
);
// After: Toastification implementation
getIt.registerLazySingleton<ToastService>(
() => ToastificationToastService(),
);
30 files in the BLoC layer, not a single line needs changing. Because the BLoC never knew which Toast library was being used underneath—it only knows emitEffect(ToastEffect(...)).
This is a classic example of the Dependency Inversion Principle: High-level modules (BLoC) do not depend on low-level modules (specific Toast implementations); both depend on abstractions (UIEffect / ToastService abstract class).
Add a diagram to illustrate this relationship:
┌─────────────────────┐
│ BLoC Layer │
│ emitEffect(...) │ ← Only depends on UIEffect abstraction
└─────────┬───────────┘
│ Stream<UIEffect>
▼
┌─────────────────────┐
│ EffectListener │ ← Chain of Responsibility dispatch
│ (UI Layer) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐ ┌──────────────────────────┐
│ defaultToastHandle│─────▶│ ToastService (abstract) │
│ defaultLoadingHandle│───▶│ LoadingService (abstract)│
└─────────────────────┘ └──────────┬───────────────┘
│ DI Injection
┌─────────▼───────────────┐
│ EasyLoadingToastService │
│ ToastificationToastSvc │ ← Concrete implementations
│ ... │
└─────────────────────────┘
The BLoC and the specific UI libraries are separated by two layers of abstraction (UIEffect + ToastService). Arbitrarily replacing the underlying implementation doesn't affect the business logic. This is also why the framework can hardcode defaultToastHandle and defaultLoadingHandle as fallback handlers at the very end of the chain of responsibility—they are "default implementations," not "the only implementations." If the business layer is dissatisfied with the default Toast style, it can completely claim it in its own handle using is ToastEffect and replace it with any display method.
Future Plans: What Else Can Be Learned from go-zero
fluzer's current functionality focuses on project creation and module generation, corresponding to go-zero's goctl api new. But goctl has 28 commands, covering the complete chain from API definition to container deployment. Mapped to the Flutter context, there are three things I most want to do:
API-driven code generation. This is currently the biggest missing piece in fluzer. goctl's core capability is "write a .api definition file → generate complete service code with one command." For fluzer, this means automatically generating Freezed data models + Repository + Dio call code from OpenAPI/Swagger documentation. Currently, fluzer new provides an empty skeleton; with this added, the generated module can directly make network requests.
Generating models from SQL. goctl can generate data layer code from MySQL DDL. fluzer can map this capability to Flutter's local database scenarios—when a project uses drift or sqflite, directly generate Dart data classes and DAO operation code from the SQL schema.
Project validation and auto-fix. goctl has api validate and api format. fluzer needs a check command (checking configuration compatibility, DI registration completeness, ARB consistency, etc.) and a fix command (automatically fixing determinable issues), suitable for inclusion in CI pipelines.
The detailed planning document and command designs are already written in the project's flutter_zero后续规划.md.
How AI Helped in This Entire Process
AI participated in this project's code, architecture documentation, and the article you're reading now. But the way it participated might be different from what you imagine.
During the architecture design phase, I would describe my understanding of MVI and several alternative solutions to the AI, letting it analyze the trade-offs of each and help me discover blind spots. The choice between the Effect system's Chain of Responsibility vs. sealed class, the three-state design of Result vs. traditional try/catch—these decisions were all made after I understood the pros and cons of both sides. The AI provided analysis and comparison, not "telling me the answer directly."
During the coding phase, AI helped me generate freezed template code, Dio interceptor boilerplate logic, and the bracket-counting algorithm for AST parsing. These things are tedious to write, error-prone, and have little "creativity" to speak of. I handed these over to the AI and spent my own energy understanding design patterns, deliberating architectural boundaries, and verifying the correctness of the generated code.
When writing documentation and this article, the AI first generated a draft, and I then revised it paragraph by paragraph, adding my own experiences of pitfalls encountered, the context during decision-making, and specific code comments. Every piece of code in the article has been verified by actual execution, and every architectural choice has a specific reason behind it—not "the Chain of Responsibility pattern is used here because it's a best practice," but "the Chain of Responsibility pattern is used here because the project needs to openly extend a certain type, and sealed class restricts this extension."
Using AI isn't about being lazy. Quite the opposite, it allows me to focus my cognitive resources on things that truly require human judgment—how to layer the architecture, how to split the responsibilities of Mixins, how to design APIs so they aren't easily misused—while leaving mechanical code generation and document filling to the machine. The final code produced, I still go through line by line, checking diffs commit by commit—but the speed of developing a complete toolchain is indeed several orders of magnitude faster than pure handwriting.
Flutter Zero is still iterating. The CLI's versioning strategy is also very direct: select the corresponding command adapter based on the project template version, and explicitly report errors outside capability boundaries, without silently falling back. None of these designs were planned in advance; they were all polished after needs gradually surfaced during actual use. If this architectural approach has reference value for you, or if you think a certain design choice is wrong and there's a better solution, welcome to discuss it on GitHub.
Project address: https://github.com/Dboy233/flutter_zero_cli
Documentation site: https://dboy233.github.io/flutter_zero_doc/