2. Your First Context
View SourceAn ecommerce platform has wide-reaching coupling across a codebase so it's important to think about writing well-defined modules. With that in mind, our goal is to build a product catalog API that handles creating, updating, and deleting the products available in our system. We'll start off with the basic features of showcasing our products, and we will add shopping cart features later. We'll see how starting with a solid foundation with isolated boundaries allows us to grow our application naturally as we add functionality.
Phoenix includes the mix phx.gen.html
, mix phx.gen.json
, mix phx.gen.live
, and mix phx.gen.context
generators that apply the ideas of isolating functionality in our applications into contexts. These generators are a great way to hit the ground running while Phoenix nudges you in the right direction to grow your application. Let's put these tools to use for our new product catalog context.
In order to run the context generators, we need to come up with a module name that groups the related functionality that we're building. In the Ecto guide, we saw how we can use Changesets and Repos to validate and persist user schemas, but we didn't integrate this with our application at large. In fact, we didn't think about where a "user" in our application should live at all. Let's take a step back and think about the different parts of our system. We know that we'll have products to showcase on pages for sale, along with descriptions, pricing, etc. Along with selling products, we know we'll need to support carting, order checkout, and so on. While the products being purchased are related to the cart and checkout processes, showcasing a product and managing the exhibition of our products is distinctly different than tracking what a user has placed in their cart or how an order is placed. A Catalog
context is a natural place for the management of our product details and the showcasing of those products we have for sale.
Starting with generators
To jump-start our catalog context, we'll use mix phx.gen.html
which creates a context module that wraps up Ecto access for creating, updating, and deleting products, along with web files like controllers and templates for the web interface into our context. Run the following command at your project root:
$ mix phx.gen.html Catalog Product products title:string \
description:string price:decimal views:integer
* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20250201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex
Add the resource to your browser scope in lib/hello_web/router.ex:
resources "/products", ProductController
Remember to update your repository by running migrations:
$ mix ecto.migrate
Phoenix generated the web files as expected in lib/hello_web/
. We can also see our context files were generated inside a lib/hello/catalog.ex
file and our product schema in the directory of the same name. Note the difference between lib/hello
and lib/hello_web
. We have a Catalog
module to serve as the public API for product catalog functionality, as well as a Catalog.Product
struct, which is an Ecto schema for casting and validating product data. Phoenix also provided web and context tests for us, it also included test helpers for creating entities via the Hello.Catalog
context, which we'll look at later. For now, let's follow the instructions and add the route according to the console instructions, in lib/hello_web/router.ex
:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
+ resources "/products", ProductController
end
With the new route in place, Phoenix reminds us to update our repo by running mix ecto.migrate
, but first we need to make a few tweaks to the generated migration in priv/repo/migrations/*_create_products.exs
:
def change do
create table(:products) do
add :title, :string
add :description, :string
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
- add :views, :integer
+ add :views, :integer, default: 0, null: false
timestamps()
end
We modified our price column to a specific precision of 15, scale of 6, along with a not-null constraint. This ensures we store currency with proper precision for any mathematical operations we may perform. Next, we added a default value and not-null constraint to our views count. With our changes in place, we're ready to migrate up our database. Let's do that now:
$ mix ecto.migrate
14:09:02.260 [info] == Running 20250201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward
14:09:02.262 [info] create table products
14:09:02.273 [info] == Migrated 20250201185747 in 0.0s
Before we jump into the generated code, let's start the server with mix phx.server
and visit http://localhost:4000/products. Let's follow the "New Product" link and click the "Save" button without providing any input. When we submit the form, we can see all the validation errors inline with the inputs. Nice! Out of the box, the context generator included the schema fields in our form template and we can see our default validations for required inputs are in effect. Let's enter some example product data and resubmit the form:
Product created successfully.
Title: Metaprogramming Elixir
Description: Write Less Code, Get More Done (and Have Fun!)
Price: 15.000000
Views: 0
If we follow the "Back" link, we get a list of all products, which should contain the one we just created. Likewise, we can update this record or delete it. Now that we've seen how it works in the browser, it's time to take a look at the generated code.
Naming things is hard
When starting a web application, it may be hard to draw lines or name its different contexts, especially when the domain you are working with is not as well established as e-commerce.
If you're stuck when defining or naming a context, you can simply create a new context using the plural form of the resource you're creating. For example, a Products
context for managing products. You will find that, even in such cases, you will organically discover other resources that belong to the Products
context, such as categories or image galleries.
As your applications grows and the different parts of your system become clear, you can simply rename the context or move resources around. The beauty of Elixir modules is moving them around should be simply a matter of renaming the module names and their callers (and renaming the files for consistency).
Grokking generated code
That little mix phx.gen.html
command packed a surprising punch. We got a lot of functionality out-of-the-box for creating, updating, and deleting products in our catalog. This is far from a full-featured app, but remember, generators are first and foremost learning tools and a starting point for you to begin building real features. Code generation can't solve all your problems, but it will teach you the ins and outs of Phoenix and nudge you towards the proper mindset when designing your application.
Let's first check out the ProductController
that was generated in lib/hello_web/controllers/product_controller.ex
:
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
def index(conn, _params) do
products = Catalog.list_products()
render(conn, :index, products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, :show, product: product)
end
...
end
We've seen how controllers work in our controller guide, so the code probably isn't too surprising. What is worth noticing is how our controller calls into the Catalog
context. We can see that the index
action fetches a list of products with Catalog.list_products/0
, and how products are persisted in the create
action with Catalog.create_product/1
. We haven't yet looked at the catalog context, so we don't yet know how product fetching and creation is happening under the hood – but that's the point. Our Phoenix controller is the web interface into our greater application. It shouldn't be concerned with the details of how products are fetched from the database or persisted into storage. We only care about telling our application to perform some work for us. This is great because our business logic and storage details are decoupled from the web layer of our application. If we move to a full-text storage engine later for fetching products instead of a SQL query, our controller doesn't need to be changed. Likewise, we can reuse our context code from any other interface in our application, be it a channel, mix task, or long-running process importing CSV data.
In the case of our create
action, when we successfully create a product, we use Phoenix.Controller.put_flash/3
to show a success message, and then we redirect to the router's product show page. Conversely, if Catalog.create_product/1
fails, we render our "new.html"
template and pass along the Ecto changeset for the template to lift error messages from.
Next, let's dig deeper and check out our Catalog
context in lib/hello/catalog.ex
:
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@doc """
Returns the list of products.
## Examples
iex> list_products()
[%Product{}, ...]
"""
def list_products do
Repo.all(Product)
end
...
end
This module will be the public API for all product catalog functionality in our system. For example, in addition to product detail management, we may also handle product category classification and product variants for things like optional sizing, trims, etc. If we look at the list_products/0
function, we can see the private details of product fetching. And it's super simple. We have a call to Repo.all(Product)
. We saw how Ecto repo queries worked in the Ecto guide, so this call should look familiar. Our list_products
function is a generalized function name specifying the intent of our code – namely to list products. The details of that intent where we use our Repo to fetch the products from our PostgreSQL database is hidden from our callers. This is a common theme we'll see re-iterated as we use the Phoenix generators. Phoenix will push us to think about where we have different responsibilities in our application, and then to wrap up those different areas behind well-named modules and functions that make the intent of our code clear, while encapsulating the details.
Now we know how data is fetched, but how are products persisted? Let's take a look at the Catalog.create_product/1
function:
@doc """
Creates a product.
## Examples
iex> create_product(%{field: value})
{:ok, %Product{}}
iex> create_product(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_product(attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
There's more documentation than code here, but a couple of things are important to highlight. First, we can see again that our Ecto Repo is used under the hood for database access. You probably also noticed the call to Product.changeset/2
. We talked about changesets before, and now we see them in action in our context.
If we open up the Product
schema in lib/hello/catalog/product.ex
, it will look immediately familiar:
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :description, :string
field :price, :decimal
field :title, :string
field :views, :integer
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:title, :description, :price, :views])
|> validate_required([:title, :description, :price, :views])
end
end
This is just what we saw before when we ran mix phx.gen.schema
, except here we see a @doc false
above our changeset/2
function. This tells us that while this function is publicly callable, it's not part of the public context API. Callers that build changesets do so via the context API. For example, Catalog.create_product/1
calls into our Product.changeset/2
to build the changeset from user input. Callers, such as our controller actions, do not access Product.changeset/2
directly. All interaction with our product changesets is done through the public Catalog
context.
Adding Catalog functions
As we've seen, your context modules are dedicated modules that expose and group related functionality. Phoenix generates generic functions, such as list_products
and update_product
, but they only serve as a basis for you to grow your business logic and application from. Let's add one of the basic features of our catalog by tracking product page view count.
For any ecommerce system, the ability to track how many times a product page has been viewed is essential for marketing, suggestions, ranking, etc. While we could try to use the existing Catalog.update_product
function, along the lines of Catalog.update_product(product, %{views: product.views + 1})
, this would not only be prone to race conditions, but it would also require the caller to know too much about our Catalog system. To see why the race condition exists, let's walk through the possible execution of events:
Intuitively, you would assume the following events:
- User 1 loads the product page with count of 13
- User 1 saves the product page with count of 14
- User 2 loads the product page with count of 14
- User 2 saves the product page with count of 15
While in practice this would happen:
- User 1 loads the product page with count of 13
- User 2 loads the product page with count of 13
- User 1 saves the product page with count of 14
- User 2 saves the product page with count of 14
The race conditions would make this an unreliable way to update the existing table since multiple callers may be updating out of date view values. There's a better way.
Let's think of a function that describes what we want to accomplish. Here's how we would like to use it:
product = Catalog.inc_page_views(product)
That looks great. Our callers will have no confusion over what this function does, and we can wrap up the increment in an atomic operation to prevent race conditions.
Open up your catalog context (lib/hello/catalog.ex
), and add this new function:
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
We built a query for fetching the current product given its ID which we pass to Repo.update_all
. Ecto's Repo.update_all
allows us to perform batch updates against the database, and is perfect for atomically updating values, such as incrementing our views count. The result of the repo operation returns the number of updated records, along with the selected schema values specified by the select
option. When we receive the new product views, we use put_in(product.views, views)
to place the new view count within the product struct.
With our context function in place, let's make use of it in our product controller. Update your show
action in lib/hello_web/controllers/product_controller.ex
to call our new function:
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
We modified our show
action to pipe our fetched product into Catalog.inc_page_views/1
, which will return the updated product. Then we rendered our template just as before. Let's try it out. Refresh one of your product pages a few times and watch the view count increase.
We can also see our atomic update in action in the ecto debug logs:
[debug] QUERY OK source="products" db=0.5ms idle=834.5ms
UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views" [1, 1]
Good work!
As we've seen, designing with contexts gives you a solid foundation to grow your application from. Using discrete, well-defined APIs that expose the intent of your system allows you to write more maintainable applications with reusable code. Now that we know how to start extending our context API, lets explore handling relationships within a context.