Module

Elixir is a functional programming language. Module is used to group functions, like namespace in Clojure.

defmodule Calculator do
  @moduledoc """
  A simple module
  """
  
  @doc """
  Adds two numbers together.
  
  ## Examples
      
      iex> Calculator.add(2, 3)
      5
  """
  def add(a, b) do
    a + b
  end
end

import

Modules can be used in other modules directly like Calculator.add. With import, you can get rid of the prefix.

defmodule MyApp do
  import Calculator

  def sum(a, b, c) do
    # Call add directly instead of Calculator.add
    add(add(a, b), c)
  end
end

Only import specific functions:

defmodule MyApp do
  import Calculator, only: [add: 2]
end

use

use is more powerful.

When use SomeModule is encountered, the __using__/1 macro defined in that module will be called, which can then inject code into the current module.

defmodule Greeter do
  defmacro __using__(opts) do
    quote do
      def hello(name) do
        "Hello, #{name}!"
      end
      
      def greeting_type do
        unquote(opts[:type] || "formal")
      end
    end
  end
end

Use it in another module:

defmodule MyApp do
  use Greeter, type: "casual"
  
  def greet(name) do
    hello(name) <> " Welcome to #{greeting_type()} chat."
  end
end

Module Resolving

When you import or use a module, Elixir doesn't directly look for files - it looks for compiled modules that are available in the current compilation context. Loading paths can be inspected using :code.get_path.

iex -S mix
:code.get_path

Elixir follows a naming convention:

  • Module names use PascalCase
  • Filenames use snake_case.ex
  • Nested modules (like MyApp.User) map to directories (my_app/user.ex)

Basic Types

Boolean

In Elixir, the only values considered falsy are false and nil, same as Clojure.

String

