234 lines
8.7 KiB
Markdown
234 lines
8.7 KiB
Markdown
|
|
# Architecture Decisions
|
||
|
|
|
||
|
|
Key architectural decisions for MeshiTrack with rationale.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-001: Monorepo with Shared Types Package
|
||
|
|
|
||
|
|
**Decision**: Use a Turborepo/Nx monorepo with `packages/api`, `packages/web`, and `packages/shared`.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Type-safe API contracts: DTOs and validation schemas (Zod) are defined once in `shared`, consumed by both API and web
|
||
|
|
- Future React Native app imports directly from `shared` — no duplication
|
||
|
|
- Atomic commits across API + web when contracts change
|
||
|
|
- Turborepo handles caching and parallel builds efficiently
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Slightly more complex initial setup vs separate repos
|
||
|
|
- Needs careful dependency management between packages
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-002: Keycloak for Authentication
|
||
|
|
|
||
|
|
**Decision**: Use Keycloak as an external OIDC identity provider rather than custom JWT auth.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Production-grade OIDC/OAuth2 out of the box
|
||
|
|
- Built-in user management admin UI — no need to build user registration/password reset flows
|
||
|
|
- Social login support if needed later
|
||
|
|
- Household multi-tenancy via custom token claims (custom protocol mapper injects `householdIds[]` into JWT)
|
||
|
|
- Self-hosted, aligns with deployment strategy
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Heavier infrastructure (JVM-based, ~512MB RAM)
|
||
|
|
- Steeper learning curve for Keycloak config vs simple Passport.js
|
||
|
|
- Adds complexity to Docker Compose
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-003: MongoDB over PostgreSQL
|
||
|
|
|
||
|
|
**Decision**: Use MongoDB as the primary database.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Flexible schema suits the product catalog (products have varying nutrition fields, optional barcodes, tags)
|
||
|
|
- Embedded documents reduce need for joins (recipe ingredients embed product references, pantry items embed freshness rules)
|
||
|
|
- Time-series-like data (price records) works well with MongoDB's TTL indexes and time-series collections
|
||
|
|
- Native JSON — no ORM impedance mismatch with TypeScript objects
|
||
|
|
- Good Atlas Search capabilities for full-text product search
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Less strict referential integrity vs PostgreSQL (mitigated by application-level validation)
|
||
|
|
- Aggregation pipeline can be complex for reporting queries
|
||
|
|
- Need to be intentional about data modeling to avoid unbounded array growth
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-004: Household-Scoped Multi-Tenancy
|
||
|
|
|
||
|
|
**Decision**: All domain data (products, recipes, pantry items, shopping lists) is scoped to a `householdId`. Users belong to one or more households.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Enables sharing: family members share a pantry, shopping list, and recipe collection
|
||
|
|
- Clean data isolation between households within the same MongoDB instance
|
||
|
|
- Every query includes `householdId` filter — implemented via a Fastify preHandler hook that validates it from the URI param (`:householdId`) against the user's `householdIds[]` JWT claim
|
||
|
|
|
||
|
|
**Data model**:
|
||
|
|
|
||
|
|
```
|
||
|
|
User { keycloakId, displayName, householdIds[], defaultHouseholdId }
|
||
|
|
Household { name, memberIds[], ownerUserId, inviteCode, settings }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Slightly more complex than single-user: need to manage household membership, invitations, role checks
|
||
|
|
- Every query must include household filter (enforced by middleware, not developer discipline)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-005: LLM Abstraction Layer (Provider Pattern)
|
||
|
|
|
||
|
|
**Decision**: Define an `ILlmProvider` interface in Phase 1, implement concrete providers in Phase 6. All LLM-dependent features have manual fallback paths.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Avoids blocking core functionality on LLM availability or cost decisions
|
||
|
|
- The LLM landscape changes rapidly — abstraction allows swapping providers without touching feature code
|
||
|
|
- Users who don't want LLM features get a fully functional app
|
||
|
|
- Each feature (product recognition, recipe parsing, meal suggestions) calls the interface; a `NoOpLlmProvider` returns graceful "not available" responses until real providers are wired
|
||
|
|
|
||
|
|
**Interface sketch**:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
interface ILlmProvider {
|
||
|
|
extractNutrition(input: string | Buffer): Promise<NutritionData | null>;
|
||
|
|
parseRecipe(text: string): Promise<ParsedRecipe | null>;
|
||
|
|
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
|
||
|
|
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
|
||
|
|
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
|
||
|
|
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Delayed gratification — smart features come last
|
||
|
|
- Interface may need revision as we learn what each feature actually needs (acceptable — iterate)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-006: Docker Compose for Self-Hosted Deployment
|
||
|
|
|
||
|
|
**Decision**: Primary deployment target is Docker Compose on a single server or VPS.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Aligns with self-hosted preference
|
||
|
|
- Simple to operate: `docker compose up -d` starts everything
|
||
|
|
- Easy backup: MongoDB volume + Keycloak DB volume
|
||
|
|
- Can scale vertically on a single machine for household-sized workloads
|
||
|
|
|
||
|
|
**Services in Docker Compose**:
|
||
|
|
|
||
|
|
```
|
||
|
|
services:
|
||
|
|
mongodb # Data store
|
||
|
|
keycloak # Auth
|
||
|
|
api # NestJS backend
|
||
|
|
web # Next.js frontend
|
||
|
|
mongo-express # Dev-only: DB admin UI
|
||
|
|
```
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- No horizontal scaling (acceptable for household app)
|
||
|
|
- Single point of failure (mitigated by container restart policies)
|
||
|
|
- Need manual backup strategy (cron + mongodump)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-007: React Native for Future Mobile App
|
||
|
|
|
||
|
|
**Decision**: When mobile is needed, build with React Native to share code with the Next.js web frontend.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Shares TypeScript types and validation from `packages/shared`
|
||
|
|
- React component patterns and hooks can be adapted (not 1:1, but concepts transfer)
|
||
|
|
- Single team can maintain web + mobile with same language
|
||
|
|
- Large ecosystem, good Android support
|
||
|
|
|
||
|
|
**Preparation (done now)**:
|
||
|
|
|
||
|
|
- All business logic and types live in `packages/shared`, not in `packages/web`
|
||
|
|
- API is the single source of truth — web is a thin UI layer
|
||
|
|
- No server-side rendering dependencies in shared code
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- React Native doesn't share actual UI components with Next.js (different render targets)
|
||
|
|
- May need a `packages/mobile` that depends on `packages/shared`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-008: Denormalized Nutrition on Recipes
|
||
|
|
|
||
|
|
**Decision**: When a recipe is saved, compute `totalNutrition` and `perServingNutrition` from ingredients and store them directly on the recipe document.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- Avoids expensive aggregation lookups on every recipe read
|
||
|
|
- Recipe pages load fast — nutrition data is pre-computed
|
||
|
|
- Recipes are read far more often than edited
|
||
|
|
|
||
|
|
**Recalculation triggers**:
|
||
|
|
|
||
|
|
- Recipe ingredient list is edited → recalculate
|
||
|
|
- A product's nutrition data is updated → background job recalculates affected recipes
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- Data can become stale if a product is updated but recipe recalc fails (mitigated by eventual consistency via background job)
|
||
|
|
- Slight write amplification on product nutrition updates
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ADR-009: Fastify + Awilix over NestJS
|
||
|
|
|
||
|
|
**Decision**: Replace NestJS with Fastify 5 + Awilix (DI) + fastify-plugin architecture. Replace Jest with Vitest.
|
||
|
|
|
||
|
|
**Rationale**:
|
||
|
|
|
||
|
|
- **ESM-native**: NestJS is locked to CommonJS. Fastify 5, Awilix 13, and Vitest 4 are all ESM-first, aligning with the Node.js ecosystem direction.
|
||
|
|
- **No decorators**: NestJS relies on TypeScript experimental decorators and `emitDecoratorMetadata`, which are non-standard and incompatible with `verbatimModuleSyntax`. Fastify + Awilix use plain functions and constructor injection.
|
||
|
|
- **Performance**: Fastify is consistently the fastest Node.js HTTP framework. No reflection overhead.
|
||
|
|
- **Simpler mental model**: Fastify's plugin system is composable and explicit. Dependencies are declared, not magically resolved via decorators.
|
||
|
|
- **Better testing**: Fastify's `app.inject()` tests routes without HTTP overhead. Vitest is faster than Jest and natively supports ESM.
|
||
|
|
- **Lighter dependency tree**: NestJS pulls in 40+ packages. Fastify core is 3 packages.
|
||
|
|
|
||
|
|
**Migration pattern**:
|
||
|
|
|
||
|
|
```
|
||
|
|
NestJS → Fastify + Awilix
|
||
|
|
@Module → fp() plugin (fastify-plugin)
|
||
|
|
@Controller → Route definitions inside plugin
|
||
|
|
@Injectable → Plain class + Awilix registration
|
||
|
|
@InjectModel → Import Mongoose model directly
|
||
|
|
class-validator → Zod schemas (fastify-type-provider-zod)
|
||
|
|
@UseGuards → onRequest/preHandler hooks
|
||
|
|
Jest → Vitest
|
||
|
|
```
|
||
|
|
|
||
|
|
**Stack versions** (as of migration):
|
||
|
|
|
||
|
|
- Fastify 5.8, Awilix 13, @fastify/awilix 8.2
|
||
|
|
- fastify-type-provider-zod 6.1 (Zod v4 support)
|
||
|
|
- jose 6.2 (ESM-only JWT, replaces passport-jwt)
|
||
|
|
- Vitest 4.1, TypeScript 6.0, Mongoose 9.3
|
||
|
|
|
||
|
|
**Trade-offs**:
|
||
|
|
|
||
|
|
- NestJS has more opinionated structure — new developers may need to learn Fastify's plugin model
|
||
|
|
- No built-in CLI scaffolding (acceptable — our module structure is documented)
|
||
|
|
- Awilix DI is less "magical" than NestJS — requires explicit registration (this is actually a benefit)
|