arch-atlas
This is the print-ready handoff document — use your browser's Print → Save as PDF to export it for the client.

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
EnvironmentBase URLNotes
Developmenthttps://api.dev.atlasdemo.exampleSeeded nightly — test accounts can be created freely.
Staginghttps://api.staging.atlasdemo.exampleMirrors production schema; client UAT happens here.
Productionhttps://api.atlasdemo.exampleNever run test requests against production.

Delivery phases

Phase 1 — Foundations & Auth

signed off2026-05-042026-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-012026-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-272026-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 progress

Email/password auth with login, registration, logout and password reset.

GetIt · GetX · Clean Architecture

Screens

Welcome back
Forgot password?Sign In
WIREFRAME
Login
Welcome back
Forgot password?
WIREFRAME
Login — submitting · loading
!Invalid email or password
Welcome backForgot password?Sign In
WIREFRAME
Login — invalid credentials · error
Create account
Password strength: medium
Sign Up
WIREFRAME
Registration form
Reset password
Send Link
WIREFRAME
Reset password
Signed in — welcome back
WIREFRAME
Home — signed in · success
Set new password
Password strength: strong
Save password
WIREFRAME
Set new password
Sign-out entry point. Capture pending — drop the PNG to light it up.
Profile drawer — sign out

API surface

MethodEndpointPurposeAuthStatus
POST/auth/loginExchange email + password for an access/refresh token pair and the user profile.noneverified
POST/auth/signupCreate an account and return a freshly-issued session (same shape as login).noneverified
POST/auth/logoutBest-effort server-side invalidation of the refresh token.bearerimplemented
POST/auth/resetTrigger a password-reset email for the given address.nonein progress
POST/auth/refreshRotate the access token using the stored refresh token.nonechanged

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.

  1. Screens call controller.login(email, pw) on form submit.
  2. Controller flips isBusy=true, runs the use case, waits for Either.
  3. On Left → fold to snackbar with AuthFailure.message.
  4. 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.
lib/features/auth/presentation/controllers/auth_controller.dart

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.

  1. Screen mounts → reads AuthController via Get.find().
  2. Form validates locally (TextFormField validators) before calling the controller.
  3. While controller.isBusy → button shows a spinner, fields disable.
  4. 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).
lib/features/auth/presentation/screens/login_screen.dart
lib/features/auth/presentation/screens/register_screen.dart
lib/features/auth/presentation/screens/forgot_password_screen.dart

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.

  1. Built by data-source mappers from the raw JSON payload (UserModel.toEntity).
  2. Returned wrapped in Either<AuthFailure, User> through the repository.
  3. Flows up to the use cases, then to AuthController which exposes it as Rx<User?>.
  4. 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.
lib/features/auth/domain/entities/user.dart
lib/features/auth/data/models/user_model.dart:42 // toEntity()

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>.

  1. AuthController.login() instantiates LoginParams(email, password).
  2. call(params) trims + lowercases the email and runs basic format checks.
  3. Delegates to AuthRepository.login(email, password).
  4. Repository returns Either<AuthFailure, User> — passed through unchanged.
  5. 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).
lib/features/auth/domain/usecases/login_usecase.dart
lib/features/auth/domain/repositories/auth_repository.dart:18 // login()

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.

  1. Controller builds RegisterParams from the form.
  2. Use case enforces password strength + email format.
  3. Calls AuthRepository.register(payload).
  4. Repository POSTs to /auth/signup, caches the returned token and user.
  5. 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.
lib/features/auth/domain/usecases/register_usecase.dart

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.

  1. Controller calls LogoutUseCase().
  2. Repository.logout() fires POST /auth/logout (fire-and-forget).
  3. Local data source clears the token from secure storage.
  4. 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.
lib/features/auth/domain/usecases/logout_usecase.dart

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.

  1. Controller builds ResetParams(email).
  2. Use case validates email format.
  3. Repository.resetPassword(email) → POST /auth/reset.
  4. 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).
lib/features/auth/domain/usecases/reset_password_usecase.dart

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).

  1. Use cases depend on the abstract interface only — they never see the impl.
  2. Impl orchestrates RemoteDataSource (HTTP) + LocalDataSource (token cache).
  3. Wraps every call in try/catch, mapping exceptions to typed AuthFailure values.
  4. 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.
lib/features/auth/domain/repositories/auth_repository.dart // abstract
lib/features/auth/data/repositories/auth_repository_impl.dart // impl

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.

  1. Receives a typed payload from the repository (LoginRequest, RegisterRequest…).
  2. Issues the HTTP call via the shared Dio instance (with auth interceptor disabled for /auth/*).
  3. Parses the response into UserModel + AuthTokenModel.
  4. 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.
lib/features/auth/data/datasources/remote/auth_remote_datasource.dart
lib/core/network/dio_client.dart

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.

  1. saveSession(AuthToken, UserModel) — JSON-encodes both, writes under namespaced keys.
  2. readSession() → returns (token, user) or null when absent.
  3. 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.
lib/features/auth/data/datasources/local/auth_local_datasource.dart

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.

  1. App boots → GetMaterialApp reads AppPages.routes.
  2. /login, /register, /forgot are public — bound with AuthBinding.
  3. /home and below are gated by AuthGuard.middleware.
  4. 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').
lib/core/routing/app_pages.dart
lib/core/routing/app_routes.dart
lib/features/auth/presentation/bindings/auth_binding.dart
lib/features/auth/presentation/middleware/auth_guard.dart

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.

  1. main() → await configureDependencies(sl).
  2. Registers Dio, FlutterSecureStorage as singletons.
  3. Registers AuthRemoteDataSource + AuthLocalDataSource (singleton).
  4. Registers AuthRepository → AuthRepositoryImpl (singleton).
  5. Registers Login/Register/Logout/Reset use cases (factory).
  6. 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.
lib/core/di/service_locator.dart
lib/main.dart:14 // configureDependencies(sl)

GetIt owns construction; GetX owns lifecycle for controllers. Don't put controllers in GetIt — bindings handle them.