Project handoff
Atlas Demo
Fictional showcase project — the documentation template every real client project starts from.
- Client
- Atlas Retail (fictional) · product@atlasretail.example
- Stack
- Flutter · GetIt · GetX · Clean Architecture
- Links
- Flutter repo: https://git.atlasdemo.example/mobileAPI docs: https://api.atlasdemo.example/docsBackend repo: https://git.atlasdemo.example/backend
| Environment | Base URL | Notes |
|---|---|---|
| Development | https://api.dev.atlasdemo.example | Seeded nightly — test accounts can be created freely. |
| Staging | https://api.staging.atlasdemo.example | Mirrors production schema; client UAT happens here. |
| Production | https://api.atlasdemo.example | Never run test requests against production. |
Delivery phases
Phase 1 — Foundations & Auth
signed off2026-05-04 → 2026-05-29- ☑Design system + theming (light/dark)
- ☑Auth flows: login, register, logout, reset
- ☑CI pipeline with signed test builds
Client signed off 2026-05-29 after UAT on staging.
Phase 2 — Catalog & Cart
active2026-06-01 → 2026-07-24- ☑Product catalog with search + filters
- ☑Local-first cart
- ☐Token-refresh hardening (contract change fix)
- ☐Deep-link handling for password reset
Phase 3 — Checkout & Launch
planned2026-07-27 → 2026-09-04- ☐Checkout with Stripe PaymentSheet
- ☐Push notifications
- ☐Store listings + release
Architecture decisions
GetIt owns construction, GetX owns lifecycle 2026-05-12
Context Controllers need GetX lifecycle hooks (onInit/onClose), but repositories and use cases should be framework-free and testable.
Decision Register data sources, repositories and use cases in GetIt; controllers live only in GetX bindings and receive dependencies via sl().
Consequences Tests override GetIt registrations with mocks; no controller ever appears in the service locator.
Password reset always shows a neutral confirmation 2026-05-20
Context Distinguishing 'unknown email' from 'reset sent' enables account-enumeration attacks.
Decision Backend returns 200 for every reset request; the UI shows 'If an account exists, an email was sent' in all cases.
Single-flight token refresh behind a mutex 2026-06-18
Context Two parallel 401s (e.g. home + notifications fetch on boot) each triggered a refresh, and the second rotation invalidated the first token.
Decision The Dio interceptor serializes refresh calls behind a mutex; concurrent 401s await the same rotation future.
Consequences Boot-time races disappeared. The 2026-07-10 backend contract change (refreshToken rename) touches exactly one file.
Authentication
in progressEmail/password auth with login, registration, logout and password reset.
GetIt · GetX · Clean Architecture
Screens

