Design pattern parameter

Sergey Chechaev
6 min readDec 29, 2022

--

Sometimes we need to unify data that we get from external services. For example, we have two different versions of endpoint but under the hood we need to use the same services. Also, we want to check external data types, validate data especially if we are talking about non database validation, and calculate or execute something that prepares data before sending it to services. This pattern has common principles like Protocol Buffers and XML where we also have mechanisms to describe schema and set data types, but inside the well known Phoenix framework.

To show this pattern in action, we create a simple application, https://github.com/sergey-chechaev/example_app, where we have endpoint Payments for obtaining payment information from an external system.

So lats generates the controller, view, and context for a Payment JSON resource.

mix phx.gen.json Payments Payment payment_logs \
sum:decimal bonus:decimal tax:decimal \
operation_type:enum:refill:withdrawal \
status:enum:initial:processed:rejected \
comment:text pay_period_from:utc_datetime \
pay_period_to:utc_datetime

Imagine that the external service sends data for us to create Payment

curl -X POST \
'http://localhost:4000/api/payments?sum=100&operation_type=refill&pay_period_from=2022-10-01&comment=comment'

We get the errors

{
"errors": {
"bonus": [
"can't be blank"
],
"pay_period_from": [
"is invalid"
],
"pay_period_to": [
"can't be blank"
],
"status": [
"can't be blank"
],
"tax": [
"can't be blank"
]
}
}

The problem here is that we must send other required attributes, but what if we need to calculate status, tax, bonus, pay_period_to itself, format pay_period_from and create a record in another table. But at the same time, we don’t want to change the changeset and context. There may be many variants to solve this issue, let’s look at Pattern Parameter.

Firstly, we need to create special interface params such as controller, view, changeset and so on. Inside entrypoint lib/example_app_web.ex add new function params, the definition of this function will be executed for every params interface.

def params do
quote do
use Ecto.Schema
import Ecto.Changeset
import ExampleAppWeb.Gettext

@primary_key false

def fetch(term, key) do
term
|> Map.from_struct()
|> Map.fetch(String.to_existing_atom(key))
end
end
end

Ecto.Schema for maps external data into Elixir structs.

Ecto.Changeset for filtering, casting, validation and definition of constraints when manipulating structs.

ExampleAppWeb.Gettext for based API for working with internationalized applications.

@primary_key false to disable generation of additional primary key fields.

fetch is the function for fetching data from struct. We can add other functions here if we need to.

Secondly, we add new folder params where we will save our params modules: mkdir lib/example_app_web/params

Then create the first params module Params.External.Payment We use external scope to show that we can use different formatting for different endpoints:

mkdir lib/example_app_web/params/external/ && touch lib/example_app_web/params/external/payment.ex

And add code to it:

defmodule Params.External.Payment do
@moduledoc """
Resolves validation params for external payment creation
"""
use ExampleAppWeb, :params

alias ExampleApp.Payments.Payment
alias Ecto.Enum

@required [:operation_type, :sum]

embedded_schema do
field :bonus, :decimal, default: 0
field :comment, :string
field :operation_type, Enum, values: Enum.values(Payment, :operation_type)
field :status, Enum, values: Enum.values(Payment, :status)
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :sum, :decimal
field :tax, :decimal, default: 0
end

def prepare(:create, params) do
params
|> create_changeset()
|> apply_action(:insert)
end

defp create_changeset(params) do
%__MODULE__{}
|> cast(params, [
:operation_type,
:status,
:sum,
:bonus,
:pay_period_from,
:pay_period_to,
:tax
])
|> validate_required(@required)
end
end

For this external endpoint, we only need :operation_type and :sum.

use ExampleAppWeb, :params here we add our new function that we define inside the entrypoint.

embedded_schema to validate data, set data type and show how the data is represented in your applications.

prepare(:create, params) is the entrypoint to execute this module inside the controller for the action create.

create_changeset for casting parameters and validation.

Now execute this module and see how it works:

iex(1)> Params.External.Payment.prepare(:create, %{operation_type: :refill, sum: 100})

{:ok,
%Params.External.Payment{
bonus: 0,
comment: nil,
operation_type: :refill,
pay_period_from: nil,
pay_period_to: nil,
status: nil,
sum: #Decimal<100>,
tax: 0
}}

As a result, we get what we need for this external endpoint, only two requirement fields were transferred. But we can’t send this struct to module Payment because we need to calculate other requirement fields for this. Let’s do that.

  def prepare(:create, params) do
params
|> cast_start_pay_period()
|> cast_end_pay_period()
|> maybe_add_bonus_sum()
|> add_tax()
|> fetch_status()
|> create_changeset()
|> apply_action(:insert)
end

defp cast_start_pay_period(params) do
pay_period_from =
Timex.today()
|> Timex.to_datetime()

Map.put(params, "pay_period_from", pay_period_from)
end

def cast_end_pay_period(params) do
pay_period_to =
Timex.today()
|> Timex.end_of_month()
|> Timex.to_datetime()

Map.put(params, "pay_period_to", pay_period_to)
end

defp maybe_add_bonus_sum(
%{
operation_type: :refill,
sum: sum
} = params
) do
Map.put(params, "bonus", Payment.calculate_bonus(sum))
end

defp maybe_add_bonus_sum(params), do: params

