Recipes, Ingredients, and Normalization

Posted by Eli Cusic on March 13, 2021

Context: Naturally, any developer must do the recipe/ingredients application, so here is a first glance at mine. For a little background - I actually do have a good deal of experience working in restaurant kitchens and managing logistics professionally, so I wanted mine to do more in the way of data tracking and cost analysis. Ultimately, this is more of a supply management application. I include this because the ingredients will need to be associated with more than just recipes, and each category it is associated with will need its own specific quantity, which means I needed to be more intentional with the backend setup.

Models: Naturally, we have a User, and belonging to the user model we have Pantry, Recipe, and GroceryList models for now. Each of these models belonging to the user will have many Ingredients associated with them, and each of those ingredients will need specific quantities based on which model they’re associated with. For instance, a Pantry object may have a brown rice ingredient with a quantity of 30 (as in ounces or some other unit), and then there could be 2 Recipe objects with a brown rice ingredient - one needs a quantity of 4 and the other a quantity of 6.

As a supply management system, I want everything to be standardized. There should be one “brown rice” Ingredient, and it should have a set name, unit of measurement, and cost per unit so that we can keep consistent data and easily track expenses. I had considered implementing a Single Table Inheritance structure to account for these ingredients having a different quantity based on its association, but ultimately it would generate a bloated database with lots of duplicate information and be susceptible to anomolies and bugs with the ingredients data. So, we need a better design structure.

Database Design: Ultimately, what we need is a join table. However, in addition to the foreign keys of the Ingredient and the Recipe it belongs to, we need a quantity field since that is the only non-standardized field at the moment. What we end up having is a RecipeIngredients table with a quantity field and foreign keys of recipe_id and ingredient_id - and the same goes for PantryIngredients and GroceryListIngredients. This is the general table structure:

:ingredients_table
t.string :name
t.string :unit
t.float :cost_per_unit

:recipes_table
t.integer :user_id
t.string :name
t.integer :servings
t.text :instructions

:recipe_ingredients_table
t.integer :recipe_id
t.integer :ingredient_id
t.float :quantity

:pantries_table
t.integer :user_id

:pantry_ingredients_table
t.integer :pantry_id
t.integer :ingredient_id
t.float :quantity

:grocery_lists_table
t.integer :user_id

:grocery_list_ingredients_table
t.integer :grocery_list_id
t.integer :ingredient_id
t.float :quantity

This table structure is an example of Normalization, which allows there to be a standardized Ingredients table and any attributes that vary will be split out into a different table(s). This means less duplication of data and the standardization should result in a decrease of anomalies and bugs. The tradeoff is that queries require hitting two or more tables rather than a single one, which can slow the processing down, but we’ll address that at a later point. First, let’s finish setting up the associations. The associations are mostly straight forward at this point but with a slight wrinkle. For the sake of time I’ll leave out Pantry and GroceryList, but they follow the same format as Recipe.

class RecipeIngredient
  belongs_to :ingredient
	belongs_to :recipe
	
class Ingredient
	
class Recipe
  belongs_to :user
  has_many :recipe_ingredients, 
  has_many :ingredients, through: :recipe_ingredients
  alias_attribute :ingredients_join_info, :recipe_ingredients
  include IngredientProcessor

Like I said, mostly straight forward. The RecipeIngredient belongs to a Recipe and an Ingredient, and then a Recipe has many Ingredients through its RecipeIngredient. I’ve decided to leave out the has_manys in the Ingredient since I don’t need a bidirectional association at the moment. The wrinkle is that I’ve aliased out the join table models. I aliased them out with the same name for Pantry and GroceryList as well, which allows me to attach the IngredientProcessor module on each of them. This module will handle putting together the composite objects to send in a standardized and readable format.

module IngredientProcessor
    def prepare_to_send
        self.attributes.merge!(ingredients: self.ingredients_with_quantities)
    end

    def ingredients_with_quantities
        joins = self.ingredients_join_info
        ingredients = self.ingredients.map.with_index do |ing, i| 
            ing.id == joins[i].ingredient_id ? ing.attributes.merge!(quantity: joins[i].quantity) : "Handle error"
        end
    end
end

And here we see the downside of Normalization - extra queries. However, by choosing to break ingredients and joins into their own arrays and process them from there I avoid having to use N + 1 queries. This saves a bit of processing compared to mapping over all the joined ingredients and calling their associated ingredient one by one. It does admittedly slow the process down, though, but it keeps my supply data in a standardized format.

One solution on speed is that I could skip the assembly step on the backend and send the data (currently using React) up to the frontend to be managed by JavaScript. My feeling is that it’s best to keep the construction and deconstruction of these objects all on the backend, but that’s just an instinct.

I’d love to hear other peoples’ opinion on this setup, so if you have any input on structuring this sort of system in a more efficient and intuitive way please don’t hesitate to reach out!