Flutter Zero: A go-zero-Inspired CLI That Generates Full MVI-BLoC Modules
I'm an Android developer by trade, and over the past few years, I've worked on several Flutter projects. 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 single time.
I previously studied the Go language for a while, and there's an open-source framework called go-zero that I really like. It uses the goctl command-line tool to generate template code. API, RPC, and database operations are all handled with a single command; the entire project skeleton doesn't need to be written by hand. 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 already moved towards a commercial paid template route, or lacked documentation to the point of being unusable. Since there wasn't a satisfactory one, I decided to build one myself. Partly to learn, and partly for my own convenience when starting new projects—though it might not compare to commercial products, it's sufficient for my own use.
Because I come from an Android background, the overall encapsulation leans towards Android development habits. The chosen MVI architecture also follows the same lineage as Android's MVI: Intent (Event) drives State changes, the View consumes State unidirectionally, and side effects are dispatched through a separate channel.
The entire Flutter Zero toolchain consists of four repositories: the CLI tool fluzer (already 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 go through the source code line by line. Instead, it focuses on the most noteworthy designs within the MVI-BLoC architecture generated by the template. If you are using flutter_bloc for medium-to-large projects, or are interested in how BLoC can combine and extend capabilities through Mixins, I hope this can serve as a 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 pre-assembled), and a DI registration module (UserCenterModule). It also automatically inserts UserCenterModule.register(getIt) into injection_base.dart, requiring no manual file changes.
The CLI also provides a 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 │ │
│ │ (runToResult → Result<T>) │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────┐
│ Data Layer │
│ Repository (BaseRepository) → DioClient → Server │
│ AppException ← ErrorHandler ← DioException │
└──────────────────────────────────────────────────────┘
In traditional BLoC writing, a single on<LoginSubmitted> handler gets stuffed with business logic, error handling, loading state toggles, Toast prompts... to the point where you can't even understand it later. Flutter Zero's approach is to split four orthogonal capabilities into independent Mixins, each doing one thing, and combine them to form a complete BLoC:
// Four files under flutter_zero_app/lib/core/bloc/
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. If CounterBloc doesn't need network requests, it doesn't need to mix in BlocCancelTokenMixin; the simplest Cubit can still use the latter three.
Let's break them down one by one, clarifying what problem each solves, how it's designed, and what edge cases are worth noting.
BlocAwaitMixin — Allows the UI to 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 requires 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: the loading state is a 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 (like hasReachedMax returning 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 is done, 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 that returns 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(...),
)
It doesn't end here. 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 philosophy from go-zero: the framework should eliminate everything 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, this will always be reached
}
});
}
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 "forgetting"
onAwait<RefreshPosts>('refresh', (event, emit) async {
final posts = await repository.fetch();
emit(state.copyWith(posts: posts));
// try/finally is handled by the framework, completeAwait is called even on exception
});
There are a few more 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 goes to the background and comes 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 — Complete Separation of Side Effects and State
In MVI, things like Toast prompts, dialogs, and page navigations are "one-time side effects." They have no direct relationship with the business state—"showing a Toast" is not a UI state; it's an action, done once and then over.
The traditional approach is to stuff side effects into the State object—for example, state.copyWith(toastMessage: 'Operation successful'), and then in the UI layer, detect this field is non-null, show a Toast, and simultaneously send 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 the cleanup isn't done right, it's a bug.
- Semantic confusion. "What is currently displayed" is a state, "show a prompt" is an action. Stuffing them into the same object is simulating an action with state.
Flutter Zero's approach is to completely extract side effects from State and put 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();
}
}
It uses broadcast instead of a regular StreamController because it can be subscribed to by multiple listeners simultaneously. Also, it naturally has no "overwrite" problem—each add is an independent event and won't be overwritten by the next one.
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-side copy, highest priority),l10nCode(internationalized localization key),code(HTTP status code or internal sentinel code, fallback mapping)DialogEffect: A dialog. Only carriestype(a business identifier string) andextra(optional additional data), does not define specific UILoadingEffect: Global loading.show: true/falsecontrols visibility,extracan pass status text
Here's a deliberate design choice—using an abstract class instead of a sealed class.
The benefit of sealed is compile-time exhaustiveness checking; when you switch, the compiler ensures all subtypes are handled. The cost is: all Effect subclasses must be declared in 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 would have to modify the ui_effect.dart framework file.
With an abstract class, any feature can define its own exclusive side-effect type by extends UIEffect in its own directory. Zero framework code changes.
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 to return true wins, and the 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,
);
}
}
Inside _EffectStreamListener, it subscribes to bloc.effectStream. Every time an Effect is received, it iterates through the handles array, and the first one to return 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, 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 framework's three fallback handlers each have their own job:
defaultToastHandle: Resolves the prioritymessage > l10nCode > codeand delegates to the injectedToastService. This service itself is also abstract (abstract class ToastService), and the implementation can be EasyLoading, Toastification, or any third-party library; the DI layer injects whichever is used.defaultDialogHandle: Renders a minimal generic AlertDialog with only a close button. It doesn't do complex business dialog rendering, just ensures that unclaimed DialogEffects are not silently dropped. 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 side effect you actually need to handle yourself is the Dialog popup. Toast and Loading have default handling by the framework.
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 + add a handler to the chain, with zero framework code changes. 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—that kind of "why did my emitEffect do nothing" bug is 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 just as high. The core idea: each BLoC maintains a Map of named CancelTokens, and when the page is destroyed, all in-progress requests are automatically cancelled.
// 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(); // Cancel the old token with the same key first
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. Debounce in a search box. A 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, the later one might return first, and the earlier one might return later—resulting in the results for "abc" overwriting the results for "abcd".
// Usage inside flutter_zero_app/lib/features/search
Future<void> _onSearch(SearchQueryChanged event, Emitter emit) async {
final result = await runToResult(
() => 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. It won't create zombie requests, and you don't 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, on a page uploading a large file, 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 has only 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 is the "heaviest" of the four Mixins and the part of the architecture with the highest information density. It does two closely related things: error normalization (various raw exceptions → a unified AppException type tree), and making the three outcomes of "success/failure/cancellation" explicit through Result<T>.
First, look at the Mixin's core interface:
// flutter_zero_app/lib/core/bloc/bloc_error_handler_mixin.dart (~80 lines)
mixin BlocErrorHandlerMixin<S> on BlocBase<S> {
ErrorHandler get errorHandler => ErrorHandler(); // getter, subclasses can override
AppException? handleError(Object error, [StackTrace? stackTrace])
=> errorHandler.handle(error, stackTrace);
bool isCancelled(Object error) => errorHandler.isCancelled(error);
Future<Result<T>> runToResult<T>(Future<T> Function() action) async {
try {
return Success(await action());
} on Object catch (e, stackTrace) {
final ex = handleError(e, stackTrace);
if (ex == null) return const Cancel(); // Cancelled operations return Cancel, not Failure
return Failure<T>(ex);
}
}
}
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 AppException exception;
}
final class Cancel<T> extends Result<T> {
const Cancel();
}
// Extension methods
extension ResultWhen<T> on Result<T> {
R? when<R>({
required R Function(T value) success,
required R Function(AppException 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—both are handled in the same catch branch, and "something went wrong" and "the user actively cancelled" have no distinction at the code level. 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 runToResult(() => _fetchPage(0));
result.when(
success: (items) => emit(state.copyWith(pagination: ...)),
failure: (ex) {
emit(state.copyWith(pagination: ..., hasError: true, error: ex.message));
emitEffect(ex.toToastEffect()); // Show error Toast
},
// The cancel branch deliberately provides no callback — a cancelled request needs no action
);
Note that the cancel parameter in when() is optional (R Function()?). This isn't laziness—in a vast number of business scenarios, cancellation genuinely 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; if a scenario truly needs to handle cancellation (e.g., needing to restore a button state after cancellation), it can be checked manually.
The error normalization chain is: Dart native exception → DioException → AppException type tree. This conversion all happens inside ErrorHandler:
// flutter_zero_app/lib/core/error/error_handler.dart
class ErrorHandler {
ServerMessageExtractor serverMessageExtractor;
ErrorHandler({ServerMessageExtractor? serverMessageExtractor})
: serverMessageExtractor = serverMessageExtractor ?? ServerMessageExtractor();
AppException? handle(Object error, [StackTrace? stackTrace]) {
if (error is DioException) return _handleDioException(error);
if (error is AppException) return error; // Already an AppException, pass through directly
// Unknown exception: don't hardcode the message, let the UI fallback translate by code(unknown)
return UnknownException(null, code: AppErrorCodes.unknown, originalError: error);
}
bool isCancelled(Object error) =>
error is DioException && error.type == DioExceptionType.cancel;
}
// Inside _handleDioException, dispatch by DioExceptionType:
// cancel → null (indicates active cancellation)
// connectionTimeout/sendTimeout/receiveTimeout → TimeoutException(code: 408)
// connectionError → NetworkException(code: -5)
// badResponse → AuthException(401/403) / ServerException(other 4xx/5xx)
// default → UnknownException(code: -1)
AppException is also an abstract class, not sealed, consistent with the design philosophy of UIEffect—it can be freely extended externally:
// flutter_zero_app/lib/core/error/app_exception.dart
abstract class AppException implements Exception {
const AppException(this.message, {this.code, this.originalError});
final String? message; // Human-readable text returned by the server
final int? code; // HTTP status code or internal sentinel code
final Object? originalError;
ToastEffect toToastEffect() => ToastEffect(message: message, code: code);
}
// 7 built-in subclasses:
// NetworkException(code:-5), ServerException, AuthException(401/403),
// TimeoutException(408), ParseException, BusinessException, UnknownException(-1)
toToastEffect() is a convenient bridge method. It converts an AppException into a ToastEffect, prioritizing the server's message, and if not available, uses the code to let the UI layer localize as a fallback. This means the framework layer has zero hardcoded user-visible text—all copy either comes from the server (server decides) or is localized in .arb files by error code (client decides).
But different backends have different error message field names. Some call it message, some error, some msg. Hardcoding any one is wrong. Flutter Zero uses the strategy pattern:
// flutter_zero_app/lib/core/error/server_message_extractor.dart
class ServerMessageExtractor {
const ServerMessageExtractor({
this.candidateKeys = const ['message', 'error', 'errorMsg', 'msg'],
});
final List<String> candidateKeys;
String? extract(Map<String, dynamic>? data) {
if (data == null) return null;
for (final key in candidateKeys) {
final value = data[key];
if (value is String && value.isNotEmpty) return value;
}
return null;
}
}
By default, it tries four fields in order. If it doesn't match your team's convention, replace it in the DI layer:
@override
ErrorHandler get errorHandler => ErrorHandler(
serverMessageExtractor: ServerMessageExtractor(['errorMsg', 'detail']),
);
Replacing the strategy requires no changes to any framework code, only the DI registration. This is also why ErrorHandler is exposed as a getter rather than being hardcoded—to extract "how to extract the error message" from the framework's implementation details, making it an injectable strategy.
Other Components of the Architecture
Mixins are the skeleton, but a complete architectural skeleton also needs muscles. The following modules, together with the Mixins, form the project's infrastructure.
DI's Three-File Template Method
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: users only modify this
injection_base.dart uses the template method pattern to lock down the registration order:
// 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. It uses CodeMod's InsertAtMethodEndTransform to guarantee idempotency: if the line already exists, it won't be inserted again.
In injection.dart, the infrastructure registration is further split by layer:
Future<void> registerBaseDependencies() async {
await _registerStorageLayer(); // SharedPreferences + SecureStorage
await _registerAuthLayer(); // TokenStorage (cache-first read)
_registerNotifiersLayer(); // ToastService + LoadingService
_registerNetworkLayer(); // DioClient + Interceptors (Auth + Locale)
await _registerLocalizationLayer(); // LocaleProvider (restore language from storage)
await _registerThemeLayer(); // ThemeProvider (restore theme from storage)
}
One method per layer; during testing, you only need to mock a single layer. For example, when testing the network layer, you can separately replace the implementation of _registerNetworkLayer to inject a Mock DioClient.
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 stays 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's placed in login/data/, then settings would need to import an internal module of login—the module boundary is immediately broken. Placing it in core means both features only depend on core and are unaware of each other's existence.
BaseRepository extends an abstract base class and provides several common response parsing methods:
// flutter_zero_app/lib/core/storage/base_repository.dart
abstract class BaseRepository {
const BaseRepository({required this.client});
final DioClient client;
// Parse array response: Response<List<dynamic>> → List<T>
List<T> parseList<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// Parse single object response: Response<Map<String, dynamic>> → T
T? parseSingle<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// Parse nested response: Response<{data: ...}> → T
T parseResponse<T>(Response<dynamic>, T Function(Map<String, dynamic>) fromJson);
// Parse wrapped response: HTTP 200 but business status code failed → BusinessException
T parseBusinessResponse<T, B>(..., {
required B Function(dynamic) parseBody,
required bool Function(B) isSuccess,
required T Function(B) extractData,
});
}
The design of parseBusinessResponse uses closure injection rather than abstract methods. Because different backends have vastly different wrapped response formats ({code, message, data} vs {status, msg, result}), rather than defining fixed field names in an abstract class, it's better to let the caller inject its own parsing logic through closures.
freezed's 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 imports for external dependencies 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.
The 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 injection via code positioning.
A Few Design Highlights Worth Mentioning Separately
The previous sections were a breakdown of individual modules. Here 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> { ... }
Notice 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 looser BlocBase<S>. This distinction is deliberate—if the latter three also constrained to Bloc<Event, State>, a pure Cubit (which has State but no Event) couldn't use them. The number of generic parameters for each Mixin is exactly 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 file:
{
"counterMaxReached": "计数已达最大值",
"@counterMaxReached": { "description": "计数器达到上限时的提示" },
"hello": "你好 {name}",
"@hello": { "placeholders": { "name": { "type": "String" } } }
}
The output is three auto-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});
Combined with the extension methods in l10n_code_ext.dart, sending a Toast 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 errors 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
We've covered how gen-l10n generates L10nCode, but haven't yet explained 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 and 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 a context and can use the translation directly.
But the BLoC layer cannot. BLoC has no context, and it shouldn't have one. If you write AppLocalizations.of(context)! inside a BLoC, you're tying business logic to Flutter's widget tree, making unit testing impossible.
An intuitive alternative is to 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: '网络连接失败')). 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.
L10nCode solves this problem: In the BLoC layer, only pass the identifier of "which text to display," not the specific translated text. The translation action is deferred to the UI layer, executed in an environment with a context.
// BLoC layer: only passes the identifier, no translation logic involved
emitEffect(ToastEffect(l10nCode: L10nCode.counterMaxReached.typeW().toString()));
// Same for parameterized ones: only passes identifier + parameters, lets the UI layer assemble it
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. The defaultToastHandle then deserializes it, calls L10nCode.parse() to restore it, and finally retrieves the translated text for the current locale.
This design brings several benefits:
Non-UI layers can also trigger internationalized prompts. The BLoC doesn't need to know the current language, where the translation files are, or even that Flutter exists. It only needs to know that "this business scenario should display the counterMaxReached message," and the rest is handed off to the UI layer. Messages pushed from the backend 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 by 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, can be passed across processes. L10nCode's toString()/parse() mechanism allows it to be passed in a Stream without losing type information. Parameters of a parameterized L10nCode (like L10nCode.hello('小杜')) are encoded into the query string and fully restored upon parsing. This means, theoretically, Notifications and DeepLinks could also carry L10nCodes, to be parsed and displayed in the UI layer—not limited to the BLoC → EffectListener path.
Why Use l10nCode in ToastEffect Instead of Directly Passing Translated Text
Following the above line of thought, a question might arise: since ToastEffect has three fields (message, l10nCode, code), why not just use message to pass the text uniformly? Why not 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, hold a context, and 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," and 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: '计数已达最大值'));
The two approaches might produce identical runtime results, but they are completely different at the architectural level. The former leaves "how to express it" to the UI layer's handler; the latter makes the decision for the UI layer.
So what is the message field for? It has a clear, irreplaceable scenario: server-returned text. For example, a backend business exception directly carries a user-facing message—"Insufficient 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 directly through message, and the UI layer displays it as is. 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 copy.l10nCode: Client-side localization identifier, translated by the UI layer according to locale. Used for frontend-determined copy.code: Error code fallback. Used for scenarios where there's an error code but no specific text, and 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 was spent above 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, rather than directly holding references to ToastService and LoadingService?
The most intuitive way to write it might be this:
// 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('加载成功'); // Direct call
} catch (e) {
toastService.showError('加载失败'); // 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 problems lie on several levels.
First problem: The BLoC's responsibility boundary is broken.
The BLoC's job is to manage business state—receive events, call repositories, produce new state. Is a Toast prompt a business state? No. Is a Loading animation a business state? No. They are UI presentations, matters of the "presentation layer." When a BLoC directly calls toastService.showSuccess('加载成功'), it's no longer a pure state manager—it's starting to tell the UI layer "how you should display," which is fundamentally no different 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: '加载成功'). As for "whether to show Loading with EasyLoading or Toastification," or "whether to show a Toast with a 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 runToResult(() => 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 that directly injects 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 Effects."
With the Effect mechanism, a unit test becomes 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 assertions: only care about business state
isA<HomeState>().having((s) => s.pagination.isLoading, 'isLoading', true),
isA<HomeState>().having((s) => s.items.length, 'items', greaterThan(0)),
],
verify: (_) {
// Effect assertions: only care about what Effect was emitted, not who handles it
verify(() => mockRepository.fetchPosts(page: 0)).called(1);
},
);
You don't need to mock ToastService, LoadingService, or even 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.
What if the project initially used EasyLoading as the Toast library, and later wants to switch to Toastification or Flutter's native SnackBar?
With 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 a BLoC—30 files to change.
With 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(),
);
Not a single line in the 30 BLoC layer files needs to change. Because the BLoC never knew which underlying Toast library was being used—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 dispatches
│ (UI Layer) │
└─────────┬───────────┘
│
▼
┌─────────────────────┐ ┌──────────────────────────┐
│ defaultToastHandle│─────▶│ ToastService (abstract) │
│ defaultLoadingHandle│───▶│ LoadingService (abstract)│
└─────────────────────┘ └──────────┬───────────────┘
│ DI injects
┌─────────▼───────────────┐
│ EasyLoadingToastService │
│ ToastificationToastSvc │ ← Concrete implementations
│ ... │
└─────────────────────────┘
The BLoC and the specific UI library are separated by two layers of abstraction (UIEffect + ToastService). Replacing the underlying implementation arbitrarily does not 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 the biggest missing piece in fluzer right now. goctl's core capability is "write a .api definition file → one command generates complete service code." For fluzer, this means automatically generating Freezed data models + Repository + Dio calling code from OpenAPI/Swagger documentation. Currently, fluzer new gives an empty skeleton; with this added, the generated module can directly make network requests.
Model generation 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 problems), suitable for inclusion in CI pipelines.
How AI Helped Throughout This Process
AI was involved in the code, architecture documentation, and even this 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 made after I understood the pros and cons of both sides. 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 them. I handed these off to AI and spent my own energy on understanding design patterns, deliberating architectural boundaries, and verifying the correctness of the generated code.
When writing documentation and this article, AI generated the first draft, and I revised it paragraph by paragraph, adding my own experiences with pitfalls, the context behind decisions, 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 that extension."
Using AI isn't about cutting corners. On the contrary, it allowed 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 an API so it's not easily misused—while leaving mechanical code generation and documentation padding to the machine. The final code output, I still go through line by line, reviewing each commit's diff—but the speed of developing a complete toolchain was indeed several orders of magnitude faster than writing purely by hand.
Flutter Zero is still iterating. The CLI's version check mechanism, the template registry's compatibility strategy (minCliVersion gate), gen-l10n's AST patcher (idempotent wiring)—these were all added after the needs gradually surfaced during actual use. If this architectural approach has any reference value for you, or if you think a certain design choice is wrong and there's a better solution, you're welcome to discuss it on GitHub.
Project address: https://github.com/Dboy233/flutter_zero_cli
Documentation site: https://dboy233.github.io/flutter_zero_doc/
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Solid approach to splitting responsibilities
[Rose]