United States — 12 min
At Code BEAM 2020, our CTO and co-founder Marcelo Lebre introduced Four Patterns to Save your Codebase and your Sanity.Remote has been utilizing these patterns for over a year now, and the results have been incredible! The speed at which we can build out new ideas and features while maintaining a consistent structure throughout all parts of the codebase is truly remarkable.
The patterns outlined below have served us well, but they do come with a drawback: boilerplate.
phx_gen_solid aims to solve the boilerplate problem as well as educate and empower others to create with the building blocks described here.
The patterns from the talk build on a set of principles first introduced in Design Principles and Design Patterns by Robert Martin.
Single-responsibility principle: "There should never be more than one reason for a class to change." In other words, every class should have only one responsibility.
Open-closed principle: "Software entities...should be open for extension, but closed for modification."
Liskov substitution principle: "Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”
Interface segregation principle: "Many client-specific interfaces are better than one general-purpose interface.”
Dependency inversion principle: "Depend upon abstractions, [not] concretions.”
There’s no need to deep dive into each of these, but they are important to keep in mind as they are the reasons behind each of the following solutions.
These four patterns are the building blocks behind everything we build in our Phoenix app at Remote. Surprisingly, there is very little overlap between each, and features usually find a happy home in one of the following ideologies.
Finders fetch data. They don’t mutate nor write, only read and present.
Non-complex database queries may also exist in Phoenix Contexts. A query can be considered complex when there are several conditions for filtering, ordering, and/or pagination. Rule of thumb is when passing a params or opts Map variable to the function, a Finder is more appropriate.
Do
Organized by application logic
Reusable across Handlers and Services
Focuses on achieving one single goal
Exposes a single public function: find
Read data structure
Uses Values to return complex data
Finders only read and look up data
Don't
Call any services
Create/modify data structures
Below is an example of a finder that finds a user.
1defmodule Remoteoss.Accounts.Finder.UserWithName do2 alias Remoteoss.Accounts34 def find(name) when is_binary(name) do5 case Accounts.get_user_by_name(name) do6 nil -> {:error, :not_found}7 user -> {:ok, user}8 end9 end1011 def find(_), do: {:error, :invalid_name}12end
Handlers are orchestrators. They exist only to dispatch and compose. A handler orders execution of tasks and/or fetches data to put a response back together.
Do
Organize by business logic, domain, or sub-domain
Orchestrate high level operations
Command services, finders, values or other handlers
Multiple public functions
Keep controllers thin
Make it easy to read
Flow control (if, case, pattern match, etc.)
Don't
Directly create/modify data structures
Execute any read/write operations
Below is an example of a handler that creates a user, sends a notification, and fetches some data.
1defmodule Remoteoss.Handler.Registration do2 alias Remoteoss.Accounts.Service.{CreateUser, SendNotification}3 alias Remoteoss.Accounts.Finder.UserWithName45 def setup_user(name) do6 with {:ok, user} <- CreateUser.call(name),7 :ok <- SendNotification.call(user),8 user_details <- UserWithName.find(name) do9 {user, user_details}10 else11 error ->12 error13 end14 end15end
Services are the execution arm. Services execute actions, write data, invoke third-party services, etc.
Do
Organize by application logic
Reusable across handlers and other services
Commands services, finders and values
Focuses on achieving one single goal
Exposes a single public function: call
Create/modify data structures
Execute and take actions
Don't
Use a service to achieve multiple goals
Call handlers
If too big, you need to break it into smaller services or your service is actually a handler.
Below is an example of a service that creates a user.
1defmodule Remoteoss.Accounts.Service.CreateUser do2 alias Remoteoss.Accounts3 alias Remoteoss.Service.ActivityLog4 require Logger56 def call(name) do7 with {:ok, user} <- Accounts.create_user(%{name: name}),8 :ok <- ActivityLog.call(:create_user) do9 {:ok, user}10 else11 {:error, %Ecto.Changeset{} = changeset} ->12 {:error, {:invalid_params, changeset.errors}}1314 error ->15 error16 end17 end18end
Values allow us to compose data structures such as responses, intermediate objects, etc. You’ll find that values are very helpful in returning JSON from an API, and in most cases trims our View render functions into just a single line, MyValue.build(some_struct).
Do
Organize by application logic
Reusable across handlers, services, and finders
Focuses on composing a data structure
Exposes a single public function: build
Use composition to build through simple logic
Don't
Call any services, handlers or finders
Below is an example of a value that builds a user object to be used in a JSON response.
1defmodule Remoteoss.Accounts.Value.User do2 alias Remoteoss.Value34 @valid_fields [:id, :name]56 def build(user, valid_fields \\\\ @valid_fields)78 def build(nil, _), do: nil910 def build(user, valid_fields) do11 user12 |> Value.init()13 |> Value.only(valid_fields)14 end15end
When building an application as large as Remote’s (almost 400k lines!), it becomes tedious to write the same sort of structure over and over. We want to get into the business logic and the specifics as fast as possible. phx_gen_solid gets us to the fun part faster by generating as much boilerplate as we can right away. We can then tweak and fine-tune the specifics in any way we like! Hopefully, the generators are useful, and at the very least phx_gen_solid can act as a resource to learn a few new patterns or tricks!
phx_gen_solid is still in its infancy, but it can already assist with one of the more complicated parts of the above patterns, values.
You can add phx_gen_solid to your Phoenix app by adding the following to your mix.exs:
1def deps do2 [3 {:phx_gen_solid, "~> 0.1", only: [:dev], runtime: false}4 ...5 ]6end
Then install and compile the dependencies:
1$ mix do deps.get, deps.compile
1$ mix phx.gen.solid.value Accounts User users id slug name
This will produce the following code in my_app/accounts/values/user.ex
1defmodule MyApp.Accounts.Value.User do2 alias MyApp.Value34 @valid_fields [:id, :name]56 def build(user, valid_fields \\\\ @valid_fields)78 def build(nil, _), do: nil910 def build(user, valid_fields) do11 user12 |> Value.init()13 |> Value.only(valid_fields)14 end15end
If you have defined your “Composer” with all the helpers to build values, you can specify it with the flag --value-module MyApp.Composition.Value, and the alias used in the generator will become alias MyApp.Composition.Value.
If you would like to generate the recommended Value composer, simply pass the --helpers flag along with the command. It will populate the context my_app/value.ex.
Remote has carefully crafted a workflow for engineers that allows us to iterate quickly and give our complete focus to the problem at hand. It’s a beautiful symphony of stakeholders, product managers, designers, and engineers working together towards a larger goal. The funny thing is, in a space where common problems are uncommon, we still find inefficiencies in what remains. It’s human nature, but it fuels progress.
If you’re interested in contributing, head over to our GitHub!
The documentation for phx_gen_solid is also available over on hexdocs.
Subscribe to receive the latest
Remote blog posts and updates in your inbox.
United States — 12 min
Jobs and Talent — 6 min
Global Employment & Expansion — 8 min
Global HR — 10 min