Production-ready auth architecture — backend-agnostic, fully testable, and swap-ready from mock to Firebase or REST in one file.
Domain is pure Dart — no Flutter, no external packages. Outer layers implement its contracts; the dependency direction never reverses.
lib/
├── core/
│ ├── di/ injection.dart
│ ├── router/ app_router.dart
│ ├── stubs/ mock_auth_remote_source.dart
│ └── theme/ app_colors.dart · app_theme.dart
└── features/auth/
├── domain/ entities · repository interface · use cases
├── data/ models · sources (abstract) · repository impl
└── presentation/
├── bloc/ auth_bloc · auth_event · auth_state
├── pages/ login_page · register_page
└── widgets auth_text_field · auth_error_banner
flutter pub get
flutter run
Runs on MockAuthRemoteSource — simulates 1.2s latency. Sessions reset on restart by design.
// main.dart — Firebase
configureDependencies(
remoteSource: FirebaseAuthRemoteSource(),
localSource: FirebaseAuthLocalSource(),
);
// main.dart — REST + JWT
configureDependencies(
remoteSource: ApiAuthRemoteSource(dio: Dio()),
localSource: SecureStorageLocalSource(),
);
Source throws AuthException
│
▼ AuthRepositoryImpl maps to typed Failure
│
▼ Returned as Left(failure) via Either<Failure, T>
│
▼ AuthBloc.fold() emits AuthError(failure)
│
▼ AuthErrorBanner renders failure.message
App Start
│
▼ SplashPage shown (AuthInitial → AuthLoading)
│
▼ SessionCheckRequested dispatched on startup
AuthBloc.checkSession()
│
├── Session found & valid ──► Authenticated ──► Role-based dashboard
│
└── No session / expired ──► Unauthenticated ──► LoginPage
│
User submits form
│
SignInRequested dispatched
│
AuthBloc processes
│
┌───────────┴───────────┐
Success Failure
│ │
Authenticated AuthError
state emitted state emitted
│ │
GoRouter redirect Error banner
to role dashboard shown in form
UserRole.admin ──► /dashboard/admin ──► AdminDashboardPage
UserRole.user ──► /dashboard/user ──► UserDashboardPage
UserRole.guest ──► /dashboard/user ──► UserDashboardPage
Redirect logic lives entirely in AppRouter._redirect.
Adding a new role is a single case in a switch expression —
no scattered guard logic anywhere.
On every app start, SessionCheckRequested is dispatched.
AuthRepositoryImpl.checkSession() reads the cached
UserModel and AuthSessionModel from
AuthLocalSource, then delegates expiry to the domain:
// domain/entities/auth_session.dart
bool get isExpired => DateTime.now().isAfter(expiresAt);
// data/repositories/auth_repository_impl.dart
Future<Either<Failure, AuthResult>> checkSession() async {
final user = await localSource.getCachedUser();
final session = await localSource.getCachedSession();
if (user == null || session == null)
return Left(SessionFailure('No cached session'));
if (session.isExpired) {
await localSource.clearCache();
return Left(SessionFailure('Session expired'));
}
return Right(AuthResult(user, session));
}
GetIt is the service locator.
Only two abstract interfaces need concrete implementations.
Everything above them — repository, use cases, BLoC, UI — is unchanged.
// core/di/injection.dart
void configureDependencies({
required AuthRemoteSource remoteSource, // ← only seam 1
required AuthLocalSource localSource, // ← only seam 2
}) {
// Singletons: sources + repository (one instance for app lifetime)
sl.registerSingleton<AuthRemoteSource>(remoteSource);
sl.registerSingleton<AuthLocalSource>(localSource);
sl.registerSingleton<AuthRepository>(
AuthRepositoryImpl(sl(), sl()),
);
// Factories: use cases + BLoC (new instance per resolution)
sl.registerFactory(() => SignInUseCase(sl()));
sl.registerFactory(() => AuthBloc(signIn: sl(), ...));
}
Either?
Exceptions are invisible in signatures. Either<Failure, T> forces callers to handle the error path explicitly — fewer silent failures in the UI.
refreshListenable wired to the BLoC stream keeps navigation declaratively in sync with auth state. No manual Navigator.push scattered across pages.
AuthBloc instance per resolution keeps BLoC lifecycle independent of the service locator — no stale state leaking across sessions.