🌐 Language: English · Tiếng Việt · 中文 · 한국어
┌─────────────────┐
│ Flutter App │ (lib/core, lib/features, lib/shared)
│ (Riverpod) │
└────────┬────────┘
│
│ Dio HTTP Client (auto-generated from OpenAPI spec)
│
┌────────▼────────────────────────────────────────────┐
│ NestJS API Server (apps/api/src) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Middleware: Helmet, CORS, request logging │ │
│ │ Guards: ThrottlerGuard, NotImplementedAuthGuard │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Controllers (routes) & Services (business logic)│ │
│ │ modules/hello, modules/health, ... │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Prisma ORM → Database models │ │
│ └─────────────────────────────────────────────────┘ │
└────────┬─────────────────────────────────────────────┘
│
│ SQL
│
┌────────▼──────────────────┐
│ PostgreSQL (Supabase) │
│ - Schema in prisma/schema │
│ - Auto-migrations │
└───────────────────────────┘
All API responses (success or error) wrap data in a standard envelope:
{
"data": { /* controller return value */ },
"meta": {
"requestId": "uuid-v4",
"timestamp": "2026-04-27T00:00:00Z",
"version": "1.0"
},
"error": null // or error object if status >= 400
}Enforced by: TransformResponseInterceptor (success) + AllExceptionsFilter (errors)
requestId fallback chain:
req.id(set by pino-http viagenReqId)X-Request-IDheader (allows client correlation)- Fresh
randomUUID()(last resort)
This ensures every error response carries a usable correlation ID even when errors fire before pino-http middleware (e.g., NestJS internal startup errors).
Generated client (Dart): Deserialization handled automatically. Response type is data field only; meta + requestId available via context.
apps/api/src/
├── main.ts # Bootstrap: app creation + listen
├── app.module.ts # Root module: imports + global guards/pipes/interceptors
├── common/ # Shared cross-module
│ ├── auth/
│ │ ├── decorators/ # @Public(), @RequireAuth()
│ │ ├── not-implemented-auth.guard.ts # Stub (default-deny)
│ │ └── jwt-auth.guard.ts # Swap in for real auth
│ ├── decorators/
│ │ └── api-standard-response.decorator.ts # Wraps DTO + envelope for Swagger
│ ├── filters/
│ │ └── all-exceptions.filter.ts # Formats errors to envelope
│ └── interceptors/
│ ├── logging.interceptor.ts # Per-request timing
│ └── transform-response.interceptor.ts # Wraps success data
├── config/
│ ├── config.module.ts # ConfigModule.forRoot (isGlobal: true)
│ ├── app.config.ts # NestJS config schema
│ └── env.schema.ts # Joi validation for .env
├── infra/ # Infrastructure
│ ├── logger/
│ │ └── logger.module.ts # Pino structured logging
│ └── prisma/
│ ├── prisma.module.ts # Global PrismaService
│ └── prisma.service.ts
└── modules/ # Feature modules
├── hello/
│ ├── hello.module.ts
│ ├── hello.controller.ts # Routes: GET /hello
│ ├── hello.service.ts # Business logic
│ ├── dto/
│ │ └── hello-response.dto.ts
│ └── hello.service.spec.ts
└── health/
├── health.module.ts
└── health.controller.ts # GET /health (liveness probe)
| Layer | Responsibility | Example |
|---|---|---|
| Controller | HTTP routing, request parsing, response formatting | @Get(':id') path + query + body params |
| Service | Business logic, orchestration, error handling | Validate input, call repository, handle edge cases |
| Repository | Data access abstraction (optional if using Prisma directly) | Interface + implementation |
| Prisma | Type-safe database queries, migrations | prisma.user.findUnique({ where: { id } }) |
| Guard/Interceptor | Cross-cutting: auth, logging, rate limiting | Applied globally or per-route via decorators |
| Filter | Exception → HTTP response formatting | 400/401/500 → envelope with error details |
| Decorator | Metadata for routing, validation, Swagger docs | @Public(), @ApiStandardResponse(Dto) |
| Provider | Purpose | Type |
|---|---|---|
| APP_PIPE | ValidationPipe with whitelist + forbidNonWhitelisted | Prevents injection |
| APP_GUARD | NotImplementedAuthGuard (default-deny) | Security first; swap for JwtAuthGuard |
| APP_GUARD | ThrottlerGuard | Rate limit: 100 req/min per IP |
| APP_FILTER | AllExceptionsFilter | Formats errors to envelope |
| APP_INTERCEPTOR | LoggingInterceptor | Logs duration + status per request |
| APP_INTERCEPTOR | TransformResponseInterceptor | Wraps success responses in envelope |
apps/mobile/lib/
├── main.dart # App entry + ProviderScope setup
├── app.dart # App widget with theme + routing
├── core/ # Shared non-feature logic
│ ├── config/
│ │ ├── env.dart # AppEnv: FLAVOR, API_BASE_URL, etc.
│ │ └── flavor.dart # Enum: dev, staging, prod
│ ├── di/
│ │ └── providers.dart # Global Riverpod providers
│ ├── network/
│ │ └── dio_client.dart # Dio instance factory
│ ├── router/
│ │ └── app_router.dart # go_router route definitions
│ ├── theme/
│ │ └── app_theme.dart # Material 3 light + dark themes
│ └── error/
│ ├── app_exception.dart # Custom exceptions
│ └── failure.dart # Failure types for AsyncValue
├── features/ # Feature modules (isolated)
│ └── hello/
│ ├── data/
│ │ └── hello_repository_impl.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── hello.dart # Data class
│ │ └── repositories/
│ │ └── hello_repository.dart # Abstract interface
│ └── presentation/
│ ├── controllers/
│ │ └── hello_controller.dart # Riverpod provider
│ ├── screens/
│ │ └── hello_screen.dart
│ └── widgets/
│ └── hello_card.dart
├── shared/ # Cross-feature widgets, utilities
│ └── widgets/
│ ├── error_view.dart
│ └── loading_view.dart
└── l10n/ # Internationalization (ARB files)
├── app_en.arb
└── app_vi.arb
Global providers live in lib/core/di/providers.dart:
flavorProvider— app flavor (dev/staging/prod), overridden inmain()envProvider— runtime config (base URL, etc.), overridden inmain()dioProvider— HTTP client, depends on env + flavor
Feature providers live in lib/features/<name>/presentation/controllers/:
<name>ControllerProvider— async notifier managing state- Depends on repository, which depends on dioProvider
Override pattern for tests:
ProviderContainer(
overrides: [
dioProvider.overrideWithValue(mockDio),
helloRepositoryProvider.overrideWithValue(FakeHelloRepository()),
],
);Each feature has an async controller:
// Plain Provider (scaffold state, no build_runner yet)
final helloRepositoryProvider = Provider<HelloRepository>(
(ref) => HelloRepositoryImpl(ref.watch(dioProvider)),
);
// AsyncNotifierProvider (manage async fetch state)
final helloControllerProvider = AsyncNotifierProvider<
HelloController,
HelloResponseDto,
>(HelloController.new);
class HelloController extends AsyncNotifier<HelloResponseDto> {
@override
Future<HelloResponseDto> build() async {
return ref.watch(helloRepositoryProvider).getHello();
}
}UI consumes:
Consumer(builder: (ctx, ref, child) {
final helloAsync = ref.watch(helloControllerProvider);
return helloAsync.when(
data: (hello) => Text(hello.message),
loading: () => CircularProgressIndicator(),
error: (err, stack) => ErrorView(error: err),
);
})-
Backend DTO Definition:
// apps/api/src/modules/hello/dto/hello-response.dto.ts export class HelloResponseDto { @ApiProperty() message: string; @ApiProperty() timestamp: Date; }
-
Controller Declaration:
@Controller('hello') export class HelloController { @Get() @ApiOperation({ operationId: 'getHello' }) @ApiStandardResponse(HelloResponseDto) getHello(): HelloResponseDto { ... } }
-
Swagger Spec Generation:
- NestJS @nestjs/swagger reads decorators
- Spec auto-generated at
http://localhost:3000/api-docs/json(dev) - Saved to
openapi.json(CI/CD)
-
Dart Client Generation:
pnpm codegen:api # Runs openapi-generator or similar- Input:
openapi.json - Output:
packages/api_client/lib/(Dio-based REST client) - Method name derived from
operationId(e.g.,getHello())
- Input:
-
Mobile Integration:
// Generated client auto-injected into feature repository final apiClient = ref.watch(apiClientProvider); final response = await apiClient.getHello(); // Type-safe
- Backend:
SKIP_DB=true(hello endpoint only) or real Postgres - Mobile:
.envwithAPI_BASE_URL=auto→ localhost:3000 (auto-resolved per platform)
- Backend: Supabase staging database, real auth (JwtAuthGuard)
- Mobile:
.envwith explicit staging API URL orflutter run --dart-define=API_BASE_URL=...
- Backend: Supabase production database, auth, monitoring
- Mobile: Signed APK/IPA, app-signing cert, released to Play Store / App Store
Located in apps/api/prisma/schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // Supabase pgbouncer workaround
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
}Workflow:
- Edit schema
pnpm --filter @mobile-boilerplate/api prisma migrate dev --name add_userspnpm --filter @mobile-boilerplate/api prisma generate- Auto-generated
PrismaClientwith type-safe queries
| Mechanism | Implementation | Enforced At |
|---|---|---|
| Default-deny auth | NotImplementedAuthGuard (401 all routes by default) | app.module.ts APP_GUARD |
| Public route opt-in | @Public() decorator |
Per-controller or per-method |
| Input validation | WhitelistPipe forbidNonWhitelisted | app.module.ts APP_PIPE |
| Rate limiting | ThrottlerGuard (100 req/min per IP) | app.module.ts APP_GUARD |
| Request ID tracing | UUID per request, logged + returned in envelope | TransformResponseInterceptor |
| HTTPS (SSL) | Reverse proxy (nginx/ALB) in production | Kubernetes/Docker orchestration |
| CORS | Helmet middleware | app.module.ts imports |
| SQL injection prevention | Prisma parameterized queries | ORM layer |
Backend exceptions → standardized envelope:
// AllExceptionsFilter catches all exceptions
{
"data": null,
"error": {
"statusCode": 400,
"message": "Validation failed",
"details": ["field1 must be a string"]
},
"meta": { "requestId": "..." }
}Mobile receives:
helloAsync.when(
error: (err, stack) {
// err is AppException with statusCode, message
},
);- Logging: Pino (structured JSON logs on backend)
- Request tracing: requestId in meta + logs
- Error reporting: All exceptions logged with context
- Performance: LoggingInterceptor logs request duration
- Health checks: GET /health (no auth required)