5. Bringing It Home
View SourceWith our Catalog
and ShoppingCart
contexts, we're seeing first-hand how our well-considered modules and function names are yielding clear and maintainable code. Our last order of business is to allow the user to initiate the checkout process. We won't go as far as integrating payment processing or order fulfillment, but we'll get you started in that direction. This will be a great opportunity to put what we have learned so far in practice.
Like before, we need to decide where code for completing an order should live. Is it part of the catalog? Clearly not, but what about the shopping cart? Shopping carts are related to orders – after all, the user has to add items in order to purchase any products – but should the order checkout process be grouped here?
If we stop and consider the order process, we'll see that orders involve related, but distinctly different data from the cart contents. Also, business rules around the checkout process are much different than carting. For example, we may allow a user to add a back-ordered item to their cart, but we could not allow an order with no inventory to be completed. Additionally, we need to capture point-in-time product information when an order is completed, such as the price of the items at payment transaction time. This is essential because a product price may change in the future, but the line items in our order must always record and display what we charged at time of purchase. For these reasons, we can start to see that ordering can stand on its own with its own data concerns and business rules.
Naming wise, Orders
clearly defines our context, so let's get started by again taking advantage of the context generators. Note that the user
scope generated by mix phx.gen.auth
is marked as default scope (in your config/config.exs
), therefore we don't need to specify it in our command. There can be different scopes in an application, in which case the --scope
option can be used when running the generators. Run the following command in your console:
$ mix phx.gen.context Orders Order orders total_price:decimal
* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20250209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
We generated an Orders
context. The order is automatically scoped to the current user and added a total_price
column. With our starting point in place, let's open up the newly created migration in priv/repo/migrations/*_create_orders.exs
and make the following changes:
def change do
create table(:orders) do
- add :total_price, :decimal
+ add :total_price, :decimal, precision: 15, scale: 6, null: false
add :user_id, references(:user, type: :id, on_delete: :delete_all)
timestamps()
end
end
Like we did previously, we gave appropriate precision and scale options for our decimal column which will allow us to store currency without precision loss. We also added a not-null constraint to enforce all orders to have a price.
The orders table alone doesn't hold much information, but we know we'll need to store point-in-time product price information of all the items in the order. For that, we'll add an additional struct for this context named LineItem
. Line items will capture the price of the product at payment transaction time. Please run the following command:
$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20250209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
We used the phx.gen.context
command to generate the LineItem
Ecto schema and inject supporting functions into our orders context. Like before, let's modify the migration in priv/repo/migrations/*_create_order_line_items.exs
and make the following decimal field changes:
def change do
create table(:order_line_items) do
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps()
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
end
With our migration in place, let's wire up our orders and line items associations in lib/hello/orders/order.ex
:
schema "orders" do
field :total_price, :decimal
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :line_items, Hello.Orders.LineItem
+ has_many :products, through: [:line_items, :product]
timestamps()
end
We used has_many :line_items
to associate orders and line items, just like we've seen before. Next, we used the :through
feature of has_many
, which allows us to instruct ecto how to associate resources across another relationship. In this case, we can associate products of an order by finding all products through associated line items. Next, let's wire up the association in the other direction in lib/hello/orders/line_item.ex
:
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
- field :order_id, :id
- field :product_id, :id
+ belongs_to :order, Hello.Orders.Order
+ belongs_to :product, Hello.Catalog.Product
timestamps()
end
We used belongs_to
to associate line items to orders and products. With our associations in place, we can start integrating the web interface into our order process. Open up your router lib/hello_web/router.ex
and add the following line:
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
+ resources "/orders", OrderController, only: [:create, :show]
end
We wired up create
and show
routes for our generated OrderController
, since these are the only actions we need at the moment. With our routes in place, we can now migrate up:
$ mix ecto.migrate
17:14:37.715 [info] == Running 20250209214612 Hello.Repo.Migrations.CreateOrders.change/0 forward
17:14:37.720 [info] create table orders
17:14:37.755 [info] == Migrated 20250209214612 in 0.0s
17:14:37.784 [info] == Running 20250209215050 Hello.Repo.Migrations.CreateOrderLineItems.change/0 forward
17:14:37.785 [info] create table order_line_items
17:14:37.795 [info] create index order_line_items_order_id_index
17:14:37.796 [info] create index order_line_items_product_id_index
17:14:37.798 [info] == Migrated 20250209215050 in 0.0s
Before we render information about our orders, we need to ensure our order data is fully populated and can be looked up by a current user. Open up your orders context in lib/hello/orders.ex
and adjust your get_order!/2
to include a preload:
def get_order!(%Scope{} = scope, id) do
- Repo.get_by!(Order, id: id, user_id: scope.user.id)
+ Order
+ |> Repo.get_by!(id: id, user_id: scope.user.id)
+ |> Repo.preload([line_items: [:product]])
end
To complete an order, our cart page can issue a POST to the OrderController.create
action, but we need to implement the operations and logic to actually complete an order. Like before, we'll start at the web interface. Create a new file at lib/hello_web/controllers/order_controller.ex
and key this in:
defmodule HelloWeb.OrderController do
use HelloWeb, :controller
alias Hello.Orders
def create(conn, _) do
case Orders.complete_order(conn.assigns.current_scope, conn.assigns.cart) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created successfully.")
|> redirect(to: ~p"/orders/#{order}")
{:error, _reason} ->
conn
|> put_flash(:error, "There was an error processing your order")
|> redirect(to: ~p"/cart")
end
end
end
We wrote the create
action to call an as-yet-implemented Orders.complete_order/2
function. Our code is technically "creating" an order, but it's important to step back and consider the naming of your interfaces. The act of completing an order is extremely important in our system. Money changes hands in a transaction, physical goods could be automatically shipped, etc. Such an operation deserves a better, more obvious function name, such as complete_order
. If the order is completed successfully we redirect to the show page, otherwise a flash error is shown as we redirect back to the cart page.
Here is also a good opportunity to highlight that contexts can naturally work with data defined by other contexts too. This will be especially common with data that is used throughout the application, such as the cart here (but it can also be the current user or the current project, and so forth, depending on your project).
Now we can implement our Orders.complete_order/2
function. To complete an order, our job will require a few operations:
- A new order record must be persisted with the total price of the order
- All items in the cart must be transformed into new order line items records with quantity and point-in-time product price information
- After successful order insert (and eventual payment), items must be pruned from the cart
From our requirements alone, we can start to see why a generic create_order
function doesn't cut it. Let's implement this new function in lib/hello/orders.ex
:
alias Hello.Orders.LineItem
alias Hello.ShoppingCart
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
true = cart.user_id == scope.user.id
line_items =
Enum.map(cart.items, fn item ->
%{product_id: item.product_id, price: item.product.price, quantity: item.quantity}
end)
order =
Ecto.Changeset.change(%Order{},
user_id: scope.user.id,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
)
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, order)
|> Ecto.Multi.run(:prune_cart, fn _repo, _changes ->
ShoppingCart.prune_cart_items(scope, cart)
end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} ->
broadcast(scope, {:created, order})
{:ok, order}
{:error, name, value, _changes_so_far} ->
{:error, {name, value}}
end
end
We started by mapping the %ShoppingCart.CartItem{}
's in our shopping cart into a map of order line items structs. The job of the order line item record is to capture the price of the product at payment transaction time, so we reference the product's price here. Next, we create a bare order changeset with Ecto.Changeset.change/2
and associate our user UUID, set our total price calculation, and place our order line items in the changeset. With a fresh order changeset ready to be inserted, we can again make use of Ecto.Multi
to execute our operations in a database transaction. We start by inserting the order, followed by a run
operation. The Ecto.Multi.run/3
function allows us to run any code in the function which must either succeed with {:ok, result}
or error, which halts and rolls back the transaction. Here, we simply call into our shopping cart context and ask it to prune all items in a cart. Running the transaction will execute the multi as before and we return the result to the caller.
To close out our order completion, we need to implement the ShoppingCart.prune_cart_items/1
function in lib/hello/shopping_cart.ex
:
def prune_cart_items(%Scope{} = scope, %Cart{} = cart) do
{_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
{:ok, get_cart(scope)}
end
Our new function accepts the cart struct and issues a Repo.delete_all
which accepts a query of all items for the provided cart. We return a success result by simply reloading the pruned cart to the caller. With our context complete, we now need to show the user their completed order. Head back to your order controller and add the show/2
action:
def show(conn, %{"id" => id}) do
order = Orders.get_order!(conn.assigns.current_scope, id)
render(conn, :show, order: order)
end
We added the show action to pass our conn.assigns.current_scope
to get_order!
which authorizes orders to be viewable only by the owner of the order. Next, we can implement the view and template. Create a new view file at lib/hello_web/controllers/order_html.ex
with the following content:
defmodule HelloWeb.OrderHTML do
use HelloWeb, :html
embed_templates "order_html/*"
end
Next we can create the template at lib/hello_web/controllers/order_html/show.html.heex
:
<.header>
Thank you for your order!
<:subtitle>
<strong>Email: </strong>{@current_scope.user.email}
</:subtitle>
</.header>
<.table id="items" rows={@order.line_items}>
<:col :let={item} label="Title">{item.product.title}</:col>
<:col :let={item} label="Quantity">{item.quantity}</:col>
<:col :let={item} label="Price">
{HelloWeb.CartHTML.currency_to_str(item.price)}
</:col>
</.table>
<strong>Total price:</strong>
{HelloWeb.CartHTML.currency_to_str(@order.total_price)}
<.button navigate={~p"/products"}>Back to products</.button>
To show our completed order, we displayed the order's user, followed by the line item listing with product title, quantity, and the price we "transacted" when completing the order, along with the total price.
Our last addition will be to add the "complete order" button to our cart page to allow completing an order. Add the following button to the <.header> of the cart show template in lib/hello_web/controllers/cart_html/show.html.heex
:
<.header>
My Cart
+ <:actions>
+ <.button href={~p"/orders"} method="post">
+ Complete order
+ </.button>
+ </:actions>
</.header>
We added a link with method="post"
to send a POST request to our OrderController.create
action. If we head back to our cart page at http://localhost:4000/cart
and complete an order, we'll be greeted by our rendered template:
Thank you for your order!
User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d
Title Quantity Price
Metaprogramming Elixir 2 $15.00
Total price: $30.00
We haven't added payments, but we can already see how our ShoppingCart
and Orders
context splitting is driving us towards a maintainable solution. With our cart items separated from our order line items, we are well equipped in the future to add payment transactions, cart price detection, and more.
Great work!