Thesis
Where the intelligence lives
A deliberate split: deterministic logic owns the guarantees, generative AI owns open-ended suggestion.
Roughly a third of food produced worldwide is never eaten, and much of that loss happens in home kitchens. Fridge2Meal starts from the inventory a household already owns and works forward: what can be cooked tonight from what is already there, and what is closest to spoiling.
The fastest path to that product is to forward the pantry contents to a language model and render whatever returns. It demos beautifully. It also produces different answers to identical questions, carries a per-request cost that scales with usage, and hands availability to an external provider.
I split the system instead. Every result the user depends on — which recipes match, how well they match, what is missing, what is about to spoil, what belongs on the shopping list — is computed by code and SQL I designed. The generative layer sits alongside that core, behind a provider-agnostic abstraction, clearly labeled in the interface, and independently toggleable. When enabled it does what generative models are genuinely good at: proposing variations and surfacing substitutions outside the fixed corpus.
That split is what makes the rest of this document interesting. It put real problems on the table: ingredient identity resolution, weighted scoring, many-to-many relational modeling, expiration-aware ranking, and the operational work of making five services start in the right order on a machine that has never seen the project before.
System
Layered architecture
Five tiers with strict boundaries. Each layer has one responsibility and no reach into the layer below it.
I designed the platform as a modular layered system so that the interface, the application logic, the data, the identity provider and the optional AI enhancement could each evolve independently. The rule I enforced throughout: the service layer never writes raw SQL, the repository layer never contains business logic, and the controller never touches data directly. That separation is what keeps a feature change from becoming a systemic refactor.
| Layer | Technology | Responsibility I owned |
|---|---|---|
| Presentation | React · protected routing · context providers | API contract design, auth-gated route structure, state boundaries between pantry, notification and session context |
| Application | Node.js · Express · REST | Routes, controllers, middleware and services across 14 domains; centralized error handling; upload and backup middleware |
| Domain logic | Custom Node.js services | Ingredient normalization, weighted compatibility ranking, expiration analysis, substitution resolution |
| Data | PostgreSQL | Schema design, normalization, junction modeling, indexing, seed and migration workflow |
| Identity | Keycloak · OAuth 2.0 / OIDC | Realm configuration, PKCE flow, JWT validation middleware, role-based access control |
| Enhancement | Groq API (optional) | Provider-agnostic service abstraction, rate limiting, isolation from the core path |
| Runtime | Docker Compose | Service definitions, dependency ordering, volume persistence, startup / reset / backup scripting |
Because the AI provider lives behind a dedicated service class, it can be swapped, upgraded or removed without any change to controllers, business logic or the user interface. That abstraction keeps the enhancement layer portable across providers, and it proved its worth the moment the feature needed to be demonstrable with the key unset.
Engine
The matching engine
The architectural centerpiece: turning messy human ingredient input into ranked, explainable recipe results.
People do not enter ingredients the way a database stores them. They type "grilled chicken," "rotisserie chicken" and "chicken breast" to mean the same thing. They pluralize inconsistently, abbreviate, and attach preparation state to the noun. A naive keyword match fails on all of it.
So the first thing the engine does is stop treating ingredient text as text.
Normalization
Input is lowercased, stripped of punctuation and preparation descriptors, whitespace-trimmed, and resolved against an alias table in PostgreSQL that maps known variants onto a single canonical ingredient identity. This is the step that converts a keyword search into an ingredient-intelligence system.
Candidate retrieval
Normalized ingredient identifiers are queried against the recipe_ingredients junction table, which resolves the many-to-many relationship between recipes and ingredients and carries per-row quantity, unit, optional status and importance level.
Weighted scoring
Each candidate recipe is scored on seven signals rather than raw match count. The formula weighs the ratio of matched to required ingredients against a penalty for missing ones, so a ten-ingredient recipe with nine matches correctly outranks a four-ingredient recipe with three.
Substitution & severity resolution
Missing items are classified as optional or critical, and the substitution table is consulted for viable replacements. The user learns not just what is missing but whether it actually blocks the dish.
Explainable output
Every result returns a compatibility score, match percentage, matched list, optional-missing list, critical-missing list and a ranking rationale — all computed deterministically in the backend, never generated.
The seven ranking signals
Signal 04 is the one I am most attached to, because it is where the data model earns its keep. Pantry rows carry expiration dates; the backend derives a freshness state — fresh, expiring soon, expired, unknown — and the ranking engine reads that state directly. The product's core promise, wasting less food, is implemented as a term in a scoring function rather than as a marketing claim.
Data
Relational design
16 normalized tables. Every personalized record keyed to an authenticated identity.
I designed the schema around one hard relationship and one hard constraint. The hard relationship is many-to-many: recipes contain many ingredients, ingredients appear in many recipes, and the join carries meaning of its own — quantity, unit, optional flag, importance. That belongs in a junction table with attributes, not in a denormalized array.
The hard constraint is isolation: pantry contents, saved recipes, meal plans, shopping lists, notifications, uploads and backups are all keyed to the authenticated user, so a query can never surface another account's data.
| Cluster | Tables | What the design enables |
|---|---|---|
| Identity | users · user_sessions | Keycloak subject linkage and session state as the anchor for every personalized row |
| Ingredient intelligence | ingredients · ingredient_aliases · ingredient_substitutes | Canonical identity, variant resolution and substitution reach — the backbone of normalization |
| Recipe core | recipes · recipe_ingredients | Attributed many-to-many join carrying quantity, unit, optional status and importance level |
| Pantry | user_ingredients · user_custom_ingredients | Persistent inventory with quantity, unit, category and expiration date driving freshness state |
| Workflow | shopping_list · meal_plans · user_saved_recipes | Missing-ingredient automation, scheduled meals and favorites as first-class records |
| Engagement | notifications · user_search_history | Expiration alerting, read-state management and behavioral signal capture |
| Media & AI | user_recipe_photos · ai_generated_meals | Uploaded imagery and optional AI output stored without polluting the canonical recipe table |
The database package ships with schema files, seed data, migration files and an initialization script executed by Docker Compose at first startup, so a clean checkout produces a populated, queryable database with no manual steps. Migrations are regression-safe, which mattered more than expected once the schema started moving weekly.
One design decision worth flagging: AI-generated meals are stored in their own table rather than written into recipes. Keeping generated content out of the canonical corpus means the deterministic matching engine can never be contaminated by unvalidated model output, and generated meals can be promoted deliberately rather than by accident.
Security
Identity and defensive posture
Authentication delegated to a dedicated identity provider. Authorization enforced at the API boundary.
The tempting shortcut here is hand-rolled auth: a users table, a password column, a session flag in the frontend. Identity belongs with a dedicated provider instead, so authentication is delegated to Keycloak and the backend's job is narrowed to one thing it can do reliably — verifying a token before performing a protected action.
| Control | Implementation |
|---|---|
| Identity provider | Keycloak realm configured and exported as version-controlled configuration; OAuth 2.0 and OpenID Connect flows for login, registration and token issuance |
| Authorization-code + PKCE | Public-client flow with proof key exchange, so no client secret is ever shipped to the browser |
| Token validation | Express middleware validating Keycloak-issued JWTs on every protected route before any controller executes |
| Access control | Role-based policy at the API layer, mirrored by protected routing in the React client — the client hides, the server enforces |
| Session lifecycle | Token refresh, logout, password change, security-question support and password-reset flow captured locally through MailHog |
| Transport & headers | Helmet middleware for HTTP security headers; CORS restricted to an explicit origin allowlist |
| Abuse resistance | Rate limiting on the AI endpoints, the one surface with real per-request cost and third-party exposure |
| Input handling | Backend validation on all mutating routes; malformed and blank input rejected before reaching the data layer |
| Secrets | Environment-variable-based credential management; no keys, connection strings or realm secrets committed to the repository |
| File uploads | Isolated upload infrastructure for avatars and recipe photos with backend validation and controlled serving, kept off the application path |
Authentication turned out to be the highest-risk integration in the system, because nearly every feature is personalized — a failure there does not degrade the product, it locks the user out of their pantry, plans, lists and settings. Getting the frontend login flow, backend validation middleware, protected routing, refresh behavior, logout semantics and profile synchronization to agree with one another was the single hardest coordination problem I solved on this project. Verified end to end: valid login succeeds, invalid credentials return a clear error, and direct navigation to an authenticated route without a token redirects to login.
Platform
Runtime and DevOps
One command. Five services. Identical behavior on a machine that has never seen the project.
As platform administrator I owned the question that decides whether a project is real: can someone else run it? The answer had to be yes without a page of setup instructions, so I containerized the entire stack and orchestrated it with Docker Compose.
What this actually cost
Compose makes multi-service orchestration sound trivial. It is not. Getting five containers to cooperate meant solving container networking, service dependency ordering, environment-variable propagation, port mapping, startup timing races between the API and a database still initializing, first-run schema execution, and Keycloak realm import that had to complete before the backend would accept a single request.
I wrote Windows and shell scripts for start, stop, reset and backup-folder configuration so that the whole environment is reproducible and disposable — a bad state is fixed by resetting, not by debugging someone's local machine.
Why local, and why that was the right call
The original plan allowed for cloud hosting. I deprioritized it deliberately. Cloud deployment would have consumed the remaining schedule on infrastructure configuration, cost management, domain setup, managed database provisioning, object storage and monitoring — none of which demonstrates the engineering the project exists to demonstrate. Containerization delivers the same portability and environment-consistency benefits at zero recurring cost, and because the architecture is already service-isolated and volume-backed, migration to a cloud provider is a deployment exercise rather than a rewrite.
Durability
Backup and restore
Built past requirement, because a system that holds user data and cannot give it back is not finished.
Data protection was not a required deliverable. I built it anyway, and it became one of the more technically demanding subsystems in the application — export has to gather user-scoped database rows and uploaded files into a single archive, and restore has to reintroduce that data without corrupting live state.
- On-demand export of user-scoped records and uploaded media into a portable ZIP archive
- Restore from local disk or from a user-uploaded archive, exposed through the Settings interface
- Scheduled local backups with retained history and file listing
- Configurable backup folder, syncable to a cloud-storage directory if the user has one
- Separate user data export, so the account owner can leave with their own data
- Docker volume persistence underneath, so database and uploads survive container recreation
Restore is the dangerous half. It is the one operation in the product that can destroy user data if it is wrong, which is why it received disproportionate testing relative to its size.
Build
The running system
Captured from the running application in the local container environment.
Verification
What was tested, and what was not
Results are only useful alongside their limits.
Verification was conducted as black-box end-to-end testing against the running container stack, which meant every case was also an integration test: an authentication case exercised the frontend, Keycloak, session handling, protected routing and backend token validation simultaneously. Cases were re-executed across multiple dates after builds shipped, giving the suite a regression character.
Negative paths were tested deliberately — invalid credentials, blank input, unauthorized route access, malformed data. Notable results: duplicate pantry entries were prevented by updating the existing quantity rather than inserting a second row; blank ingredient search returned validation feedback rather than an empty query; and searches with a single ingredient and with more than twenty ingredients both returned ranked results within an acceptable response window, which is the closest thing the suite has to a load signal.
Limits I will state plainly
This was manual testing by a single tester. It does not establish behavior under concurrent load, cross-browser and cross-device compatibility, penetration-level security assurance, sustained uptime, or recovery from deliberate database, identity-provider or AI-service outages. Automated unit, integration, API and end-to-end coverage is the first thing I would add before this system faced real users, and I would rather say so than let a 100% pass rate imply more than it earned.
Status
Honest build state
Scoped using the same three-state model the pantry uses: shipped, prototype-level, deferred.
| Capability | State | Note |
|---|---|---|
| Ingredient matching & ranking | Shipped | Normalization, weighted scoring, substitution and severity resolution operational |
| Relational data layer | Shipped | Schema, seeds, migrations and container-run initialization |
| Identity & access control | Shipped | Keycloak realm, PKCE flow, JWT middleware, protected routing |
| Containerized runtime | Shipped | Five services, persistent volumes, start / stop / reset scripting |
| Backup & restore | Shipped | Export, restore from disk or upload, scheduling, history |
| Pantry & expiration intelligence | Prototype | Fully functional; wants broader real-world usability validation |
| Shopping automation | Prototype | Recipe-to-list and list-to-pantry sync need further edge-case testing |
| Notifications & digest | Prototype | Accuracy depends on scheduled checks and production email delivery |
| Optional AI generation | Prototype | Deliberately non-essential; needs output validation and quota handling |
| Cloud deployment | Deferred | Architecture is migration-ready; deferred by schedule decision, not capability |
| Automated test coverage | Deferred | Highest-priority next investment |
| Observability & analytics | Deferred | Health checks and logging exist; metrics and tracing do not |
| Nutrition analysis | Deferred | Schema anticipates it; scoring engine has a slot for it |
| Native mobile | Deferred | Responsive web only in this build |