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
Create a
Productstruct with fields: id, name, price, quantity. Add functions:new/4,total_value/1,in_stock?/1Define a protocol
Serializablewith a functionserialize/1. Implement it for Map, List, and a custom structCreate a behavior
DataStorewith callbacks:get/1,put/2,delete/1. Implement it with a module that uses AgentBuild a module
Calculatorwith nested modules:Basic(add, subtract),Advanced(power, sqrt). Use proper aliasesCreate a struct
BankAccountwith 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.