Modules and Structs

Organize code with modules, create custom data types with structs, implement protocols, and define behaviors.

Modules

Modules are collections of functions - the primary way to organize code in Elixir.

Basic Module

defmodule Math do
  def add(a, b), do: a + b
  def multiply(a, b), do: a * b
end

Math.add(2, 3)      # => 5
Math.multiply(4, 5) # => 20

Module Attributes

Compile-time constants and metadata:

defmodule Config do
  @default_timeout 5000
  @version "1.0.0"
  
  def timeout, do: @default_timeout
  def version, do: @version
end

Config.timeout()  # => 5000

Module Attributes as Accumulators

defmodule Routes do
  @routes []
  
  @routes [{:get, "/users", :index} | @routes]
  @routes [{:post, "/users", :create} | @routes]
  
  def routes, do: @routes
end

Routes.routes()
# => [{:post, "/users", :create}, {:get, "/users", :index}]

Reserved Attributes

defmodule MyModule do
  @moduledoc """
  This module does X, Y, and Z.
  """
  
  @doc """
  Performs operation X.
  """
  def operation_x, do: :ok
  
  @doc false  # Hide from documentation
  def internal_function, do: :secret
end

Nested Modules

defmodule Company do
  defmodule Employee do
    def new(name), do: %{name: name}
  end
  
  defmodule Department do
    def new(name), do: %{name: name}
  end
end

Company.Employee.new("Alice")
# => %{name: "Alice"}

Can also define separately:

defmodule Company.Employee do
  def new(name), do: %{name: name}
end

defmodule Company.Department do
  def new(name), do: %{name: name}
end

Aliases

Shorten module names:

defmodule MyApp.Service do
  # Full name every time
  def process do
    MyApp.Utils.Helper.format("data")
  end
end

# With alias
defmodule MyApp.Service do
  alias MyApp.Utils.Helper
  
  def process do
    Helper.format("data")
  end
end

# Multiple aliases
alias MyApp.Utils.{Helper, Formatter, Validator}

# Alias with custom name
alias MyApp.VeryLongModuleName, as: Short

Imports

Bring functions into current scope:

defmodule Example do
  import List, only: [flatten: 1, duplicate: 2]
  
  def test do
    flatten([[1, 2], [3, 4]])    # => [1, 2, 3, 4]
    duplicate("x", 3)             # => ["x", "x", "x"]
  end
end

# Import everything (use sparingly)
import String

# Import except
import Enum, except: [map: 2]

# Import only macros or functions
import MyModule, only: :macros
import MyModule, only: :functions

Require

Load macros from a module:

defmodule Example do
  require Integer
  
  def is_odd?(n) do
    Integer.is_odd(n)  # Macro from Integer module
  end
end

Use

Inject code into a module (common with frameworks):

defmodule MyApp.User do
  use Ecto.Schema
  
  schema "users" do
    field :name, :string
    field :age, :integer
  end
end

# `use` calls __using__/1 macro in the target module
# Equivalent to running code at compile time

Structs

Custom data types with named fields and pattern matching.

Defining a Struct

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

# Or with default values
defmodule User do
  defstruct name: "Unknown", age: 0, email: ""
end

Creating Structs

# All fields
user = %User{name: "Alice", age: 30, email: "alice@example.com"}

# Partial (others use defaults)
user = %User{name: "Alice"}
# => %User{name: "Alice", age: 0, email: ""}

# Must use defined fields only
user = %User{invalid: "field"}
# ** (KeyError) key :invalid not found

Accessing Struct Fields

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

# Dot notation
user.name  # => "Alice"
user.age   # => 30

# Pattern matching
%User{name: name, age: age} = user
# name = "Alice", age = 30

# Match specific fields
%User{name: "Alice"} = user  # Succeeds
%User{name: "Bob"} = user    # ** (MatchError)

Updating Structs

Structs are immutable - create new ones:

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

# Update one field
updated = %{user | age: 31}
# => %User{name: "Alice", age: 31, email: ""}

# Update multiple fields
updated = %{user | age: 31, email: "alice@new.com"}

# Cannot add new fields
updated = %{user | nickname: "Ally"}
# ** (KeyError)

Struct Modules with Functions

defmodule User do
  defstruct [:name, :age, :email]
  
  def new(name, age, email) do
    %User{name: name, age: age, email: email}
  end
  
  def full_info(%User{name: name, age: age}) do
    "#{name} (#{age} years old)"
  end
  
  def adult?(%User{age: age}), do: age >= 18
end

user = User.new("Alice", 30, "alice@example.com")
User.full_info(user)  # => "Alice (30 years old)"
User.adult?(user)     # => true

Enforced Keys

Require fields to be set:

