I have written about Elixir in the past, but I have yet to write anything about the framework that makes it so productive for web development — Phoenix. All software is the result of many small decisions that coalesce into a more meaningful whole. And while it’s difficult for me to distill what makes Phoenix so great into words, if I had to try I’d say that it’s the product of very thoughtful, effective small decisions. When I see how and why something works the way it does in Phoenix, I often find myself agreeing with the author, and more often than not find myself marveling at the elegance of the solution.
Forms and Validation
For example, let’s look at how Phoenix suggests we handle form validation in a web application. Many of the validation libraries I have used in the past have strong opinions about how they should be used. This tends to translate to coupling the user both to how validation is performed, and how it is integrated into the UI.
Phoenix takes a different approach. When using the form/1
component
(from
Phoenix LiveView),
the docs suggest you pass it a
%Form{}
struct. Let’s take a look at what that looks like:
@type t() :: %Phoenix.HTML.Form{
action: atom(),
data: %{required(field()) => term()},
errors: [{field(), term()}],
hidden: Keyword.t(),
id: String.t(),
impl: module(),
index: nil | non_neg_integer(),
name: String.t(),
options: Keyword.t(),
params: %{required(binary()) => term()},
source: Phoenix.HTML.FormData.t()
}
At a quick glance, this doesn’t look too scary. And we can already see
how this struct might play a part in how validation is handled (e.g. the
:errors
property likely has a role in this). And we can read
the docs on %Phoenix.HTML.Form{}
to see exactly what these properties are for, and how we might build
one.
However, I’d estimate it’s rare that people actually build one of these structs themselves. Phoenix offers a “happy path” where you use Ecto Changesets to perform the validation. In that case, you’d be writing code like this to validate your database schemas:
@doc false
def changeset(item, attrs) do
item
|> ensure_new_record()
|> ensure_not_coin()
|> cast(attrs, [:name, :description, :weight, :value, :is_treasure, :icon])
|> validate_required([:name, :weight, :value, :is_treasure, :icon])
|> validate_number(:weight, greater_than_or_equal_to: 0)
end
And then using the to_form/2
function from either
Phoenix.LiveView
or
Phoenix.HTML
to convert one of these changesets to a %Form{}
struct.
So your loop ends up looking like this:
render -> form_change params -> %Ecto.Changeset{} -> %Form{} -> render
And that’s the happy path that 99% of applications probably take. Both because of the excellent documentation, and because the generated code uses this pattern by default.
What if you don’t want to use Ecto Changesets for validation though?
What if you don’t want to use Ecto in your application at all? Being
that the data schema expected by the form/1
component is decoupled
from any single validation library / technique, we can do that! If we
wanted, we could even write a to_form
function that takes the params
from the form change event, and produce a %Form{}
struct.
Phoenix is full of thoughtful design decisions like this, that acknowledge there is value in providing an opinionated solution to a problem, but also understanding that that solution won’t always work for every person or application.
Generators
Which brings us to generators. Commands you run that generate code in your codebase, which you then adopt and maintain yourself. I understand not everybody loves them, but I think the way Phoenix uses them strikes a very delicate balance between flexibility, reliability, and productivity.
This section is going to be heavily colored by the fact I strongly dislike dependencies. Or to put a finer point on it, I dislike the current culture of using a dependency for everything. I think it’s dangerous, insecure, and renders software far more prone to bitrot. As with everything there is a balancing act to be made in how much you depend on third party software, but I tend to lean more on the “write it yourself” side than it seems is the average nowadays.
Writing everything yourself, particularly boilerplate code, can be exhausting though. And I feel that Phoenix again strikes a very meaningful balance in this area. By letting you generate a lot of this boilerplate code, and then tweak it as necessary, it accomplishes a few very meaningful properties:
-
Propagates common architectures / patterns in the community
Chances are, if you’ve worked on a Phoenix application, it probably uses Contexts. An idea derived from Domain Driven Design, these are effectively Bounded Contexts (albeit far more lenient). Phoenix has managed to promote a successful architectural pattern in the community, without enforcing it (which would come at the cost of flexibility)
-
Flexibility
This is particularly important for a framework. I’ve used several “opinionated” frameworks that enforced one way of doing things. And I’ve mostly just ended up frustrated when I wanted to do something more complex than a simple CRUD application. If enough people have the same issue, the framework often changes to accommodate it, and the solution is often a compromise of the framework and the user experience.
Generators allow the framework to sidestep this issue entirely. They get to be as opinionated as they want with the generated code, as long as the core behavior of the framework is flexible enough to bend and contort to serve different use cases.
-
Consistency
This one is huge. I’d wager that the average small-ish application (e.g. 10k lines) is composed of more third-party code than first-party. And I’d wager that most developers on those applications probably haven’t read much, if any, of that third party code. Isn’t that scary? Not only is most of your app a black box to you (and your org), but it’s also shifting beneath your feet every time you update your dependencies. And if some of your dependencies stop being maintained, you might find yourself needing to tear it out and replace it. If your application isn’t consistent, how can it be maintainable or reliable?
-
Security
I mentioned the ground shifting beneath your feet every time you update dependencies, but the maintainers might well be shifting too. I don’t think it’s any secret to anyone that there are a lot of under-funded, burnt out maintainers out there. And many of them (rightfully so) choose to step away rather than continue to deal with the stress of dealing with entitled jerks. When that happens, either someone else takes their place, or it just stops being maintained. Both can be really bad for end users (e.g. CVE-2024-3094). When you generate code, you own it. It becomes part of your application, part of your organization, part of the collective knowledge shared by the developers. It might not be perfect, but it’s yours.
Summary
I have found that learning Phoenix has made me a better developer. Whenever I get asked questions in other contexts, I find myself thinking about how Phoenix handles that situation or use case. I often come away with an understanding of why the Phoenix developers chose the path they did, and impressed they had the thoughtfulness to do so. And I’m glad I finally made some time to reflect on and appreciate the free software that I’ve used so much in the last 5 years.