defp add_tax(%{sum: sum, operation_type: :refill} = params) do
Map.put(params, "tax", Payment.calculate_tax(sum))
end

defp add_tax(params), do: params

def fetch_status(params) do
{:ok, status} = fetch(Payment, "status")
Map.put(params, "status", status)
end

We also need to add the functions calculate_bonus and calculate_tax to the Payment Changeset:

defmodule ExampleApp.Payments.Payment do
use Ecto.Schema
import Ecto.Changeset

@tax_percent 0.1

schema "payment_logs" do
field :bonus, :decimal
field :comment, :string
field :operation_type, Ecto.Enum, values: [:refill, :withdrawal]
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :status, Ecto.Enum, values: [:initial, :processed, :rejected], default: :initial
field :sum, :decimal
field :tax, :decimal

timestamps()
end

@doc false
def changeset(payment, attrs) do
payment
|> cast(attrs, [:sum, :bonus, :tax, :operation_type, :status, :comment, :pay_period_from, :pay_period_to])
|> validate_required([:sum, :bonus, :tax, :operation_type, :status, :pay_period_from, :pay_period_to])
end

def calculate_bonus(sum) do
sum = Decimal.new(sum) |> Decimal.to_float()
case sum do
n when n >= 100 ->
2
_ ->
0
end
end

def calculate_tax(sum) do
sum = Decimal.new(sum) |> Decimal.to_float()
sum * @tax_percent
end
end

Then execute Params.External.Payment inside Controller:

defmodule ExampleAppWeb.PaymentController do
use ExampleAppWeb, :controller

alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.External.Payment, as: PaymentParams

action_fallback ExampleAppWeb.FallbackController

def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render("show.json", payment: payment)
end
end
end

Now we can use the same Payment module for different Params modules and endpoints. For example: we separate external and internal endpoints. Change router.ex by adding internal and external scopes:

  scope "/external", ExampleAppWeb.External do
pipe_through :api

resources "/payments", PaymentController
end

scope "/internal", ExampleAppWeb.Internal do
pipe_through :api

resources "/payments", PaymentController
end

Add a controller and add a different Params module to it where there are different required fields.

External Controller with External Payment Params

mkdir lib/example_app_web/controller/external/ && touch lib/example_app_web/controller/external/payment_controller.ex
defmodule ExampleAppWeb.External.PaymentController do
use ExampleAppWeb, :controller

alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.External.Payment, as: PaymentParams

def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render(ExampleAppWeb.PaymentView, "show.json", payment: payment)
end
end
end

Internal Controller with Internal Payment Params

mkdir lib/example_app_web/controller/internal/ && touch lib/example_app_web/controller/internal/payment_controller.ex
defmodule ExampleAppWeb.Internal.PaymentController do
use ExampleAppWeb, :controller

alias ExampleApp.Payments
alias ExampleApp.Payments.Payment
alias Params.Internal.Payment, as: PaymentParams

def create(conn, payment_params) do
with {:ok, prepared_params} <- PaymentParams.prepare(:create, payment_params),
params <- Map.from_struct(prepared_params),
{:ok, %Payment{} = payment} <- Payments.create_payment(params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.payment_path(conn, :show, payment))
|> render(ExampleAppWeb.PaymentView, "show.json", payment: payment)
end
end
end

And finally create the Internal Payment Params module with different data logic

mkdir lib/example_app_web/params/internal/ && touch lib/example_app_web/params/internal/payment.ex
defmodule Params.Internal.Payment do
@moduledoc """
Resolves validation params for internal payment creation
"""
use ExampleAppWeb, :params

alias ExampleApp.Payments.Payment
alias Ecto.Enum

@required [:operation_type, :sum, :bonus, :tax, :comment]

embedded_schema do
field :bonus, :decimal
field :comment, :string
field :operation_type, Enum, values: Enum.values(Payment, :operation_type)
field :status, Enum, values: Enum.values(Payment, :status)
field :pay_period_from, :utc_datetime
field :pay_period_to, :utc_datetime
field :sum, :decimal
field :tax, :decimal
end

def prepare(:create, params) do
params
|> cast_start_pay_period()
|> cast_end_pay_period()
|> fetch_status()
|> create_changeset()
|> apply_action(:insert)
end

defp cast_start_pay_period(params) do
pay_period_from =
Timex.today()
|> Timex.to_datetime()

Map.put(params, "pay_period_from", pay_period_from)
end

def cast_end_pay_period(params) do
pay_period_to =
Timex.today()
|> Timex.end_of_month()
|> Timex.to_datetime()

Map.put(params, "pay_period_to", pay_period_to)
end

def fetch_status(params) do
{:ok, status} = fetch(Payment, "status")
Map.put(params, "status", status)
end

defp create_changeset(params) do
%__MODULE__{}
|> cast(params, [
:operation_type,
:status,
:sum,
:bonus,
:pay_period_from,
:pay_period_to,
:tax,
:comment
])
|> validate_required(@required)
end
end

Conclusion

We use different endpoints and different Params modules, but under the hood we use the same Payment context. This approach encapsulates parameters logic, helps us validate and prepare data for our controller and separate responsibility between our incoming data and business logic. It also helps us prepare data for complex service where we use more than one context. We will see this when we create a service based on the pattern Railway Oriented Programming — Part 2.

Go to main page

--

--