DEV Community

Cover image for The First E-commerce Shop on Mars
Henricus Louwhoff
Henricus Louwhoff

Posted on • Edited on

The First E-commerce Shop on Mars

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open up your favorite code editor and put this in config/config.exs:

import Config

import_config "#{config_env()}.exs"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 and updated_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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)