As consultants, our job is to provide the most value possible to our clients over the course of our engagement. As engineers, our desire is to build software that is a joy to work on. Thankfully, these goals are not mutually exclusive. In trying to meet both of them, it helps to focus on building well-designed applications. Since "well-designed" is a fairly nebulous term, I'll lean on Sandi Metz to provide a definition.

From a practical point of view, changeability is the only design metric that matters; code that’s easy to change is well-designed.
Sandi Metz

When our code is easy to change, we can respond to changing requirements quickly, thereby providing more value to our clients. Similarly, code that's easy to change affords us the joy of fluid expression.

Ecto

We want data management tools that support our intentions to design our applications well. Ecto is a popular database wrapper and integrated query language for Elixir. Through a tour of its four main modules — Schema, Changeset, Repo, and Query — we'll see how Ecto provides the tools to write code that's easy to change, and thus well-designed.

Schema

Schemas are used to map data into Elixir structs. Here's one that describes posts that we might see in a content management application:

use Ecto.Schema

schema "posts" do
  field :title, :string
  field :body, :text
end

What's nice about schemas is that they are lightweight. For any particular domain model, we can have multiple schemas for different purposes. For instance, we can have one schema for pulling posts out of our database into an Elixir struct, ready for manipulation by our application. We can have another one for taking data input by a user on a web form, and mapping that to an Elixir struct with a slightly different shape, for eventual insertion back into the database.

The key observation that led the Ecto team to these lightweight schemas is that when it comes to our complex business domains, one size does not fit all. Despite the fact that we may have a single table in our database to hold the data for a particular model, the ways in which we work with that data can be many and varied.

When we have different use cases for our data, it helps to be able to work on those use cases independently. The mapping used for pulling data out of our database doesn't always work as well for the use case of validating user data. Ecto promotes a single, lightweight schema for each use case. Since these schemas can be maintained separately, when a change is required for one of those use cases, we can make the change for that use case and only that use case. This ease of change supports our design intentions.

Changeset

Changesets are used to filter, cast, and validate data. They work well with schemas. Here is one we might apply to form data for our posts:

import Ecto.Changeset

def changeset(%Post{} = post, attrs) do
  post
  |> cast(attrs, [:title, :body])
  |> validate_required(:title)
end

The value of changesets goes hand-in-hand with that of schemas. We can have multiple of them for different use cases. We use a schema each time we need to represent a particular shape of data. We use changesets to get data from different sources into that shape.

For instance, we might want to collect comma-delimited tags for a post on the same form in which the post is created. We might, however, want to store tags in a separate table in the database. To support this use case, we could have a schema and a matching changeset specifically for the form. Here we would use Ecto's embedded_schema, which doesn't require a table name, because this data isn't being mapped directly to the database:

use Ecto.Schema
import Ecto.Changeset

embedded_schema do
  field :title, :string
  field :body, :text
  field :tags, :string
end

def changeset(%Post{} = post, attrs) do
  post
  |> cast(attrs, [:title, :body, :tags])
  |> validate_required(:title)
end

Once the data is cast and validated, we could have application code take the form schema struct and generate a few different schema structs, one for the post, and some for as many tags as were provided. For an example of this approach of using a single schema to map data destined for multiple tables, see the "Schemaless changesets" section of the excellent e-book What's New in Ecto 2.1 by Plataformatec, the company behind Elixir.

Repo

A repository, or "repo," is used to interact with a database. The functions provided by the Repo module allow us to store and retrieve the data that our application cares about. Here are some examples of repo operations:

alias MyApp.{Post, Repo}

Repo.insert(changeset)

Repo.update(changeset)

Repo.delete(post)

Repo.all(Post)

Repo.get!(Post, id)

A perk of working with repos in this way is that it's very explicit. By invoking functions directly on the Repo module, it's clear when we are performing operations that may affect our database. When it comes time to change application behavior, we can see which parts of our code may have side affects on our data, or run potentially costly queries against it. Having a clearer view of what our code is doing enables us to change it with more confidence.

Query

Queries are used to structure data retrieval and update operations. Here is an example that selects the name of each post and tag combination:

import Ecto.Query, only: [from: 2]

query =
  from p in Post,
    join: t in Tag, where: t.post_id == p.id
    select: [p.title, t.name]

Once we have built a query, we can execute it through our repo:

Repo.all(query)

Due to the way queries are implemented, we can easily compose them. For example, to order the results of the above query, we could use it in a new query:

ordered_query =
  from pt in query,
    order_by: [:title, :name]

Ease of composition promotes the writing of many small queries for specialized purposes, which we can combine to provide the behavior we need. Adding behavior becomes more straightforward when it is carried out by composing pieces of existing functionality. Similarly, when we need to update an existing bit of behavior, smaller units allow us to locate that behavior more easily and update it more confidently.

Another benefit of the Ecto query syntax is that it is compiled by the Elixir compiler. In practice, this results in query bugs being surfaced earlier in the development workflow, at compile time, rather than at runtime. This is because the first step in running new or updated Elixir code is to compile it. In this workflow, it isn't necessary to wait until the test that executes your code runs, or until you exercise it via your application interface, to find out that there is a problem with the way your query is written. Compilation happens automatically and immediately upon invoking your test suite or application interface.

Closing thoughts

The ease of change that Ecto provides stems from its design. Each of the four modules outlined above carries a narrow yet cohesive set of responsibilities. Once we learn those responsibilities, whenever we need a particular set of behavior, it's clear which module to reach for.

My hope for this post has been to show how use of Ecto promotes the same desirable qualities in our own code. Authoring discrete bits of functionality, and then composing them to suit our needs, makes it easier for us to adapt to change, and thus be stewards of well-designed applications.