# 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; parseRecipe(text: string): Promise; parseRecipeFromUrl(url: string): Promise; parseReceipt(image: Buffer): Promise; suggestMealPlan(context: MealPlanContext): Promise; parseNaturalLanguage(text: string): Promise; } ``` **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)