defmodule User do
  @enforce_keys [:name, :email]
  defstruct [:name, :email, age: 0]
end

User.new("Alice", "alice@example.com")  # Needs helper function

%User{name: "Alice"}
# ** (ArgumentError) :email is required

%User{name: "Alice", email: "alice@example.com"}
# => Works!

Struct vs Map

user_struct = %User{name: "Alice"}
user_map = %{name: "Alice"}

# Structs have __struct__ field
Map.keys(user_struct)
# => [:__struct__, :name, :age, :email]

user_struct.__struct__
# => User

# Structs are maps
is_map(user_struct)  # => true

# But not all maps are structs
is_struct(user_map)  # => false
is_struct(user_struct)  # => true

Protocols

Define polymorphic functions - same function, different implementations for different types.

Built-in Protocols

# String.Chars - to_string/1
to_string(123)        # => "123"
to_string(:atom)      # => "atom"
to_string([1, 2, 3])  # => "[1, 2, 3]"

# Inspect - inspect/1
inspect(%{a: 1})      # => "%{a: 1}"

# Enumerable - allows Enum functions
Enum.map([1, 2, 3], &(&1 * 2))
Enum.map(1..5, &(&1 * 2))

Defining a Protocol

defprotocol Size do
  @doc "Calculates the size"
  def size(data)
end

Implementing for Different Types

defimpl Size, for: BitString do
  def size(string), do: String.length(string)
end

defimpl Size, for: List do
  def size(list), do: length(list)
end

defimpl Size, for: Map do
  def size(map), do: map_size(map)
end

Size.size("hello")        # => 5
Size.size([1, 2, 3])      # => 3
Size.size(%{a: 1, b: 2})  # => 2

Implementing for Structs

defmodule User do
  defstruct [:name, :email]
end

defimpl Size, for: User do
  def size(_user), do: 1  # Each user counts as 1
end

Size.size(%User{name: "Alice", email: "alice@example.com"})
# => 1

Deriving Protocols

Automatically implement protocols for structs:

defmodule User do
  @derive [Inspect]  # Custom Inspect implementation
  defstruct [:name, :email, :password]
end

# Or derive with options
defmodule User do
  @derive {Inspect, only: [:name, :email]}
  defstruct [:name, :email, :password]
end

inspect(%User{name: "Alice", email: "alice@example.com", password: "secret"})
# => "#User<name: \"Alice\", email: \"alice@example.com\", ...>"
# Password is hidden

Fallback Implementation

defprotocol JSON do
  @fallback_to_any true
  def encode(data)
end

defimpl JSON, for: Any do
  def encode(_), do: "{}"
end

defimpl JSON, for: Map do
  def encode(map) do
    # Real JSON encoding logic
    Jason.encode!(map)
  end
end

# Falls back to Any for unimplemented types
JSON.encode(%{a: 1})  # Uses Map implementation
JSON.encode(:atom)    # Falls back to Any

Protocol Dispatch

defprotocol Drawable do
  def draw(shape)
end

defmodule Circle do
  defstruct [:radius]
end

defmodule Rectangle do
  defstruct [:width, :height]
end

defimpl Drawable, for: Circle do
  def draw(%Circle{radius: r}) do
    "Drawing circle with radius #{r}"
  end
end

defimpl Drawable, for: Rectangle do
  def draw(%Rectangle{width: w, height: h}) do
    "Drawing rectangle #{w}x#{h}"
  end
end

Drawable.draw(%Circle{radius: 5})
# => "Drawing circle with radius 5"

Drawable.draw(%Rectangle{width: 10, height: 20})
# => "Drawing rectangle 10x20"

Behaviors

Define callback contracts - what functions a module must implement.

Defining a Behavior

defmodule Parser do
  @callback parse(String.t()) :: {:ok, term()} | {:error, String.t()}
  @callback extensions() :: [String.t()]
end

Implementing a Behavior

defmodule JSONParser do
  @behaviour Parser
  
  def parse(string) do
    case Jason.decode(string) do
      {:ok, data} -> {:ok, data}
      {:error, _} -> {:error, "Invalid JSON"}
    end
  end
  
  def extensions, do: [".json"]
end

defmodule XMLParser do
  @behaviour Parser
  
  def parse(string) do
    # XML parsing logic
    {:ok, parsed_xml}
  end
  
  def extensions, do: [".xml"]
end

Optional Callbacks

defmodule Cache do
  @callback get(key :: String.t()) :: any()
  @callback put(key :: String.t(), value :: any()) :: :ok
  @callback delete(key :: String.t()) :: :ok
  
  @optional_callbacks delete: 1
end

Using Behaviors

