This is just my thoughts on how I would organize code in an endpoint. Let's have an example addresses
endpoint that will handle CRUD operations for customer addresses
in a store.
For brevety we will only focus on a simple query that returns all the addresses for a given customer id.
The structure is based on CRC and some loose interpretation of the concepts in Designing Elixir Systems with OTP
Directory Structure
├── addresses
│ ├── endpoints.ex
│ ├── metrics.ex
│ ├── repo
│ │ ├── commands.ex
│ │ └── queries.ex
│ ├── requests.ex
│ └── resolvers.ex
├── metrics.ex
├── requests.ex
└── responses.ex
Root Directory
requests.ex
Let's start with the requests.ex
file that will standarize the params given to the endpoints and use it as a token (accumulator) to pass between the reducers. This would be a Constructor
in CRC.
defmodule Endpoints.Requests do
alias Endpoints.Metrics
defstruct [params: %{}, metrics: Metrics.new(), data: %{}, valid?: true]
def new(metrics, params, data \\ %{}, valid? \\ true) do
%__MODULE__{metrics: metrics, params: params, data: data, valid?: valid?}
end
def new(params) do
%__MODULE__{params: params}
end
def
end
responses.ex
The responses.ex
will handle the final result. It will be our Converter
in CRC.
defmodule Endpoints.Responses do
alias Endpoints.Requests
defstruct [:status, :data, request: nil]
def new(%Requests{} = request, data, status \\ :ok) do
%__MODULE__{status: status, data: data, request: request}
end
def new(status, data) do
%__MODULE__{status: status, data: data}
end
def ok(data \\ []) do
new(:ok, data)
end
def error(data \\ []) do
new(:error, data)
end
def render(%__MODULE__{} = response) do
{response.status, response.data}
end
end
metrics.ex
The metrics.ex
is a simple structure that can store the params and functions to send to telemetry and instrumentation services like Prometheus. It's a façade
that can standarize and simplify those calls. This can be associated with a Boundary
layer, because it interacts with an external component.
defmodule Endpoints.Metrics do
# Epoch = Start Time
# Id = Id to Send to the metrics system
defstruct [:epoch, :id]
def new(id \\ 0) do
%__MODULE__{epoch: System.monotonic_time(:microsecond), id: id}
end
def count(data, operation, metrics) do
# Call A metrics system to send the count operation
{:count, operation, data, metrics.id}
end
def count(operation, metrics), do: count([], operation, metrics)
def error(data, operation, metrics) do
{:count_error, operation, data, metrics.id}
end
def error(operation, metrics), do: error([], operation, metrics)
@doc """
Track are for time measurement since it uses the metrics's epoch
"""
def track(data, operation, metrics) do
{:track, operation, data, metrics.id, metrics.epoch}
end
def track(operation, metrics), do: track([], operation, metrics)
end
Address Directory
endpoints.ex
This is a boundary layer that will receive all the params
from the HTTP or GraphQL Request and will call the other functions and render the final response.
defmodule Endpoints.Addresses.Endpoints do
alias Endpoints.Addresses.Requests
alias Endpoints.Metrics
def get_all_addresses_for_customer_id(customer_id) do
# initiate metrics here to have a good epoch
Requests.get_all_addresses_for_customer_id(Metrics.new(), customer_id)
|> Resolvers.get_all_addresses_for_customer_id()
|> Responses.render()
end
end
metrics.ex
These are helper functions to standarize the metrics used inside all the endpoints.
May be these can be automated using macros or if you are adventurous a decorator
defmodule Endpoints.Addresses.Metrics do
alias Endpoints.Metrics
def init_address_request(metrics), do: Metrics.count("INIT_ADDRESS_REQUEST", metrics)
def init_address_request(_data, metrics), do: init_address_request(metrics)
def count_address_found(metrics), do: Metrics.count("OK_ADDRESS_FOUND", metrics)
def count_address_found(_data, metrics), do: count_address_found(metrics)
def count_address_not_found(metrics), do: Metrics.count("OK_ADDRESS_NOT_FOUND", metrics)
def count_address_not_found(_data, metrics), do: count_address_not_found(metrics)
def track_address_get(data, metrics): Metrics.track(data, "TRACK_ADDRESS_GET", metrics)
def track_address_get(metrics), do: track_address_get([], metrics)
end
requests.ex
The address request is a Constructor
that will standarize params and return a new Request
struct.
Maybe it can validate the params too and see if it valid.
defmodule Endpoints.Addresses.Requests do
alias Endpoints.Requests
def get_all_addresses_for_customer_id(metrics, customer_id) do
Requests.new(metrics, %{customer: %{id: customer_id}})
end
end
resolvers.ex
The resolver is the one who orchestates queries, requests and responses.
defmodule Endpoints.Addresses.Resolvers do
alias Endpoint.Responses
alias Endpoints.Addresses.Requests
alias Endpoints.Addresses.Repo.Queries
alias Endpoints.Addresses.Metrics
def get_all_addresses_for_customer_id(%Requests{} = request) do
metrics = request.metrics
Metrics.init_address_request(metrics)
case Queries.addresses(customer: request.params.customer.id) do
[] ->
Metrics.count_address_not_found(metrics)
|> Metrics.track_address_get(metrics)
Responses.ok()
addresses ->
Metrics.count_address_found(metrics)
Metrics.track_address_get(addresses, metrics)
Responses.ok(addresses)
end
end
end
Repo Directory
These are two files that are using CQRS to store queries.
queries.ex
defmodule Endpoints.Addresses.Repo.Queries do
import Ecto.Query
use Ecto.Repo
def addresses(customer: id) do
from(a in Address,
where: a.customer_id == ^id
)
|> Repo.all()
end
end
commands.ex
defmodule Endpoints.Addresses.Repo.Commands do
end
Conclusion
These are just some thoughts about code organization. I tried to follow CRC and apply different layers of code organization.
Thanks.
Top comments (0)