Subscribe to access all episodes. View plans →

#78: Phoenix Contexts

Published January 7, 2019

Phoenix 1.4

Elixir 1.7

View source on GitHub


In this episode we’ll look at Phoenix Contexts. How they work and how they guide developers toward creating more organized and maintainable code.

Released with Phoenix 1.3, Phoenix Contexts provide a “context” module as way to group related functionality together in a unified API. Contexts encourage you to think about the design of your application, making it easier to design an application that has functionality with clearly defined boundaries.

For example, if we were building a system to handle user accounts, we may have an “accounts” context module that would hold all the functionality related to user registration and session management. Any other modules that needed to implement this functionality, say a controller module, would do so via the single “accounts” context module.

But to really get an understanding of how Phoenix Contexts work let’s create one. Let’s start by creating a new Phoenix application that we’ll turn into a blog. In the process of building our blog, we’ll see how we can leverage Phoenix Contexts to keep our related “blog” functionality grouped together.

First off, we’ll create a new Phoenix application named “teacher” and let’s install the dependencies.

$ mix phx.new teacher
* creating teacher/config/config.exs
* creating teacher/config/dev.exs
* creating teacher/config/prod.exs
...

Then let’s move into our new “teacher” directory and we’ll create the database. Then we’ll start the server.

$ cd teacher/
$ mix ecto.create
The database for Teacher.Repo has been created
$ mix phx.server
...

And let’s go open our new application in the browser, just to make sure everything is working. Great - we see the default “Welcome to Phoenix!” page.

Now let’s create our first context. Phoenix gives us an HTML task that will create our context module along with our view, templates, and controller. We’ll use this for our blog.

Before we run our task we need to decide on a name for our context. Let’s call our context “blog”. We’ll want our blog to have posts and comments. Let’s start with posts. We’ll create a “Post” schema with the database table name being the plural “posts” and we’ll give our posts a title and a body.

When we run the task we see that our post_controller.ex was created as well as our templates, view, and schema. It also created our blog.ex context module.

$ mix phx.gen.html Blog Post posts title:string body:text
* creating lib/teacher_web/controllers/post_controller.ex
* creating lib/teacher_web/templates/post/edit.html.eex
* creating lib/teacher_web/templates/post/form.html.eex
* creating lib/teacher_web/templates/post/index.html.eex
* creating lib/teacher_web/templates/post/new.html.eex
* creating lib/teacher_web/templates/post/show.html.eex
* creating lib/teacher_web/views/post_view.ex
* creating test/teacher_web/controllers/post_controller_test.exs
* creating lib/teacher/blog/post.ex
* creating priv/repo/migrations/20190101232822_create_posts.exs
* creating lib/teacher/blog/blog.ex
* injecting lib/teacher/blog/blog.ex
* creating test/teacher/blog/blog_test.exs
* injecting test/teacher/blog/blog_test.exs

Add the resource to your browser scope in lib/teacher_web/router.ex:

    resources "/posts", PostController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

We need to update our router and run our migration, but before we do, let’s take a closer look at what was created for us.

Let’s open our post.ex module and we see that it’s our post schema that maps to the corresponding database table we created named “posts”.

Now let’s open our post_controller.ex and if you used Phoenix prior to the release of 1.3, you may expect there to be calls to the Repo module or the Post module, but here when we’re interacting with our “posts” we’re doing so through our Blog context module. For example to get our posts, we get them with the Blog.list_posts function. Our “blog” functionality is now contained in our Blog context module, which exposes a public API that contains all of the functionality to interact with posts.

And when we open our blog module we can see the different functions that Phoenix implements by default. There’s the list_posts function, which looks up all our posts from the database, get_post! which fetches a single post and create_post.

Now that we’ve had a look at the context Phoenix created for us let’s add our new resource to our router.ex.

lib/teacher_web/router.ex

...
scope "/", TeacherWeb do
  pipe_through :browser

  get "/", PageController, :index
  resources "/posts", PostController
end
...

Let’s also update our css from what’s generated with a new Phoenix application. I’ll also remove the default header from the app.html.eex template.

Now we can go to the command line and run our migration.

$ mix ecto.migrate
[info] == Running 20190101232822 Teacher.Repo.Migrations.CreatePosts.change/0 forward
[info] create table posts
[info] == Migrated 20190101232822 in 0.0s