defmodule FileHandler do
  def handle_file(file_path, parser) do
    with {:ok, content} <- File.read(file_path),
         {:ok, data} <- parser.parse(content) do
      {:ok, data}
    end
  end
end

FileHandler.handle_file("data.json", JSONParser)
FileHandler.handle_file("data.xml", XMLParser)

Behavior vs Protocol

Behavior: Contract for modules (compile-time check)

  • "This module must implement these functions"
  • Used for libraries/frameworks (GenServer, Supervisor, etc.)

Protocol: Polymorphism for data types (runtime dispatch)

  • "This type can do this operation"
  • Used for data operations (String.Chars, Enumerable, etc.)

Module Attributes in Detail

As Constants

defmodule Circle do
  @pi 3.14159
  
  def area(radius), do: @pi * radius * radius
  def circumference(radius), do: 2 * @pi * radius
end

Accumulating Values

defmodule RouteValidator do
  @valid_methods []
  
  @valid_methods ["GET" | @valid_methods]
  @valid_methods ["POST" | @valid_methods]
  @valid_methods ["PUT" | @valid_methods]
  
  def valid?(method), do: method in @valid_methods
end

As Storage for Computed Values

defmodule Config do
  @external_resource "config.txt"
  @config_content File.read!(@external_resource)
  @config String.split(@config_content, "\n")
  
  def get_config, do: @config
end

Practical Examples

User Management

defmodule User do
  @enforce_keys [:id, :email]
  defstruct [:id, :email, :name, :role, joined_at: nil]
  
  @type t :: %__MODULE__{
    id: integer(),
    email: String.t(),
    name: String.t() | nil,
    role: atom(),
    joined_at: DateTime.t() | nil
  }
  
  def new(id, email, opts \\ []) do
    %User{
      id: id,
      email: email,
      name: Keyword.get(opts, :name),
      role: Keyword.get(opts, :role, :member),
      joined_at: DateTime.utc_now()
    }
  end
  
  def admin?(%User{role: :admin}), do: true
  def admin?(%User{}), do: false
  
  def display_name(%User{name: name}) when is_binary(name), do: name
  def display_name(%User{email: email}), do: email
end

Shape Calculator with Protocols

defprotocol Shape do
  def area(shape)
  def perimeter(shape)
end

defmodule Circle do
  defstruct [:radius]
end

defmodule Rectangle do
  defstruct [:width, :height]
end

defmodule Triangle do
  defstruct [:a, :b, :c]
end

defimpl Shape, for: Circle do
  def area(%Circle{radius: r}), do: 3.14159 * r * r
  def perimeter(%Circle{radius: r}), do: 2 * 3.14159 * r
end

defimpl Shape, for: Rectangle do
  def area(%Rectangle{width: w, height: h}), do: w * h
  def perimeter(%Rectangle{width: w, height: h}), do: 2 * (w + h)
end

defimpl Shape, for: Triangle do
  def area(%Triangle{a: a, b: b, c: c}) do
    s = (a + b + c) / 2
    :math.sqrt(s * (s - a) * (s - b) * (s - c))
  end
  
  def perimeter(%Triangle{a: a, b: b, c: c}), do: a + b + c
end

# Usage
shapes = [
  %Circle{radius: 5},
  %Rectangle{width: 10, height: 20},
  %Triangle{a: 3, b: 4, c: 5}
]

Enum.each(shapes, fn shape ->
  IO.puts("Area: #{Shape.area(shape)}")
  IO.puts("Perimeter: #{Shape.perimeter(shape)}")
end)

Plugin System with Behaviors

defmodule Plugin do
  @callback init(config :: map()) :: {:ok, state :: any()} | {:error, reason :: String.t()}
  @callback handle_event(event :: String.t(), state :: any()) :: {:ok, any()}
  @callback terminate(state :: any()) :: :ok
  
  @optional_callbacks terminate: 1
end

defmodule LoggerPlugin do
  @behaviour Plugin
  
  def init(_config) do
    {:ok, %{logs: []}}
  end
  
  def handle_event(event, state) do
    IO.puts("LOG: #{event}")
    {:ok, %{state | logs: [event | state.logs]}}
  end
  
  def terminate(_state) do
    IO.puts("Logger shutting down")
    :ok
  end
end

defmodule PluginManager do
  def run_plugin(plugin_module, config) do
    with {:ok, state} <- plugin_module.init(config),
         {:ok, new_state} <- plugin_module.handle_event("startup", state) do
      if function_exported?(plugin_module, :terminate, 1) do
        plugin_module.terminate(new_state)
      end
      {:ok, new_state}
    end
  end
end

PluginManager.run_plugin(LoggerPlugin, %{})

Configuration Module

