Riverpod + Clean Architecture + Firebase — The Complete Flutter Guide
A deep-dive guide into the most battle-tested Flutter stack: Riverpod for state, Clean Architecture for structure, Firebase as your backend. Learn when to choose it, what gets generated, and how each layer connects.
Stack Configuration — what FlutterInit generates for this guide
When to choose this stack
Choose this stack when you're building an app with a team of 2+ developers, need Firebase's real-time capabilities, and want your codebase to remain maintainable as features grow. The Clean Architecture layers add upfront complexity but pay dividends when you need to swap Firebase for Supabase, add offline support, or onboard a new developer.
What This Stack Generates
When you select Riverpod + Clean Architecture + Firebase in FlutterInit, you get a fully wired Flutter project with clear separation of concerns across three layers: Data, Domain, and Presentation.
Project Structure
Understanding Each Layer
Domain — The Core Rules
The domain layer is the heart of Clean Architecture. It contains:
- Entities: Plain Dart objects with no framework dependencies
- Repository interfaces: Abstract classes defining what the data layer must provide
- Use cases: Single-responsibility classes encapsulating business logic
class UserEntity {
final String id;
final String email;
final String? displayName;
const UserEntity({
required this.id,
required this.email,
this.displayName,
});
}
// domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Either<Failure, UserEntity>> signIn({
required String email,
required String password,
});
Future<Either<Failure, void>> signOut();
Stream<UserEntity?> get authStateChanges;
}
Notice that AuthRepository has zero Firebase imports. This is the key benefit — your domain layer is testable without any Firebase SDK.
Data — The Firebase Implementation
The data layer implements the domain interfaces using real Firebase calls:
class AuthRepositoryImpl implements AuthRepository {
final FirebaseAuth _auth;
const AuthRepositoryImpl({required FirebaseAuth auth}) : _auth = auth;
@override
Future<Either<Failure, UserEntity>> signIn({
required String email,
required String password,
}) async {
try {
final credential = await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
return Right(UserModel.fromFirebase(credential.user!).toEntity());
} on FirebaseAuthException catch (e) {
return Left(AuthFailure(message: e.message ?? 'Authentication failed'));
}
}
}
Presentation — Riverpod Providers
With Riverpod, your UI state is managed by StateNotifierProvider or AsyncNotifierProvider:
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepositoryImpl(auth: FirebaseAuth.instance);
});
final authNotifierProvider =
StateNotifierProvider<AuthNotifier, AsyncValue<UserEntity?>>((ref) {
return AuthNotifier(ref.watch(authRepositoryProvider));
});
class AuthNotifier extends StateNotifier<AsyncValue<UserEntity?>> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AsyncValue.loading()) {
_init();
}
void _init() {
_repository.authStateChanges.listen((user) {
state = AsyncValue.data(user);
});
}
Future<void> signIn({required String email, required String password}) async {
state = const AsyncValue.loading();
final result = await _repository.signIn(email: email, password: password);
result.fold(
(failure) => state = AsyncValue.error(failure, StackTrace.current),
(user) => state = AsyncValue.data(user),
);
}
}
Navigation with go_router
The generated project uses go_router with a StreamProvider to handle auth-state-based redirects:
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/sign-in',
redirect: (context, state) {
final isAuthenticated = authState.valueOrNull != null;
final isOnAuth = state.uri.path.startsWith('/sign-in') ||
state.uri.path.startsWith('/sign-up');
if (isAuthenticated && isOnAuth) return '/home';
if (!isAuthenticated && !isOnAuth) return '/sign-in';
return null;
},
routes: [
GoRoute(path: '/sign-in', builder: (_, __) => const SignInPage()),
GoRoute(path: '/sign-up', builder: (_, __) => const SignUpPage()),
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
],
);
});
Adding a New Feature
The best test of an architecture is how easy it is to extend. Here's how you'd add a todos feature:
- Create
features/todos/domain/entities/todo_entity.dart - Define
features/todos/domain/repositories/todo_repository.dart - Write use cases:
GetTodosUsecase,AddTodoUsecase,DeleteTodoUsecase - Implement with Firestore in
features/todos/data/ - Connect with a
StateNotifierProviderinfeatures/todos/presentation/providers/
Each step is isolated. A bug in the Firestore implementation never corrupts the domain logic. A UI change never touches the data layer.
Testing Strategy
With this separation, testing becomes straightforward:
// Test use case with a mock repository
void main() {
group('SignInUsecase', () {
late MockAuthRepository mockRepository;
late SignInUsecase usecase;
setUp(() {
mockRepository = MockAuthRepository();
usecase = SignInUsecase(repository: mockRepository);
});
test('returns UserEntity on success', () async {
when(() => mockRepository.signIn(email: any, password: any))
.thenAnswer((_) async => Right(testUser));
final result = await usecase(email: 'test@test.com', password: 'pass');
expect(result, Right(testUser));
});
});
}
Ready to Generate?
This entire structure — with all the boilerplate wired up and pubspec.yaml pre-configured — is what FlutterInit generates for you in seconds.
Ready to build?
Generate this project in seconds
FlutterInit scaffolds the entire structure described in this guide — wired up, typed, and ready for flutter run.