We should be able to create some posts. Let’s start up our server and test it out.

$ mix phx.server
...

We’ll go to our new “/posts” route and create a new post. And everything is working.

We we’re able to create a working blog wth the logic grouped into a clear context with very little effort on our part. Now let’s implement the second part of our blog - comments. Since we have an existing “blog” context let’s go ahead and include our comments functionality in it.

Earlier when we created our posts, we used the phx.gen.html generator to create the context, schema, and the web files for our blog. To keep this simple, let’s use the existing post templates for our comments.

And since we don’t need any web files created we can use the phx.gen.context generator to create our comments. We’ll call mix phx.gen.context with “Blog” as the context and “Comment” the schema and “comments” for the table name. Our comments will need a body and let’s also associate a comment with a post, so we’ll add a “post_id” that references “posts”. When we run the generator, Phoenix prints a message asking us if we’re sure we want to include our comments in the existing context and not a context of its own.

As a general rule, if you’re unsure whether a resource belongs in any existing context, it’s usually better to create a new context for it. Since our comments are tied to our blog, grouping it in the existing “blog” context will work for our purposes. So let’s proceed and we see a new “comment” schema module was created as well as the associated “comments” migration was created. It also injected some functions into our “blog” context for interacting with comments.

$ mix phx.gen.context Blog Comment comments body:string post_id:references:posts
You are generating into an existing context.
The Teacher.Blog context currently has 6 functions and 2 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/teacher/blog/comment.ex
* creating priv/repo/migrations/{{timestamp}}_create_comments.exs
* injecting lib/teacher/blog/blog.ex
* injecting test/teacher/blog/blog_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Because we just defined a relationship between our comments and our posts, let’s open our our new comment.ex schema module and let’s add a belongs_to association to the schema.

lib/teacher/blog/comment.ex

...
schema "comments" do
  field :body, :string
  belongs_to :post, Teacher.Blog.Post

  timestamps()
end
...

Then let’s open our post.ex module and add a has_many association with our comments.

lib/teacher/blog/comment.ex

...
schema "posts" do
  field :body, :string
  field :title, :string
  has_many :comments, Teacher.Blog.Comment

  timestamps()
end
...

Let’s also go to the migration that was generated. And when a post is deleted, we want all of the associated comments to be deleted as well, so let’s update on_delete: to :delete_all - now when a post is deleted, all of its comments will be deleted too.

priv/repo/migrations/{timestamp}_create_comments.exs

defmodule Teacher.Repo.Migrations.CreateComments do
  use Ecto.Migration

  def change do
    create table(:comments) do
      add :body, :string
      add :post_id, references(:posts, on_delete: :delete_all)

      timestamps()
    end

    create index(:comments, [:post_id])
  end
end

Now that we’ve got our associations added and our migration updated, let’s go back to the command line and migrate our database:

$ mix ecto.migrate
[info] create table comments
[info] create index comments_post_id_index
[info] == Migrated in 0.0s

Alright, now let’s go to our blog.ex context module and we’ll find the create_comment function that was generated by Phoenix. The default functions that Phoenix added for interacting with comments are in the same pattern as what was added for posts. So if we scroll up we see that there’s a function to list comments and get a comment.

Let’s go back to the create_comment function and since our comments will be associated with a “post” let’s update it to account for that. We’ll update our function to accept the post we want to associate the comment with.

Let’s take our “post” and pipe it into Ecto.build_assoc this will return a comment struct that’s been associated to the post. We can take that struct and then pipe it into Comment.changeset with our attributes. We’ll also want to update our function @doc to reflect our changes.

lib/teacher/blog/blog.ex

...
@doc """
Creates a comment.

Examples

iex> create_comment(post, %{field: value})
  {:ok, %Comment{}}

  iex> create_comment(post, %{field: bad_value})
  {:error, %Ecto.Changeset{}}

"""
def create_comment(%Post{} = post, attrs \\ %{}) do
  post
  |> Ecto.build_assoc(:comments)
  |> Comment.changeset(attrs)
  |> Repo.insert()
end
...

Great, with our function updated to properly create our comments we’ll now want to create a CommentController. We’ll create a new module comment_controller.ex in controllers directory. Then we’ll define our module. Let’s give it a single function - create - that will accept the connection and let’s pattern match on the params to get the post’s ID and then the comment params - this will be what’s sent over from our comment form.

