Intro
In the year 2024 humankind is sending its first spacecraft to Mars to set up the infrastructure. The crew of the "Ares VI" will set up the colony which will mark the beginning of life on the Red Planet. As a developer you are responsible for setting up an online shop to track inventory and payments.
Requirements
The journey to the Red Planet is quite long and the spacecraft cannot carry a large payload. You managed to get a MoonPi computer and your own personal laptop cleared, but not much else. The MoonPi will be powerful enough to run the shop you will have to manage your resources.
You start writing down the requirements:
- Should run on a MoonPi with limited resources
- Should not have external dependencies like PostgreSQL due to limited resources
- Should have a light-weight JSON API for clients
- Should try not to rely on external libraries for simplicity (with exceptions)
Luckily you know Elixir and it would be perfect for the job. It does not need a lot of resources and it's very resilient.
Let's start coding!
App
You begin by creating a new project. The below command will create a new project called Ares
:
$ mix new ares --sup
$ cd ares/
A --sup option can be given to generate an OTP application skeleton including a supervision tree. Normally an app is generated without a supervisor and without the app callback.
Source: https://hexdocs.pm/mix/1.15.2/Mix.Tasks.New.html
Now that your app has a supervisor we also needs some config files.
$ mkdir -p config
$ touch config/config.exs
$ touch config/prod.exs
$ touch config/dev.exs
$ touch config/test.exs
Open up your favorite code editor and put this in config/config.exs
:
import Config
import_config "#{config_env()}.exs"
This will hold our config and it will also load any overrides that you set per environment.
We now have an app, with a supervisor and a config system in place!
Products
Let's build some products to go into our shop. A product is going to be very simple. It will represent the items that are for sale like potatoes, ketchup and fertilizer.
A struct will represent our Product (https://hexdocs.pm/elixir/Kernel.html#struct/2):
defmodule Ares.Catalog.Product do
@moduledoc """
This module hold the structure of the Product
"""
defstruct [:id, :sku, :price, :title, :inserted_at, :updated_at]
@type id :: non_neg_integer()
@type t :: %__MODULE__{
id: id() | nil,
sku: String.t() | nil, # Stock Keeping Unit
price: non_neg_integer() | nil, # Just a simple price
title: String.t() | nil, # The title of the product
inserted_at: NaiveDateTime.t() | nil,
updated_at: NaiveDateTime.t() | nil
}
end
Now that we have the structure of our products, we need a way to persist them. The requirements state that external dependencies like PostgreSQL are not an option due to limited resources. While you could write and read a file to disk for every product, you decide that it would be easier to use a library.
Database
CubDB (https://hex.pm/packages/cubdb) is one of the exceptions. It fits the bill but would take too much work to build ourselves. It's a simple key-value storage written in Elixir with zero dependencies.
Add {:cubdb, "~> 2.0"}
to your deps/0
function in mix.exs
and run:
$ mix deps.get
Open up ares/application.ex
and add these lines (marked with +
) to your start/2
function:
defmodule Ares.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
+ cub_db_config = Application.get_env(:ares, CubDB)
children = [
+ {CubDB, cub_db_config}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Ares.Supervisor]
Supervisor.start_link(children, opts)
end
end
Next, open config/config.exs
and add the following lines:
import Config
+ config :ares, CubDB,
+ data_dir: "data", # the directory where to write the data
+ name: Ares.Repo # the name of the server
import_config "#{config_env()}.exs"
This will tell our application supervisor to start the CubDB server with our config.
To complete it, we need a file called ares/repo.ex
which will hold some functions that will make it easier for you to talk to CubDB.
defmodule Ares.Repo do
@moduledoc """
The repository to interact with CubDB
"""
@spec insert(struct()) :: {:ok, struct()}
def insert(%{__struct__: collection, id: nil} = struct) do
id =
CubDB.transaction(__MODULE__, fn tx ->
id = CubDB.Tx.get(tx, {:index, collection}, 1)
tx = CubDB.Tx.put(tx, {:index, collection}, id + 1)
{:commit, tx, id}
end)
now = NaiveDateTime.utc_now()
struct = %{struct | id: id, inserted_at: now, updated_at: now}
:ok = CubDB.put(__MODULE__, {collection, id}, struct)
{:ok, struct}
end
end
The insert/1
function does a couple of things here:
- It makes sure, with pattern matching, that the struct that you want to insert had ID
nil
. This is to prevent already existing products to be inserted again and thus get a new ID. - The ID is created in a transaction to make sure we get a new ID and the record is also locked to prevent a single ID to be issued twice. See: https://hexdocs.pm/cubdb/CubDB.html#transaction/2
- It sets both the
inserted_at
andupdated_at
timestamps
The next thing you will need is a context to hold the functions to create, read, update, and delete the Product.
Create a file called ares/catalog.ex
and paste the following code:
defmodule Ares.Catalog do
@moduledoc """
Catalog context
"""
alias Ares.Catalog.Product
alias Ares.Repo
@spec create_product(map() | Keyword.t()) :: {:ok, Product.t()}
def create_product(attrs) do
product = struct(Product, attrs)
Repo.insert(product)
end
end
Tests
To make sure this all works as planned some tests are needed. Before you can run tests, you need to change your mix.exs
:
defmodule Ares.MixProject do
use Mix.Project
def project do
[
app: :ares,
version: "0.1.0",
elixir: "~> 1.15",
+ elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
+ # Specifies which paths to compile per environment.
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {Ares.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:cubdb, "~> 2.0"}
]
end
end
The above change is needed because you needs some support files in the :test
environment.
Next up is to create your product fixture, create a file called test/support/catalog_fixtures.ex
with the following contents:
defmodule Ares.CatalogFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Ares.Catalog` context.
"""
def unique_product_sku, do: "sku#{System.unique_integer()}"
def unique_product_price, do: :rand.uniform(9999)
def unique_product_title, do: "product#{System.unique_integer()}"
def valid_product_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
sku: unique_product_sku(),
price: unique_product_price(),
title: unique_product_title()
})
end
def product_fixture(attrs \\ %{}) do
{:ok, product} =
attrs
|> valid_product_attributes()
|> Ares.Catalog.create_product()
product
end
end
Fixtures will allow you to create products for test purposes.
You also need to change the config for CubDB, you do not want your test data ending up in your main database.
Open config/test.exs
and add the following:
import Config
config :ares, CubDB,
data_dir: "data/test", # This is our test data folder
name: Ares.Repo # The name stays the same for now
On to our first test! Create a file test/ares/catalog_test.exs
with the following contents:
defmodule Ares.CatalogTest do
@moduledoc """
Tests for the Catalog context
"""
use ExUnit.Case, async: true
alias Ares.Catalog
import Ares.CatalogFixtures
describe "create_product/1" do
test "requires sku to be set" do
attrs = valid_product_attributes() |> Map.delete(:sku)
assert {:error, errors} = Catalog.create_product(attrs)
assert {:sku, "is required"} in errors
end
test "requires price to be set" do
attrs = valid_product_attributes() |> Map.delete(:price)
assert {:error, errors} = Catalog.create_product(attrs)
assert {:price, "is required"} in errors
end
test "creates a product" do
attrs = valid_product_attributes()
{:ok, product} = Catalog.create_product(attrs)
assert is_integer(product.id)
assert product.sku == attrs.sku
assert product.price == attrs.price
assert product.title == attrs.title
end
end
end
Running mix test
should result in 3 successful tests!
Now that you have everything in place you should expand your context and repo to cover the other cases like read, update and delete. When you need inspiration or get stuck you can take a look at https://github.com/hl/ares
The next posts in this series will cover creating an API to query your products, a GenServer implementation for the carts, payment gateways, Elm frontend, and etc.
I hope you enjoyed this post and feel free to reach out in case of questions or improvements.
Photo by Nicolas Lobos on Unsplash
Top comments (0)