Introduction
By day 98 of learning Flutter, you've probably built enough toy apps to know what works and what slows you down. Rather than starting fresh each time, we've learned to invest a few hours in the right foundation. My Flutter starter: a practical guide for developers is about what every new app gets on day one—the structure, tooling, and automation that let your team move fast without accruing technical debt before the first feature ships.
This isn't about opinionated frameworks or reinventing the wheel. It's about the shared practices we've found across trading dashboards, ride-hailing apps, and SaaS backends that actually reduce friction and keep code readable at scale.
Architecture: Feature-First Folders and Separation of Concerns
The easiest way to end up with a mess is to let the folder structure grow organically. By day one, we organize by feature, not by type. Each feature owns its UI, business logic, and data layer.
Here's what a typical feature looks like:
lib/
features/
auth/
presentation/
pages/
login_page.dart
widgets/
email_field.dart
view_models/
auth_view_model.dart
domain/
entities/
user.dart
repositories/
auth_repository.dart
use_cases/
login_use_case.dart
data/
datasources/
auth_local_datasource.dart
auth_remote_datasource.dart
models/
user_model.dart
repositories/
auth_repository_impl.dart
core/
di/
service_locator.dart
constants/
extensions/
utils/
The domain layer defines contracts; the data layer implements them. Presentation never talks directly to data. Repositories act as the bridge. This isn't perfect architecture—it's pragmatic separation that keeps tests isolated and logic reusable.
In production ride-hailing apps, we've seen teams move fast because a change to the payment source logic doesn't cascade into five UI pages. The repository interface stays the same; only the implementation changes.
State and Dependency Injection: Riverpod or Bloc, by Project
We don't dogmatically pick one state management solution for every project. A simple settings screen doesn't need Bloc. A complex multi-step booking flow does. Our starter includes both, and we choose at feature level.
Riverpod works well when state is largely derived and immutable. It's fast to write, the devtools are excellent, and testing feels natural.
final userProvider = FutureProvider<User>((ref) async {
final repository = ref.watch(authRepositoryProvider);
return repository.getCurrentUser();
});
final isAuthenticatedProvider = Provider<bool>((ref) {
final user = ref.watch(userProvider);
return user.when(
data: (_) => true,
loading: () => false,
error: (_, __) => false,
);
});
Bloc shines when state transitions are complex and you need to reason about events explicitly. Trading platforms and ride-hailing apps benefit from Bloc's explicit event handling and replay capabilities.
class BookingBloc extends Bloc<BookingEvent, BookingState> {
final CreateBookingUseCase _createBooking;
BookingBloc(this._createBooking) : super(BookingInitial()) {
on<ConfirmBookingEvent>(_onConfirmBooking);
}
Future<void> _onConfirmBooking(
ConfirmBookingEvent event,
Emitter<BookingState> emit,
) async {
emit(BookingLoading());
final result = await _createBooking(event.details);
result.fold(
(failure) => emit(BookingError(failure.message)),
(booking) => emit(BookingSuccess(booking)),
);
}
}
We set up a dependency injection container on day one that's agnostic to which approach you choose. Riverpod handles its own DI through providers; Bloc needs explicit registration. Either way, a single service locator file lets new team members understand what's available without hunting.
Core Packages: Router, HTTP, Code Generation, and Secrets
The starter includes four packages that we've found non-negotiable:
- go_router — Type-safe navigation with deep linking baked in. No more string-based routes or NavigationObserver hackery.
- dio — HTTP client with interceptor support for auth tokens, error handling, and request logging without boilerplate.
- freezed — Immutable value objects and union types. Eliminates operator==, hashCode, copyWith, and serialization bugs.
- flutter_secure_storage — Platform-native secure storage for tokens and credentials. Never roll your own.
Here's a real example: a Dio interceptor that automatically refreshes an auth token and retries the request.
class AuthInterceptor extends Interceptor {
final AuthRepository _authRepository;
AuthInterceptor(this._authRepository);
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
try {
final newToken = await _authRepository.refreshToken();
final request = err.requestOptions;
request.headers['Authorization'] = 'Bearer $newToken';
return handler.resolve(await dio.fetch(request));
} catch (e) {
return handler.next(err);
}
}
return handler.next(err);
}
}
Go_router's redirect callbacks handle authentication flows without imperative logic scattered across pages. Freezed's code generation removes a category of runtime bugs entirely. Secure storage keeps secrets off the filesystem in plaintext. These are force multipliers, not luxuries.
Quality: Lints, Tests, and CI from the First Commit
Code quality decisions after the fact are arguments. Code quality from day one is just how we work. The starter includes a strict lint config that catches nullable misuse, missing documentation, and unused variables.
linter:
rules:
- avoid_empty_else
- avoid_null_checks_in_equality_operators
- avoid_relative_lib_imports
- avoid_returning_null_for_future
- avoid_slow_async_io
- cancel_subscriptions
- close_sinks
- comment_references
- control_flow_in_finally
- empty_statements
- invariant_booleans
- leading_newlines_in_multiline_strings
- sized_box_for_whitespace
- sized_box_shrink
- unnecessary_statements
- unrelated_type_equality_checks
We run dart analyze and format with dart format in CI. No exceptions, no reviews about style. The formatter decides.
Testing scaffolding is in place from day one. A unit test template, a widget test template, and a simple CI workflow that runs both before allowing a merge to main. In trading and SaaS contexts, we've seen teams push more tests to CI early because the friction of local setup is gone.
void main() {
group('LoginUseCase', () {
late MockAuthRepository mockRepository;
late LoginUseCase useCase;
setUp(() {
mockRepository = MockAuthRepository();
useCase = LoginUseCase(mockRepository);
});
test('returns user on successful login', () async {
final user = User(id: '1', email: 'test@example.com');
when(mockRepository.login('test@example.com', 'password'))
.thenAnswer((_) async => Right(user));
final result = await useCase('test@example.com', 'password');
expect(result, Right(user));
});
});
}
Release: Flavors, Crash Reporting, and Automated Builds
Multiple environments—staging, production, internal testing—aren't an afterthought. The starter scaffolds flavors with separate bundle IDs, app names, and API endpoints from the first build.
abstract class AppConfig {
String get apiBaseUrl;
String get appName;
bool get enableCrashReporting;
}
class ProductionConfig implements AppConfig {
@override
String get apiBaseUrl => 'https://api.production.example.com';
@override
String get appName => 'MyApp';
@override
bool get enableCrashReporting => true;
}
class StagingConfig implements AppConfig {
@override
String get apiBaseUrl => 'https://api.staging.example.com';
@override
String get appName => 'MyApp Staging';
@override
bool get enableCrashReporting => false;
}
We integrate Sentry (or Crashlytics) on day one. Not after the first production crash. By then it's too late. A single line of configuration in main() sends exceptions, performance metrics, and release tracking to a dashboard.
GitHub Actions or Codemagic handles building and signing. On every tag, a build runs, tests pass, and the artifact is uploaded to TestFlight or Google Play Console. Team members don't manual-sign APKs at 11pm before a demo.
Common Mistakes
- Over-engineering from the start. A simple counter app doesn't need repositories and use cases. Use architecture as the codebase grows, not because architecture is virtuous.
- Choosing state management based on blog posts instead of project needs. Riverpod is simpler for most screens. Bloc is clearer for complex transitions. Neither is objectively better.
- Skipping CI setup because it's "just a side project." The side project becomes the real project. CI and testing discipline compound over time.
- Storing secrets in code or version control. Use environment variables, secure storage, or a secrets manager from day one. Don't refactor it later.
- Treating the starter as sacred. Every project is different. Remove what you don't need; add what you do. The starter is a checklist, not a dogma.
Key Takeaways
- Organize by feature, not by type. Keep presentation, domain, and data layers separate so changes don't cascade.
- Choose state management per feature. Riverpod for derived state, Bloc for complex transitions.
- Core packages—go_router, dio, freezed, secure_storage—eliminate entire classes of bugs and boilerplate.
- Lint, test, and CI strictness from the first commit prevents architectural and quality debt.
- Flavors, crash reporting, and automated builds let teams ship confidently without manual overhead.
- A starter is a template to adapt, not a mandate. Remove what's unnecessary; add what your project needs.






Comments
Loading comments…