API surface
| Method | Endpoint | Purpose | Auth | Status |
|---|---|---|---|---|
| POST | /auth/login | Exchange email + password for an access/refresh token pair and the user profile. | none | verified |
| POST | /auth/signup | Create an account and return a freshly-issued session (same shape as login). | none | verified |
| POST | /auth/logout | Best-effort server-side invalidation of the refresh token. | bearer | implemented |
| POST | /auth/reset | Trigger a password-reset email for the given address. | none | in progress |
| POST | /auth/refresh | Rotate the access token using the stored refresh token. | none | changed |
Presentation layer
Auth Controller · presentation · GetxController
GetxController that screens bind to. Holds the reactive Rx<User?> session, exposes async actions (login, register, logout, reset) and surfaces loading + error state for the UI.
- Screens call controller.login(email, pw) on form submit.
- Controller flips isBusy=true, runs the use case, waits for Either.
- On Left → fold to snackbar with AuthFailure.message.
- On Right → assign user.value, persist via repository, navigate via routes.
- Double-submit guard: ignore taps while isBusy=true.
- Reset busy state inside finally so a thrown error never leaves the button spinning.
- On boot: read cached session and pre-populate user.value before the first frame.
The only place GetX (Rx, Get.snackbar, Get.offAll) is allowed to leak in. Keep use cases and repos GetX-free.
Screens · Login · Register · Forgot
Three Flutter screens — LoginScreen, RegisterScreen, ForgotPasswordScreen — that bind to AuthController and submit the form payload. UI-only: no business logic.
- Screen mounts → reads AuthController via Get.find().
- Form validates locally (TextFormField validators) before calling the controller.
- While controller.isBusy → button shows a spinner, fields disable.
- Errors render via Get.snackbar from the controller; success navigates away.
- Keyboard-driven 'submit on enter' wired with TextInputAction.next/done.
- Forgot-password screen always shows the same neutral confirmation (anti-enumeration).
- Register screen shows a live password-strength meter (UI-only).
Screens never import use cases or repositories directly. The controller is the only door in.
Domain layer
User Entity · domain/entities
Pure Dart model that represents an authenticated user inside the domain layer. It carries the minimum identity payload the rest of the app needs after login — id, email, display name and a few profile flags — with no Flutter or HTTP imports.
- Built by data-source mappers from the raw JSON payload (UserModel.toEntity).
- Returned wrapped in Either<AuthFailure, User> through the repository.
- Flows up to the use cases, then to AuthController which exposes it as Rx<User?>.
- UI screens read it reactively to render the avatar, greetings and gated routes.
- Email is normalized to lowercase before construction so equality checks are stable.
- displayName falls back to the part before '@' when the backend omits it.
- Treat the entity as immutable — copyWith for any mutation, never setters.
Keep the entity framework-agnostic. UserModel (data layer) is the JSON-aware sibling — it adapts wire format to this entity.
Login Use Case · domain/usecases
Single-purpose use case for signing a user in with email + password. Validates inputs, calls AuthRepository.login and lifts the result into Either<AuthFailure, User>.
- AuthController.login() instantiates LoginParams(email, password).
- call(params) trims + lowercases the email and runs basic format checks.
- Delegates to AuthRepository.login(email, password).
- Repository returns Either<AuthFailure, User> — passed through unchanged.
- Controller maps Left → snackbar, Right → cache user + navigate to /home.
- Empty / malformed email → returns Left(AuthFailure.invalidEmail) without hitting the network.
- Password shorter than 6 chars → Left(AuthFailure.weakPassword).
- Server returns 401 → Left(AuthFailure.invalidCredentials).
- Connectivity drop mid-call → Left(AuthFailure.network).
Stays free of Get/GetIt/HTTP — it's the boundary the controller talks to. Add new validation rules here, not in the controller.
Register Use Case · domain/usecases
Creates a new account from a registration payload (email, password, displayName) and returns the freshly-issued User on success.
- Controller builds RegisterParams from the form.
- Use case enforces password strength + email format.
- Calls AuthRepository.register(payload).
- Repository POSTs to /auth/signup, caches the returned token and user.
- Returns Either<AuthFailure, User>.
- Email already taken → Left(AuthFailure.emailInUse) (HTTP 409).
- Server-side validation rejection → Left(AuthFailure.serverValidation(messages)).
- Network failure after token issued but before cache write — handled idempotently on next boot.
Mirrors LoginUseCase intentionally — same shape, same return type — so the controller can reuse the same Either-handling helper.
Logout Use Case · domain/usecases
Tears down the current session: clears the cached token, drops in-memory user state, and (best-effort) tells the backend to invalidate the refresh token.
- Controller calls LogoutUseCase().
- Repository.logout() fires POST /auth/logout (fire-and-forget).
- Local data source clears the token from secure storage.
- Returns Either<AuthFailure, Unit>.
- Backend unreachable → still clear local token; never block the user from logging out.
- Local storage write fails → surface a warning but continue navigation to /login.
Logout must always succeed locally — the user expects to land on /login. Treat the network call as best-effort cleanup.
Reset Password Use Case · domain/usecases
Triggers a password-reset email for the given address. Does not log the user in — the reset itself happens via a deep link from the email.
- Controller builds ResetParams(email).
- Use case validates email format.
- Repository.resetPassword(email) → POST /auth/reset.
- Backend always returns 200 (anti-enumeration) — surface a neutral confirmation either way.
- Unknown email — UI never reveals it; show 'If an account exists, an email was sent.'
- Rate-limit (HTTP 429) → Left(AuthFailure.rateLimited).
Privacy: do not differentiate 'unknown email' from 'success' in the UI — that's how account-enumeration attacks work.
Data layer
Auth Repository · domain/repositories
Abstract contract in the domain layer + concrete AuthRepositoryImpl in the data layer. The single seam between use cases and the outside world (network + storage).
- Use cases depend on the abstract interface only — they never see the impl.
- Impl orchestrates RemoteDataSource (HTTP) + LocalDataSource (token cache).
- Wraps every call in try/catch, mapping exceptions to typed AuthFailure values.
- Returns Either<AuthFailure, T> upward — no exceptions cross the boundary.
- Network call succeeds but local cache write fails — propagate user but log warning; next boot will re-sync.
- Token refresh race: two parallel 401s should trigger only one refresh attempt (mutex).
- Offline boot: trust the cached token until the next protected call rejects it.
All exception → AuthFailure mapping lives in the impl. Adding a new error case? Map it here, not in the use cases.
Remote Data Source · data/datasources
Thin Dio-based client that knows the auth API surface (login, signup, logout, reset). Speaks JSON in, UserModel/AuthTokenModel out — nothing more.
- Receives a typed payload from the repository (LoginRequest, RegisterRequest…).
- Issues the HTTP call via the shared Dio instance (with auth interceptor disabled for /auth/*).
- Parses the response into UserModel + AuthTokenModel.
- Throws ServerException / NetworkException — never returns failures.
- DioException with type connectionTimeout/receiveTimeout → NetworkException.
- Non-2xx status → ServerException(statusCode, message).
- Malformed JSON → FormatException — let it bubble; repository maps to AuthFailure.unexpected.
Stays exception-throwing on purpose. Either-conversion happens one level up (in the repository), so this file remains a 'dumb' adapter.
Local Data Source · data/datasources (token cache)
Persists the auth token + cached user in flutter_secure_storage. Read on boot to restore the session, written after login/register, cleared on logout.
- saveSession(AuthToken, UserModel) — JSON-encodes both, writes under namespaced keys.
- readSession() → returns (token, user) or null when absent.
- clearSession() — wipes both keys atomically.
- Secure storage unavailable on emulator/web → fall back to SharedPreferences with a clear log warning.
- Corrupt JSON in storage → treat as 'no session' and clear the keys defensively.
Never store the password. Only the issued token + the public user payload. Token rotation invalidates whatever is cached here.
Dependency Injection layer
Routes & Bindings · GetX routes · middleware
GetX route table + AuthBinding that lazy-puts the AuthController. AuthGuard middleware redirects unauthenticated users to /login on protected routes.
- App boots → GetMaterialApp reads AppPages.routes.
- /login, /register, /forgot are public — bound with AuthBinding.
- /home and below are gated by AuthGuard.middleware.
- Guard reads AuthController.user.value — null → redirect to /login.
- Deep link to a protected route while logged-out → guard saves intended path and replays after login.
- Token expired during navigation → 401 from a protected call triggers Get.offAll('/login').
Keep route names in AppRoutes constants — never hardcode strings inside screens.
Service Locator · GetIt registration
GetIt instance ('sl') registered in main.dart before runApp. Wires up Dio, secure storage, data sources, repositories and use cases as singletons / factories.
- main() → await configureDependencies(sl).
- Registers Dio, FlutterSecureStorage as singletons.
- Registers AuthRemoteDataSource + AuthLocalDataSource (singleton).
- Registers AuthRepository → AuthRepositoryImpl (singleton).
- Registers Login/Register/Logout/Reset use cases (factory).
- AuthBinding later does Get.lazyPut(AuthController(sl(), sl(), sl(), sl())).
- Test setup overrides registrations with mocks via sl.allowReassignment = true.
- Hot-reload reuses the existing registrations — guard with sl.isRegistered<T>() before re-register.
GetIt owns construction; GetX owns lifecycle for controllers. Don't put controllers in GetIt — bindings handle them.