MeshiTrack/docs/architecture.md

8.7 KiB

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:

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)