Double quotes (") create a binary string (UTF-8 encoded)

msg = "鯖の塩焼き"
is_binary(msg)
# true
byte_size(msg)
# 15
String.at(msg, 1)
# "の"
byte_size("über")
# 5

Single quotes (') create a character(integer) list, which is deprecated, use ~c if you want a list.

chars = ~c"atoz"
is_list(chars)
# true
hd(chars)
# 97

heredoc

"""
Hello,
Alice
"""
# "Hello,\nAlice\n"

String concatenation

name = "Alice"
"Hello, " <> name <> "!"
# "Hello, Alice!"

String interpolation

name = "Alice"
"Hello, #{name}!"
# "Hello, Alice!"

interpolation not work with ~S and ~C

name = "Alice"
~S"Hello, #{name}!"
# "Hello, \#{name}!"

The String module

import String

name = "Alice"
"Hello, #{upcase(name)}!"
# "Hello, ALICE!"

contains? "abc", "b"
# true

trim " hello\n"
# hello

replace "hello", "e", "a"
# "hallo"

Sigils

Sigils in Elixir are a mechanism for working with textual representations using a compact and specialized syntax. They provide a convenient way to create and manipulate different types of data.

Syntax

~<letter><delimiter><content><delimiter><modifiers>

common <delimiter> are ", ', (, [, {, <, /, but you are free to use other chars.

Built-in Sigils

String: ~s and ~S

one reason to use ~s is that it's easier to include double quotes:

~s(double "quotes")
# "double \"quotes\""

uppercased sigils like ~S ignores interpolation or escaping

~S(\n #{})
# "\\n \#{}"

Character List: ~c and ~C

hd(~c"a")
# 97

Regular Expression: ~r and ~R

"a" =~ ~r/^[a-z]$/
# true

Word List: ~w and ~W

~w(split by space)
# ["split", "by", "space"]
~w(a for atom)a
# [:a, :for, :atom]

Date/Time Sigils: ~D, ~T, ~U, ~N

~D[1970-01-01]
# ~D[1970-01-01]

Custom Sigils

defmodule HTMLSigil do
  def sigil_h(string, _opts) do
    string
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
  end
end

import HTMLSigil
html = ~h(<div>Hello & welcome</div>)
# "&lt;div&gt;Hello &amp; welcome&lt;/div&gt;"

Date and Time

Elixir has four primary date/time types:

  • Date - Just date
  • Time - Just time
  • NaiveDateTime - Date and time without timezone
  • DateTime - Date and time with timezone
Date.new(2025, 5, 2)
# {:ok, ~D[2025-05-02]}
Date.new(2025, 5, 32)
# {:error, :invalid_date}
Time.new(23, 59, 59)
# {:ok, ~T[23:59:59]}
Time.new(24, 0, 0)
# {:error, :invalid_time}
NaiveDateTime.new(2025, 5, 2, 23, 59, 59)
# {:ok, ~N[2025-05-02 23:59:59]}
DateTime.new(Date.utc_today(), Time.utc_now())
# {:ok, ~U[2025-05-02 23:59:59.802817Z]}
DateTime.utc_now()
# ~U[2025-05-02 23:59:59.931917Z]

Manipulations

Date.diff(~D[2025-05-02], ~D[2025-05-08])
# -6
Date.add(~D[2025-05-02], 365)
# ~D[2026-05-02]
DateTime.compare(~U[2025-05-02 23:59:59Z], DateTime.utc_now())
# :lt

Formating

Calendar.strftime(~U[2025-05-02 23:59:59Z], "%Y-%m-%d %H:%M:%S")
# "2025-05-02 23:59:59"
DateTime.shift_zone(~U[2025-05-02 23:59:59Z], "Asia/Tokyo")
# {:error, :utc_only_time_zone_database}

Anonymous Functions

fn a, b -> a + b end

There is more concise form:

&(&1 + &2)

Which reminds me of the Clojure anonymous function shorthand:

#(+ % %2)

The fn ... end form is more versatile, it supports pattern matching in arguments, and allows multiple clauses.

handle_result = fn
  {:ok, value} -> "Success: #{value}"
  {:error, reason} -> "Error: #{reason}"
end

To call a anonymous function directly:

sum = fn a, b -> a + b end
sum.(2, 3)

If you are curious why there is a dot. There is a lenthy explaination.

But usually you don't use anoymous functions this way. In most cases, anonymous functions are passed to other functions:

Enum.reduce([1, 2, 3, 4], 0, &(&1 + &2))

Pipe Operator

Pipe operator |>:

import String

"  hello  "
  |> trim
  |> upcase

Similar to Clojure's threading macro:

(require '[clojure.string :refer [trim upper-case]])

(-> "  hello  "
    trim
    upper-case)

It is worth mentioning that Clojure's threading macro is more versertile with variations like ->>, as-> and cond->.

Collections

Tuple

tp = {:ok, "value"}

Tuple elements are usually accessed via pattern matching:

{:ok, value} = tp
value
# "value"

But they can also be accessed using elem:

elem(tp, 0)
# :ok

Lists

xs = [1, 2, 3]
hd(xs)
# 1
tl(xs)
# [2, 3]
[first | rest] = xs
rest
# [2, 3]
[first | rest]
# [1, 2, 3]
xs ++ [4]
# [1, 2, 3, 4]

Accessing elements by index is an O(n):

Enum.at(xs, 1)
# 2
Enum.at(xs, 3)
# nil
Enum.at(xs, 3, :default_val)
# :default_val

Operations map, filter on Lists are more common:

Enum.map([1, 2, 3], fn x -> x * 2 end)
# [2, 4, 6]
Enum.filter([1, 2, 3], &rem(&1, 2) == 0)
# [2]
Enum.reduce([1, 2, 3, 4], 0, &+/2)
# 10

Keyword Lists

A keyword list is a list of two-element tuples where the first element (the key) is an atom.

foo = [{:a, 1}, {:a, 2}, {:b, nil}]
# [a: 1, a: 2, b: nil]

Elixir's keyword lists are conceptually very similar alists in Emacs Lisp.

'((first-name . "John") (last-name . "Doe") (age . 30))

Elixir provides a more concise syntax sugar for keyword lists:

foo = [a: 1, a: 2, b: nil]
# [a: 1, a: 2, b: nil]
hd(foo)
# {:a, 1}
foo[:a]
# 1
Keyword.get_values(foo, :a)
# [1, 2]
Keyword.has_key?(foo, :b)
# true
foo[:c]
# nil

Keyword Lists are usually small(access is O(n)).

A great use case is for passing optional named arguments to functions:

String.split "hello world  ", " ", trim: true
# ["hello", "world"]
defmodule Greeter do
  def greet(name, opts \\ []) do
    greeting = Keyword.get(opts, :greeting, "Hello")
    "#{greeting}, #{name}!"
  end
end

Greeter.greet("Alice")
# "Hello, Alice!"

Greeter.greet("Alice", greeting: "Hi")
# "Hi, Alice!"

This is usually achieved by using a map in other languages. In Clojure it's keyword arguments:

(defn greet [name & {:keys [greeting] :or {greeting "Hello"}}]
  (str greeting ", " name "!"))

(greet "Alice" )
; "Hello, Alice!"
(greet "Alice" :greeting "Hi")
; "Hi, Alice!"

Map

map = %{:name => "Alice", "age" => 7, 7 => nil, true => false}
# %{7 => nil, true => false, :name => "Alice", "age" => 7}

map["age"]
# 7

map[:name]
# "Alice"

map.name
# "Alice"

map[:no_such_key]
# nil

Map.get(map, :no_such_key, :value)
# :value

Map.fetch(map, :name)
# {:ok, "Alice"}

Map.fetch(map, :no_such_key)
# :error

Map.new([{:name, "Alice"}, {map, nil}])
# %{
#   :name => "Alice",
#   %{7 => nil, true => false, :name => "Alice", "age" => 7} => nil
# }
map = %{key: 1}
# %{key: 1}

%{map | key: 2}
# %{key: 2}
%{map | k2: 1}
# ** (KeyError) key :k2 not found in: %{key: 1}

Map.put(map, :key, 2)
# %{key: 2}
Map.put(map, :key2, 2)
# %{key: 1, key2: 2}
%{a: 1, b: 2} |> Enum.map(fn {k, v} -> {k, v * 2} end)
# [a: 2, b: 4]
%{a: 1, b: 2} |> Enum.map(fn {k, v} -> {k, v * 2} end) |> Map.new
%{a: 2, b: 4}

MapSet

set = MapSet.new(["a", "b", "c"])

MapSet.member?(set, "b")
# true

MapSet.put(set, "b")
# MapSet.new(["a", "b", "c"])

MapSet.delete(set, "d")
# MapSet.new(["a", "b", "c"])

MapSet.union(set, MapSet.new(["c", "d"]))
# MapSet.new(["a", "b", "c", "d"])

MapSet.intersection(set, MapSet.new(["c", "d"]))
# MapSet.new(["c"])

MapSet.difference(set, MapSet.new(["c", "d"]))
# MapSet.new(["a", "b"])

MapSet.subset?(MapSet.new(["c"]), set)
# true

Struct

defmodule User do
  defstruct name: "", age: nil
end

user = %User{name: "Alice", age: 7}

user.age
# 7

%User{ user | age: 8}
# %User{name: "Alice", age: 8}
defmodule User do
  defstruct [:name, :age]

  def child?(%User{age: age}) do
    is_integer(age) and age < 18
  end
end

User.child?(%User{name: "Alice", age: 7})
# true

Working with enumerable data types

Enum provides a wide range of functions for working with enumerable data types. Combined with pipe operator, it can pretty clear and concise.

List comprehension is even more concise for specific operations, namely map and filter.

1..10
|> Enum.filter &rem(&1, 2) == 0
|> Enum.map &(&1*&1)
for x <- 1..10, rem(x, 2) == 0, do: x * x

There is also into:

%{a: 1, b: 2}
|> Enum.map(fn {k, v} -> {k, v * 2} end)
|> Map.new
for {k, v} <- %{a: 1, b: 2}, into: %{}, do: {k, v * 2}

Pattern Matching

Elixir has first-class pattern matching support.

Control Flow

case {:ok, 1} do
  {:ok, value} -> value
  :error -> 0
end

In Rust:

#![allow(unused)]
fn main() {
    println!("{}", match Some(1) {
        Some(value) => value,
        None => 0
    });
}

Function Parameters

defmodule Greeter do
  def greet({:ok, name}), do: "Hello, #{name}"
  def greet(:error), do: "Error occurred"
end

Greeter.greet({:ok, "Alice"})
#![allow(unused)]
fn main() {
    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
    
    fn greet(result: Result<&str, &str>) -> String {
        match result {
            Result::Ok(name) => format!("Hello, {}", name),
            Result::Err(_) => "Error occurred".to_string(),
        }
    }
    
    println!("{}", greet(Result::Ok("Alice")));
}

Guards

defmodule Number do
  def sign(n) when n > 0, do: :positive
  def sign(n) when n < 0, do: :negative
  def sign(0), do: :zero
end
#![allow(unused)]
fn main() {
    fn sign(n: i32) -> &'static str {
        match n {
            x if x > 0 => "positive",
            x if x < 0 => "negative",
            _ => "zero",
        }
    }
    println!("{}", sign(-1));
}

Wildcards

case value do
  {:ok, v} -> IO.puts("Got #{v}")
  _ -> IO.puts("Unknown")
end
match value {
    Some(v) => println!("Got {}", v),
    _ => println!("Unknown"),
}

It is worth mentioning that Rust has very useful compile-time exhaustiveness check, which is based on type system.

Error Handling

Pattern Matching

case File.read("example.txt") do
  {:ok, content} -> 
    IO.puts("File content: #{content}")
  {:error, reason} -> 
    IO.puts("Error reading file: #{reason}")
end

with

with is handy for match result of a serial of operations:

with {:ok, file} <- File.open("example.txt"),
     content = IO.read(file),
     :ok <- File.close(file),
     {:ok, data} <- Jason.decode(content) do
  data
else
  {:error, reason} -> 
    IO.puts("Error: #{reason}")
  _ -> 
    IO.puts("Unknown error occurred")
end

with allows you to focus on the successful path of the computation. It is called "Railway-oriented programming".

This is a common approach in functional programming languages and others where exceptions aren't the primary error handling mechanism.

In haskell there is do and >>=.

In rust to work with Result<T, E> and Option<T> there is ? operator.

In JavaScript, it's Promise chaining using .then().

try/rescue

For catching exceptions(not common in idiomatic Elixir):

try do
  String.to_integer("not_a_number")
rescue
  ArgumentError -> "Invalid argument"
  e in RuntimeError -> "Runtime error: #{e.message}"
  _ -> "Unknown error"
after
  # like finally in other languages
  IO.puts("Operation attempted")
end

Handling process termination

# trap to prevent the parent process from exiting
Process.flag(:trap_exit, true)

spawn_link(fn ->
  exit("I am exiting")
end)

Task

task = Task.async(fn -> 
  File.read!("missing_file.txt")
end)

case Task.yield(task, 5000) || Task.shutdown(task) do
  {:ok, result} -> 
    result
  nil -> 
    "Timeout"
  {:exit, reason} -> 
    IO.puts inspect(reason)
end

Polymorphism

Protocol

Protocol is a manchanism for dispatching behavior based on data type, kind of like interface in other languages.

defprotocol Speak do
  def speak(data)
end

defmodule Dog do
  defstruct [:name]
end

defimpl Speak, for: Dog do
  def speak(%Dog{name: name}), do: "#{name} says woof!"
end

Speak.speak(%Dog{name: "Rex"})
# "Rex says woof!"

Elixir's Protocol is conceptually similar to Clojure's protocol:

(defprotocol Speak
  (speak [this]))

(defrecord Dog [name])

(extend-type Dog
  Speak
  (speak [{:keys [name]}]
    (str name " says woof!")))
    
(let [rex (->Dog "Rex")]
  (speak rex))

Behavior

Behaviour is a formal way to define interfaces that modules should implement.

defmodule MyBehaviour do
  @callback hello(String.t()) :: String.t()
end
defmodule MyModule do
  @behaviour MyBehaviour

  @impl MyBehaviour
  def hello(name), do: "Hello, #{name}"
end

Behaviour conformance is checked at compile time. The compiler will warn if any required callback is missing or arity doesn't match.

Macros

defmodule Demo do
  defmacro unless(condition, do: expression) do
    quote do
      if !unquote(condition), do: unquote(expression)
    end
  end
end

Similar to Clojure's:

(ns demo)

(defmacro unless [condition body]
  `(if (not ~condition)
     ~body))

Spec

defmodule App1.Math do
  @spec sum(integer(), integer()) :: integer()
  def sum(a, b) do
    a + b
  end
end

Custom Type

defmodule App1.User do
  @type t() :: %__MODULE__{id: integer(), name: String.t()}
  defstruct name: "", id: 0
end
defmodule App1.Greeter do
  alias App1.User
  @spec greet(User.t()) :: String.t()
  def greet(%User{name: name}) do
    "Hello, #{name}"
  end
end

Hex

hex is the package manager and repository for Elixir (and Erlang) libraries and packages.

Login

mix hex.user register

or

mix hex.user auth

Create a new package

mix new my_pkg

mix.exs

defmodule MyPkg.MixProject do
  use Mix.Project

  def project do
    [
      name: "MyPkg",
      app: :my_pkg,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      description: description(),
      package: package(),
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:ex_doc, "~> 0.29", only: :dev, runtime: false},
    ]
  end

  defp description() do
    """
    A Elixir library that ...
    """
  end

  defp package() do
    [
      licenses: ["MIT"],
      links: %{
        "GitHub" => "https://github.com/foo/mypkg"
      }
    ]
  end
end

Publish

mix hex.build

mix hex.publish

Routing

Start postgres:

docker run --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres

Create a new project:

mix phx.new app1 --live
cd app1
mix ecto.create
mix phx.server

Take a look at router.ex:

defmodule App1Web.Router do
  use App1Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {App1Web.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", App1Web do
    pipe_through :browser
    get "/", PageController, :home
  end
end

use App1Web, :router

This will inject some code defined in app1_web.ex in to current Router module:

defmodule App1Web do
  # ...
  def router do
    quote do
      use Phoenix.Router, helpers: false
      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end
  # ...
end

pipeline & plug

pipeline and plug come from Plug, which is Phoenix's middleware mechanism.

A Plug manipulates a connection, in other languages and frameworks middleware usually manipulates Request and Response.

A Plug can be a function or a module. Let's take module as an example:

defmodule App1Web.Plugs.SayHello do
  import Plug.Conn

  def init(default), do:
    IO.puts "SayHello Plug is Ready"
  end

  def call(conn, _opts) do
    IO.puts "Hello from the plug!"
    Plug.Conn.put_resp_header(conn, "x-say-hello", "Hello")
    conn
  end
end

And obviously a pipeline is a collection of middlewares. Insert the middleware to the top of the list:

  pipeline :browser do
    plug App1Web.Plugs.SayHello
    # ...
  end

It is worth mentioning that Phoenix provides compile-time checked verified routes. Errors such as misspelled route names or incorrect parameters when generating paths/URLs will be caught by the compiler.

Controller

Phoenix adopt an architectural pattern similar to the MVC pattern.

In controller we need to find out:

  • How to access request data, like headers, body and query parameters.
  • How to invoke models.
  • How to render template.

Take a look at lib/app1_web/controllers/page_controller.ex:

defmodule App1Web.PageController do
  use App1Web, :controller

  def home(conn, _params) do
    render(conn, :home, layout: false)
  end
end

Take a look at lib/app1_web.ex:

  def controller do
    quote do
      use Phoenix.Controller,
        formats: [:html, :json],
        layouts: [html: App1Web.Layouts]

      use Gettext, backend: App1Web.Gettext

      import Plug.Conn

      unquote(verified_routes())
    end
  end

Accessing Request Data

_params is a map of query string and form inputs:

  def home(conn, _params) do
    render(conn, :home, layout: false)
  end

Try to add query string with curl:

curl 'localhost:4000?key=1&key=2'

It will be printed on the command line:

[debug] Processing with App1Web.PageController.home/2
  Parameters: %{"key" => 2}
  Pipelines: [:browser]

See if array works:

curl 'localhost:4000?key[]=1&key[]=2'
%{"key" => ["1", "2"]}

Add a routing rule for POST in router.ex:

post "/", PageController, :home
curl -d 'foo=bar' 'localhost:4000?key[]=1&key[]=2'
%{"foo" => "bar", "key" => ["1", "2"]}

JSON also works:

curl -d '{"ok":true}' -H 'content-type: application/json' \
  'localhost:4000?key[]=1&key[]=2'
%{"key" => ["1", "2"], "ok" => true}

Rendering Template

The controler simply called render, add passed :home.

In lib/app1_web/controllers/page_html.ex, it doesn't do much, just called embed_templates and passed the director path.

defmodule App1Web.PageHTML do
  use App1Web, :html

  embed_templates "page_html/*"
end

The embed_templates macro expands to following:

def home(assigns) do
  ~H"""
  <.flash_group flash={@flash} />
  ...
  """
end

And the home function will be called by render.

View

  • How to pass variables to template?
  • How to handle lists?
  • How to handle branching?
  • How to escape dangerous content?

Passing Variables

Take a look at lib/app1_web/controllers/page_html/home.html.heex. Replace it with following content:

<div>{ @message }</div>

Variables can be passed directly to render:

render(conn, :home, layout: false, message: "Hello")

You can also assign variables to conn:

conn
|> assign(:user, %{:name => "Alice"})
|> render(:home, layout: false)
<div>{@user.name}</div>

It is worth mentioning that assign is imported from Plug.Conn and it's immutable in behavior.

Render a list

<ul>
<%= for user <- @users do %>
  <li>{user.name}</li>
<% end %>
</ul>

Or use the :for attribute, which is more concise:

<ul :for={user <- @users}>
  <li>{user.name}</li>
</ul>

Branching

<%= if @logged_in do %>
  <p>Welcome back!</p>
<% else %>
  <p>Please log in.</p>
<% end %>

Elixir is more expressive than average programming languages, and it offers case and cond:

<%= case @user.role do %>
  <% "admin" -> %>
    <p>You're an admin.</p>
  <% "member" -> %>
    <p>You're a member.</p>
  <% _ -> %>
    <p>Unknown role.</p>
<% end %>
<%= cond do %>
  @user.age < 13 ->
    <p>You're a child.</p>
  @user.age < 20 ->
    <p>You're a teenager.</p>
  true ->
    <p>You're an adult.</p>
<% end %>

Escaping

Dangerous or untrusted content (like user input) must be escaped to prevent XSS attacks.

Phoenix's HEEx templates automatically escape content by default.

To render trusted content without excapping:

<%= raw("<b>Check you console!</b>") %>
<%= raw("<script>console.log('code executing!')</script>") %>

Layouts

Layouts are a way to define a shared HTML structure (like headers, navbars, footers) that wraps around your templates.

In lib/app1_web/components/layouts.ex, embed_templates is called to embed the template under the layouts folder as functions.

To render with the default layout:

render(conn, :home)

Custom Layout

Create a custom layout at lib/app1_web/components/layouts/admin.html.heex.

<header><h1>Admin Panel</h1></header>
<main><%= @inner_content %></main>
<footer><p>© 2025 Demo</p></footer>

The custom layout will also be wrapped with the root layout. So no need for a full html structure.

Specify the layout with put_layout:

conn
|> put_layout(html: :admin)
|> render(:home)

Component

<.button>Submit</.button>

Define custom component:

defmodule App1Web.CoreComponents do
  use Phoenix.Component

  def alert(assigns) do
    ~H"""
    <div class={"alert alert-#{@type || "info"}"}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end
end
<.alert type="error"><div>Something went wrong!</div></.alert>

Context

The Model in traditional MVC frameworks are splited into context and schema in Phoenix.

Scaffolding

Following command will generate a full CRUD API for a Post resource inside a Blog context.

mix phx.gen.json Blog Post posts title:string content:text

The command will genrate a bunch of files,

* lib/app1_web/controllers/post_controller.ex
* lib/app1_web/controllers/post_json.ex
* lib/app1_web/controllers/changeset_json.ex
* lib/app1_web/controllers/fallback_controller.ex
* lib/app1/blog/post.ex
* lib/app1/blog.ex
...
more db migration and other test related files

Add a routing rule to the "/api" scope in lib/app1_web/router.ex:

resources "/posts", PostController, except: [:new, :edit]

Then run mix ecto.migrate to apply the database schema changes.

The API should be ready.

curl localhost:4000/api/posts

Will return:

{"data":[]}

Context

What is a context? Take a look at lib/app1/blog.ex. It containers following functions:

  • list_posts
  • get_post
  • create_post
  • update_post
  • delete_post
  • change_post

And in those functions, App1.Repo is invoke to talk to database:

def list_posts do
  Repo.all(Post)
end

Here Post is a schema module defined in lib/app1/blog/post.ex.

In Phoenix, it's

Controller → Context → Repo → Schema

In Spring, it's

Controller → Service → Repository → Entity

Schema

Schema definition is stright forward, what's interesting here is changeset:

defmodule App1.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :content, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> validate_required([:title, :content])
  end
end

changeset from Ecto is for validation and transformation. It will called in context before inserting the data into database.

def create_post(attrs \\ %{}) do
  %Post{}
  |> Post.changeset(attrs)
  |> Repo.insert()
end

Try to call API with invalid request body:

curl -id '{"post": {}}' -H 'content-type: application/json'\
  localhost:4000/api/posts

Will return:

HTTP/1.1 422 Unprocessable Entity
...

{"errors":{"title":["can't be blank"],"content":["can't be blank"]}}

Ecto

Ecto is an ORM, or more precisely a "language integrated query" (LINQ) library for Elixir.

Schema

defmodule App1.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer

    timestamps() # Adds :inserted_at and :updated_at fields
  end
end

Changeset

Change set is a way to track and validate changes to data before persisting them.

  • cast selects the allowed fields from input.
  • validate_* functions add errors if rules are broken.
  • changeset function is usually the place to define these rules, you may use separated functions for insert and update or other purpose.
defmodule App1.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer

    timestamps() # Adds :inserted_at and :updated_at fields
  end

  def changeset(user, attrs) do
    user
    |> Ecto.Changeset.cast(attrs, [:name, :email, :age])
    |> Ecto.Changeset.validate_required([:name, :email])
    |> Ecto.Changeset.validate_number(:age, greater_than_or_equal_to: 0)
    |> Ecto.Changeset.unique_constraint(:email, name: :users_email_index)
  end
end

Create a Changeset:

alias App1.User

%User{}
|> User.changeset(%{name: "Alice"})

#Ecto.Changeset<
  action: nil,
  changes: %{name: "Alice"},
  errors: [email: {"can't be blank", [validation: :required]}],
  data: #App1.User<>,
  valid?: false,
  ...
>

changeset.valid? is false and changeset.errors contains the validation messages. Typically these errors will returned to the user (e.g., in a web form).

Fix the Changeset:

%User{}
|> User.changeset(%{name: "Alice", email: "alice@example.com", age: 30})

#Ecto.Changeset<
  action: nil,
  changes: %{name: "Alice", email: "alice@example.com", age: 30},
  errors: [],
  data: #App1.User<>,
  valid?: true,
  ...
>

Now changeset.valid? is true and changeset.errors is empty. And this changeset can be inserted into the database using Repo.insert(changeset).

Repo

A repository module is needed to interact with the database.

defmodule App1.Repo do
  use Ecto.Repo,
    otp_app: :app1,
    adapter: Ecto.Adapters.Postgres
end

In config/config.exs:

config :app1, App1.Repo,
  url: System.get_env("DATABASE_URL"),

Inserting

alias App1.Repo

%User{}
|> User.changeset(%{name: "Alice", email: "alice@example.com", age: 30})
|> Repo.insert() # Returns {:ok, user} or {:error, changeset}

Updating

{:ok, user} = Repo.get(User, 1)
user
|> User.changeset(%{age: 7})
|> Repo.update()
User
  |> Query.where(id: 1)
  |> Repo.update_all(inc: [age: 1])

Query

Repo.all(App1.User)
Repo.get_by(App1.User, email: "alice@example.com")

Use the from macro:

import Ecto.Query

from u in User,
  where: u.age > 18,
  order_by: u.name,
  select: [u.id, u.email]
|> Repo.all

Since from is a macro, you need to unquote variables using ^:

from u in User,
  where: u.age > ^min_age

Tackle the N+1 Problem

users = Repo.preload(Repo.all(User, :posts))

Channels

Channels are Phoenix's abstraction of WebSockets:

defmodule MyAppWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end

Scaling across multiple servers

Channels use Phoenix.PubSub under the hood to broadcast messages between processes. By default, it's local to the node and can be configured for distributed systems,

In config.exs:

config :my_app, MyAppWeb.Endpoint,
  pubsub_server: MyApp.PubSub

config :my_app, MyApp.PubSub,
  adapter: Phoenix.PubSub.Redis,
  url: "redis://localhost:6379"

LiveView

Modern web applications usually render views in the browser using JavaScript. LiveView renders HTML on the server and pushes changes to the client through diffs over WebSockets.

A Counter

defmodule App1Web.CounterLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :count, 0)}
  end

  def handle_event("increment", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <div>
      <p>Count: { @count }</p>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

Add a routing rule under scope "/":

live "/counter", CounterLive

The counter is now ready at http://localhost:4000/counter.

Anatomy of a LiveView Component

The LiveView component is a module with specific functions like mount, render and handle_event. Similar to React's class component, those are called lifecycle callbacks, as they are invoked at different stages.

  • mount: Invoked once when the component is first added to the page or the LiveView process is started. It's used to initialize the component's state (assigns).

  • handle_event: Handles events originating from the client (e.g., button clicks).

  • render: Called to generate HTML.

  • update: Called whenever the assigns passed to the component from its parent LiveView change. It's an opportune place to update the component's internal state based on the new assigns.

A Rough Comparation with React

Life Cycle Callbacks:

LiveView ComponentReact Class Component
mount(params, session, socket)componentDidMount()
handle_event(event, params, socket)custom functions
render(assigns)render()
update(assigns, socket)componentDidUpdate(prevProps, prevState)

Event Handling:

LiveView ComponentReact Class Component
phx-clickonClick
phx-changeonChange

State Management:

LiveView ComponentReact Class Component
assign(socket, state)this.setState(state)
update(socket, key, fn)this.setState(fn)

Thoughts on LiveView

The ultimate goal of LiveView is to create dynamic server-rendered applications without writing JavaScript.

It is kind of a unique and intereting solution from Phoenix.

  • States are kept on the server, not in the browser, rendering is also done on the server.
  • After rendering, a diff with previous rendering result is done and only the diff is sent to the browser, in order to minimize the payload.

Why is the idea not adapted in other frameworks?

  • It relies on WebSocket, which is cheap in Phoenix but a luxury for a lot of other frameworks.
  • It's a back-end oriented solution, the JavaScript community is front-end centric, and LiveView solves a problem that they don't have.

LiveView is a good fit for

  • Chats, monitoring(real-time updates)
  • Dashboards, admin panels(limited number of concurrent users, relative simple interaction)
  • Developers with limited front-end skills

Not suitable for

  • UI-heavy apps with complex states
  • Apps with extreme high-concurrency usage
  • Mobile-first apps on unstable networks, or apps with offline usage

Debugging

printing

op()
|> IO.inspect(label: "op", limit: :infinity)
op()
|> dbg()

tracing

:dbg.tracer()
:dbg.p(:all, :c) # :c for call trace
:dbg.tpl(App1Web.PageController, :home, 2, []) # tpl for trace process local
:dbg.stop_clear()

breaking point

IEx.pry starts an interactive IEx session at a specific point during code execution.

  • You can insert IEx.pry() into your code.
  • When execution reaches IEx.pry(), it pauses and drops you into an IEx shell.
  • Inside the pry session, you can inspect local variables, evaluate expressions, and resume execution.

IEx.break!

The iex --dbg pry command line option configures IEx to automatically enter a pry session when a breakpoint is hit or certain errors occur.

iex --dbg pry -S mix

IEx.break! will leads to a pry session when the breakpoint is hit.

IEx.break!(App1Web.PageController, :home, 2)

recon

REmote CONtrol, it can be used in prod env.

defmodule M, do: (def incr(n), do: n + 1)

:recon_trace.calls({M, :incr, :_}, 3)

M.incr(0)

The maching pattern here is {mod, fn, arity}, and it limited to 3 times.

:recon_trace.calls/2 returns 0 if instruction is not successful, this happens when the module is not loaded or the function spec is incorrect.

maching parameters

Only tracing calls where paramters are [1]:

:recon_trace.calls({M, :incr, [{[1], [], []}]}, 3)
M.incr(0)
M.incr(1)

tracing result

:recon_trace.calls({M, :incr, [{[:_], [], [{:return_trace}]}]}, 3)
# or simply:
:recon_trace.calls({M, :incr, :return_trace}, 3)
M.incr(0)
12:24:58.761345 <0.658.0> 'Elixir.M':incr(0)
1
Recon tracer rate limit tripped.

tracing all calls to modules matching a prefix

prefix = "Elixir.App1Web."

:code.all_loaded()
  |> Enum.map(fn {mod, _file} -> mod end)
  |> Enum.filter(&String.starts_with?(Atom.to_string(&1), prefix))
  |> Enum.each(&:recon_trace.calls({&1, :_, :return_trace}, 100))

Test

ExUnit

ExUnit is Elixir's built-in testing framework. It's integrated with Mix.

A basic example

1. Code to Test (lib/my_math.ex)

defmodule MyMath do
  def add(a, b) do
    a + b
  end
end

2. Test File (test/my_math_test.exs)

defmodule MyMathTest do
  use ExUnit.Case
  doctest MyMath  # Run doctests found in lib/my_math.ex (optional but good practice)
  
  test "adds two numbers" do
    assert MyMath.add(1, 2) == 3
    assert MyMath.add(0, 0) == 0
    assert MyMath.add(-1, 1) == 0
  end
end

3. Running Tests

mix test

Mix will compile your code and run all files matching test/*_test.exs. To test a single file:

mix test test/my_math_test.exs

To get coverage reporting:

mix --cover test

More

  • describe
  • setup

Mock

Mock is a testing library that allows you to temporarily replace the behavior of modules or functions during tests.

Deploy

Create a simple app

To create a new Phoenix project, open your terminal and run the following command:

mix phx.new ping_pong_app --no-html --no-ecto --no-dashboard

Navigate into your new project directory:

cd ping_pong_app

~/pg/cloud/ping_pong_app/lib/ping_pong_app_web/controllers/page_controller.ex

defmodule PingPongAppWeb.Controllers.PageController do
  use PingPongAppWeb, :controller

  def index(conn, _params) do
    data = %{
      message: "Hello, world!"
    }
    json(conn, data)
  end
end

~/pg/cloud/ping_pong_app/lib/ping_pong_app_web/router.ex

  scope "/api", PingPongAppWeb do
    pipe_through :api

    get "/ping", Controllers.PageController, :index
  end

Prepare a Dockerfile

~/pg/cloud/ping_pong_app/Dockerfile

FROM elixir:1.16.0-alpine as builder

RUN apk add --no-cache build-base git openssl-dev ca-certificates

WORKDIR /app

COPY mix.exs mix.lock ./
COPY config config/
COPY lib lib/
COPY priv priv/
COPY assets assets/

RUN mix deps.get --only prod
RUN mix deps.compile

ENV MIX_ENV=prod
RUN mix compile

RUN mix release

FROM alpine:3.18

RUN apk add --no-cache openssl ncurses-libs libstdc++

WORKDIR /app

COPY --from=builder /app/_build/prod/rel/ping_pong_app ./

ENV PORT=8080
EXPOSE 8080

CMD ["/app/bin/ping_pong_app", "start"]

google cloud

gcloud auth login
gcloud projects list
echo 'export GCLOUD_PROJECT_ID=<your project id>'
direnv allow
gcloud config set project $GCLOUD_PROJECT_ID

Build and Push Image

docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap
brew install docker-credential-helper
gcloud auth configure-docker
docker buildx build \
  --platform linux/amd64 \
  -t gcr.io/$GCLOUD_PROJECT_ID/ping-pong-app:v1 \
  --push .
docker push gcr.io/$GCLOUD_PROJECT_ID/ping-pong-app:v1

Create a GKE cluster

gcloud container clusters create ping-pong-cluster \
    --zone us-central1-c \
    --num-nodes 1 \
    --machine-type e2-micro \
    --enable-ip-alias

Authenticates your local kubectl CLI with the GKE cluster:

gcloud components install gke-gcloud-auth-plugin
gcloud container clusters get-credentials ping-pong-cluster --zone us-central1-c
kubectl get node

Prepare k8s configuration

~/pg/cloud/ping_pong_app/k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ping-pong-deployment
  labels:
    app: ping-pong-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ping-pong-app
  template:
    metadata:
      labels:
        app: ping-pong-app
    spec:
      containers:
      - name: ping-pong-app
        image: gcr.io/YOUR_PROJECT_ID/ping-pong-app:v1 # Replace with your project ID
        ports:
        - containerPort: 8080
        env:
        - name: HOST
          value: "0.0.0.0"
        - name: PORT
          value: "8080"

~/pg/cloud/ping_pong_app/k8s/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: ping-pong-service
spec:
  selector:
    app: ping-pong-app # Selects pods with this label
  ports:
    - protocol: TCP
      port: 80 # The port the service exposes
      targetPort: 8080 # The port on the pod to forward traffic to
  type: LoadBalancer # Creates an external IP address

Deploy

kubectl apply -f k8s/
deployment.apps/ping-pong-deployment created
service/ping-pong-service created
kubectl get pods -l app=ping-pong-app

kubectl get pods -l app=ping-pong-app -o jsonpath='{.items[0].metadata.name}'

kubectl logs YOUR_POD_NAME

Visit your app

kubectl get service ping-pong-service

Clean up

kubectl delete -f k8s/
gcloud container clusters delete ping-pong-cluster --zone us-central1-c
gcloud container images list
gcloud container images list-tags gcr.io/$GCLOUD_PROJECT_ID/ping-pong-app
gcloud container images delete gcr.io/$GCLOUD_PROJECT_ID/ping-pong-app:v1