defmodule AppConfig do
  @moduledoc """
  Application configuration management.
  """
  
  @environments [:dev, :test, :prod]
  @current_env Mix.env()
  
  def env, do: @current_env
  
  def database_url do
    case @current_env do
      :prod -> System.get_env("DATABASE_URL")
      :test -> "ecto://localhost/myapp_test"
      :dev -> "ecto://localhost/myapp_dev"
    end
  end
  
  def port do
    case @current_env do
      :prod -> String.to_integer(System.get_env("PORT", "4000"))
      _ -> 4000
    end
  end
  
  def feature_enabled?(feature) do
    features = %{
      experimental: @current_env != :prod,
      analytics: @current_env == :prod
    }
    Map.get(features, feature, false)
  end
end

Exercises

  1. Create a Product struct with fields: id, name, price, quantity. Add functions: new/4, total_value/1, in_stock?/1

  2. Define a protocol Serializable with a function serialize/1. Implement it for Map, List, and a custom struct

  3. Create a behavior DataStore with callbacks: get/1, put/2, delete/1. Implement it with a module that uses Agent

  4. Build a module Calculator with nested modules: Basic (add, subtract), Advanced (power, sqrt). Use proper aliases

  5. Create a struct BankAccount with balance tracking. Add functions with guards to prevent negative balance

# Solutions

# 1. Product struct
defmodule Product do
  defstruct [:id, :name, :price, :quantity]
  
  def new(id, name, price, quantity) do
    %Product{id: id, name: name, price: price, quantity: quantity}
  end
  
  def total_value(%Product{price: price, quantity: quantity}) do
    price * quantity
  end
  
  def in_stock?(%Product{quantity: quantity}), do: quantity > 0
end

# 2. Serializable protocol
defprotocol Serializable do
  def serialize(data)
end

defimpl Serializable, for: Map do
  def serialize(map) do
    Enum.map(map, fn {k, v} -> "#{k}:#{v}" end)
    |> Enum.join(",")
  end
end

defimpl Serializable, for: List do
  def serialize(list) do
    Enum.join(list, ",")
  end
end

defmodule Record do
  defstruct [:id, :data]
end

defimpl Serializable, for: Record do
  def serialize(%Record{id: id, data: data}) do
    "Record[#{id}]=#{data}"
  end
end

# 3. DataStore behavior
defmodule DataStore do
  @callback get(key :: String.t()) :: {:ok, any()} | {:error, :not_found}
  @callback put(key :: String.t(), value :: any()) :: :ok
  @callback delete(key :: String.t()) :: :ok
end

defmodule MemoryStore do
  @behaviour DataStore
  use Agent
  
  def start_link do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end
  
  def get(key) do
    case Agent.get(__MODULE__, &Map.fetch(&1, key)) do
      {:ok, value} -> {:ok, value}
      :error -> {:error, :not_found}
    end
  end
  
  def put(key, value) do
    Agent.update(__MODULE__, &Map.put(&1, key, value))
  end
  
  def delete(key) do
    Agent.update(__MODULE__, &Map.delete(&1, key))
  end
end

# 4. Calculator with modules
defmodule Calculator do
  defmodule Basic do
    def add(a, b), do: a + b
    def subtract(a, b), do: a - b
    def multiply(a, b), do: a * b
    def divide(_, 0), do: {:error, :division_by_zero}
    def divide(a, b), do: {:ok, a / b}
  end
  
  defmodule Advanced do
    alias Calculator.Basic
    
    def power(base, exp), do: :math.pow(base, exp)
    def sqrt(n) when n >= 0, do: {:ok, :math.sqrt(n)}
    def sqrt(_), do: {:error, :negative_number}
    
    def compound(a, b) do
      Basic.add(a, b) |> Basic.multiply(2)
    end
  end
end

# 5. BankAccount with guards
defmodule BankAccount do
  defstruct balance: 0.0, holder: nil
  
  def new(holder, initial_balance \\ 0.0) 
      when is_number(initial_balance) and initial_balance >= 0 do
    %BankAccount{holder: holder, balance: initial_balance}
  end
  
  def deposit(%BankAccount{balance: balance} = account, amount)
      when is_number(amount) and amount > 0 do
    {:ok, %{account | balance: balance + amount}}
  end
  def deposit(_, _), do: {:error, :invalid_amount}
  
  def withdraw(%BankAccount{balance: balance} = account, amount)
      when is_number(amount) and amount > 0 and amount <= balance do
    {:ok, %{account | balance: balance - amount}}
  end
  def withdraw(_, _), do: {:error, :insufficient_funds}
  
  def balance(%BankAccount{balance: balance}), do: balance
end

Next Steps

Continue to 07-error-handling.md to learn about error handling strategies, try/rescue/catch, the with macro, and Elixir's "let it crash" philosophy.