Skip to Content
Course content

249: Building a Recipe Sharing Platform API

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times with developers moving from small scripts to full-blown APIs. You build a feature, it works perfectly in your local environment with five test recipes, and then you deploy it. Suddenly, the production logs are screaming, and the API response time climbs from 100ms to 3 seconds. Usually, the culprit is exactly what I'm about to show you.

# app/controllers/api/v1/recipes_controller.rb
module Api
  module V1
    class RecipesController < ApplicationController
      def index
        @recipes = Recipe.all
        
        # We want to return each recipe and its list of ingredients
        render json: @recipes.map { |recipe| 
          { 
            title: recipe.title, 
            ingredients: recipe.ingredients.map(&:name) 
          } 
        }
      end
    end
  end
end

The Log-Spamming N+1 Query

At first glance, this code is clean. It does exactly what the requirements ask: it fetches recipes and lists their ingredients. But if you look at your server logs while hitting this endpoint, you'll see something terrifying. You'll see one query to fetch all recipes, followed by a separate query for every single recipe to fetch its ingredients.

If you have 100 recipes, Ruby is making 101 database calls. We call this the N+1 problem. The "1" is the initial fetch for the recipes, and the "N" is the number of recipes that trigger additional queries. In a production environment with thousands of recipes, this will effectively DDOS your own database.

Eager Loading the Ingredients

The fix is a simple change to how we fetch the initial collection. We need to tell ActiveRecord to grab all the ingredients for all the recipes in one go, rather than waiting until the loop hits them. We do this using .includes.

# app/controllers/api/v1/recipes_controller.rb
module Api
  module V1
    class RecipesController < ApplicationController
      def index
        # This is the magic line. It tells Rails to "eager load" the ingredients.
        @recipes = Recipe.includes(:ingredients).all
        
        render json: @recipes.map { |recipe| 
          { 
            title: recipe.title, 
            ingredients: recipe.ingredients.map(&:name) 
          } 
        }
      end
    end
  end
end

By adding .includes(:ingredients), Rails changes its strategy. Instead of 101 queries, it now performs two: one to get all the recipes, and one massive query to get all ingredients associated with those specific recipe IDs. It then maps them together in memory. I personally always check my logs during development using a gem like bullet, which will actually throw a notification at you the moment it detects an N+1 query. It's a lifesaver because it's so easy to forget this when you're focused on the JSON structure rather than the database layer.




📋 Practical Task

Implementing Dietary Restriction Filtering for the Recipe API

Now that we've optimized the base index action, we need to make the API actually useful for users with dietary needs. Your task is to modify the RecipesController#index action to support an optional category query parameter (e.g., /api/v1/recipes?category=vegan).

  • Modify the index action to check if params[:category] is present.
  • If the parameter exists, filter the recipes to only return those belonging to that category. Assume the Recipe model has a category column.
  • Ensure that you maintain the .includes(:ingredients) optimization so that the filtered results don't trigger N+1 queries.
  • If no category is provided, the API should continue to return all recipes as it did before.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.