Now inside the function we’ll get our post using the Blog.get_post! function. Then let’s use a case statement to create our comment using our Blog.create_comment function that we just updated to associate a comment with the post.

Then we’ll pattern match on the OK tuple that’s returned if the comment was created successfully and the error tuple that’s returned if there’s an issue creating the comment.

If a comment is created let’s display a flash message letting the user know and then redirect them to the post. For an error we’ll just display a general message and direct them to the post path.

If this were a production application we’d probably want to use the changeset to grab the specific errors to display to the user, but we wont worry about that for the purposes of this demo. Then let’s alias our Blog module so we can use it without the prefix.

lib/teacher_web/controllers/comment_controller.ex

defmodule TeacherWeb.CommentController do
  use TeacherWeb, :controller

  alias Teacher.Blog

  def create(conn, %{"post_id" => post_id, "comment" => comment_params}) do
    post = Blog.get_post!(post_id)

    case Blog.create_comment(post, comment_params) do
      {:ok, _comment} ->
        conn
        |> put_flash(:info, "Comment created successfully.")
        |> redirect(to: Routes.post_path(conn, :show, post))
      {:error, _changeset} ->
        conn
        |> put_flash(:error, "Issue creating comment.")
        |> redirect(to: Routes.post_path(conn, :show, post))
    end
  end
end

Then let’s open our router.ex and we’ll nest our new CommentController in our “posts” resource. We’ll only want the “create” action since that’s all we’ve defined in our controller.

lib/teacher_web/router.ex

...
resources "/posts", PostController do
  resources "/comments", CommentController, only: [:create]
end
...

Now we can open our post’s show.html.eex template and I’ll go ahead and paste in our form to add a comment below our post. But let’s walk through it. We’ve defined a new form_forwith a @comment_changeset that we’ll create in a minute and we’re posting the form to the new comment path we just created. Our form only has one field, our comment’s body.

Template path: lib/teacher_web/templates/post/show.html.eex

...
<h3>Add a comment</h3>
<%= form_for @comment_changeset, Routes.post_comment_path(@conn, :create, @post), fn f -> %>
  <%= label f, :body %>
  <%= textarea f, :body %>
  <%= error_tag f, :body %>
  <div>
    <%= submit "Save" %>
  </div>
<% end %>
...

Now let’s open our post_controller.ex and in our “show” function, let’s go ahead and create our comment_changeset. We’ll call Blog.change_comment to get our comment changeset. We’ll then pass it into the assigns as comment_changeset. Then let’s alias Comment so we can use it here without the prefix.

lib/teacher_web/controllers/post_controller.ex

...
alias Teacher.Blog.{Comment, Post}
...
def show(conn, %{"id" => id}) do
  post = Blog.get_post!(id)
  comment_changeset = Blog.change_comment(%Comment{})
  render(conn, "show.html", post: post, comment_changeset: comment_changeset)
end
...

Great, let’s make sure our server is started and then go back to the browser.

$ mix phx.server
...

And we’ll go to our post and great, our comment form is on the page. Let’s go ahead and create a comment.

It says it’s created, but we’re not seeing it. This is because while we’re persisting it to the database, we’re not displaying it on the page. Let’s go back to our blog.ex context module and where we’re looking up our post, let’s also load all the comments for a post.

Because Ecto does not support lazy loading we’ll explicitly load them here, by calling Repo.preload passing in our post and then the association we want to load “comments”.

lib/teacher/blog/blog.ex

...
def get_post!(id) do
  Post
  |> Repo.get!(id)
  |> Repo.preload(:comments)
end
...

Now that we’re loading our comments, we can go back to our post’s show.html.eex template and below our post, let’s display all of the associated comments.

Template path: lib/teacher_web/templates/post/show.html.eex

...
<h3>Comments</h3>
<ul>
<%= for comment <- @post.comments do %>
  <li class="comment"><%= comment.body %></li>
<% end %>
</ul>
...

And if we go back to the browser, we see our comments are now being loaded and any new comments are then displayed. By building a simple blog we saw how Phoenix contexts encourage you to think of the design of your application.

© 2024 HEXMONSTER LLC