Phase 1, Session 4: Modules & Structs
Overview
Modules are how you organize code in Elixir. Structs are how you define typed, structured data with defaults and compile-time guarantees.
What you’ll learn:
- Module definition and namespacing
-
Module attributes (
@moduledoc,@doc, constants) -
Structs with
defstruct -
Composition:
alias,import,require,use
Livebook Notes
Important:
- Run cells in order - structs must be defined before use
- If you get “module already defined” errors, restart the runtime: Runtime menu → Reconnect and reevaluate
-
All modules are namespaced with
S4.(Session 4) to avoid conflicts
Why This Matters for Your Agent Framework
Your agent framework will be organized into modules:
-
Agent- Core agent struct and behavior -
Agent.MessageHandler- Message processing logic -
Agent.State- State management -
Agent.Supervisor- Process supervision (later in OTP)
Structs will define your core data:
- Agent state with known fields and defaults
- Message types with enforced structure
Section 1: Module Basics
1.1 Defining a Module
defmodule S4.Greeter do
def hello(name) do
"Hello, #{name}!"
end
def goodbye(name) do
"Goodbye, #{name}!"
end
end
{S4.Greeter.hello("World"), S4.Greeter.goodbye("World")}
1.2 Nested Modules
Use dot notation for hierarchy. These are just naming conventions - no actual nesting:
defmodule S4.Agents.Researcher do
def describe, do: "A research agent that searches and summarizes"
end
defmodule S4.Agents.Analyzer do
def describe, do: "An analysis agent that processes data"
end
{S4.Agents.Researcher.describe(), S4.Agents.Analyzer.describe()}
1.3 Module Organization Pattern
defmodule S4.SimpleAgent do
# Public API at the top
def new(name), do: %{name: name, state: :idle}
def process(agent, message), do: do_process(agent, message)
# Private helpers at the bottom
defp do_process(agent, {:task, _, _}), do: %{agent | state: :busy}
defp do_process(agent, _), do: agent
end
S4.SimpleAgent.new("Worker") |> S4.SimpleAgent.process({:task, "001", %{}})
Section 2: Module Attributes
Module attributes are compile-time constants and metadata.
2.1 Documentation Attributes
defmodule S4.Calculator do
@moduledoc """
A simple calculator module.
Provides basic arithmetic operations.
"""
@doc "Adds two numbers together."
def add(a, b), do: a + b
@doc "Multiplies two numbers."
def multiply(a, b), do: a * b
end
{S4.Calculator.add(2, 3), S4.Calculator.multiply(4, 5)}
2.2 Constants with @
defmodule S4.Config do
@default_timeout 5000
@max_retries 3
@supported_actions [:search, :analyze, :summarize]
def default_timeout, do: @default_timeout
def max_retries, do: @max_retries
def supported_actions, do: @supported_actions
def valid_action?(action), do: action in @supported_actions
end
{S4.Config.default_timeout(), S4.Config.max_retries(), S4.Config.valid_action?(:search)}
2.3 Compile-Time vs Runtime
Module attributes are evaluated at compile time:
defmodule S4.Timestamps do
@compiled_at DateTime.utc_now() # Set ONCE at compile time
def compiled_at, do: @compiled_at
def current_time, do: DateTime.utc_now() # Evaluated each call
end
# compiled_at always returns the same value
{S4.Timestamps.compiled_at(), S4.Timestamps.current_time()}
Section 3: Structs
Structs are maps with:
- A defined set of fields
- Default values
- Compile-time checks for field names
3.1 Basic Struct Definition
defmodule S4.User do
defstruct name: nil, email: nil, role: :user
end
# Use the struct (run after cell above)
user1 = %S4.User{}
user2 = %S4.User{name: "Alice", email: "alice@example.com"}
user3 = %S4.User{name: "Bob", role: :admin}
{user1, user2, user3}
3.2 Struct vs Map
defmodule S4.Person do
defstruct [:name, :age]
end
# Structs ARE maps (with a special __struct__ key)
person = %S4.Person{name: "Alice", age: 30}
is_map(person)
# But maps don't match struct patterns
person = %S4.Person{name: "Alice", age: 30}
# This function only accepts S4.Person structs
accepts_person = fn %S4.Person{name: n} -> "Person: #{n}" end
accepts_person.(person)
# accepts_person.(%{name: "Bob", age: 25}) would fail
3.3 Enforcing Required Fields
defmodule S4.Task do
@enforce_keys [:id, :action]
defstruct [:id, :action, status: :pending, result: nil]
end
# This works - required keys provided
valid_task = %S4.Task{id: "001", action: :search}
# This would fail:
# %S4.Task{action: :search} # Missing :id
valid_task
3.4 Updating Structs
defmodule S4.Agent do
defstruct name: nil, state: :idle, inbox: [], processed_count: 0
end
agent = %S4.Agent{name: "Worker-1"}
# Update with pipe syntax (same as maps)
updated = %{agent | state: :busy, processed_count: 1}
{agent, updated}
3.5 Pattern Matching with Structs
defmodule S4.Message do
defstruct [:type, :id, :payload]
end
defmodule S4.MessageProcessor do
def process(%S4.Message{type: :task, id: id, payload: p}) do
"Processing task #{id}: #{inspect(p)}"
end
def process(%S4.Message{type: :response, id: id, payload: p}) do
"Got response for #{id}: #{inspect(p)}"
end
def process(%S4.Message{} = msg) do
"Unknown message type: #{inspect(msg.type)}"
end
end
[
S4.MessageProcessor.process(%S4.Message{type: :task, id: "001", payload: %{action: :search}}),
S4.MessageProcessor.process(%S4.Message{type: :response, id: "001", payload: {:ok, "done"}}),
S4.MessageProcessor.process(%S4.Message{type: :unknown, id: "002", payload: nil})
]
3.6 Struct with Functions
Best practice: define helper functions in the same module as the struct:
defmodule S4.AgentState do
@enforce_keys [:name]
defstruct [:name, state: :idle, inbox: [], memory: %{}, processed_count: 0]
def new(name) when is_binary(name), do: %__MODULE__{name: name}
# State transitions
def set_idle(agent), do: %{agent | state: :idle}
def set_busy(agent), do: %{agent | state: :busy}
def set_waiting(agent), do: %{agent | state: :waiting}
# Inbox operations
def add_message(%__MODULE__{inbox: inbox} = agent, message) do
%{agent | inbox: [message | inbox]}
end
def pop_message(%__MODULE__{inbox: []} = agent), do: {nil, agent}
def pop_message(%__MODULE__{inbox: [msg | rest]} = agent) do
{msg, %{agent | inbox: rest}}
end
# Memory operations
def remember(%__MODULE__{memory: mem} = agent, key, value) do
%{agent | memory: Map.put(mem, key, value)}
end
def recall(%__MODULE__{memory: mem}, key, default \\ nil) do
Map.get(mem, key, default)
end
def increment_processed(%__MODULE__{processed_count: n} = agent) do
%{agent | processed_count: n + 1}
end
end
# Usage with pipes
agent =
S4.AgentState.new("Researcher")
|> S4.AgentState.add_message({:task, "001", %{action: :search}})
|> S4.AgentState.add_message({:task, "002", %{action: :analyze}})
|> S4.AgentState.set_busy()
|> S4.AgentState.remember(:context, "Researching Elixir")
{msg, agent} = S4.AgentState.pop_message(agent)
{msg, agent, S4.AgentState.recall(agent, :context)}
Section 4: Module Composition
4.1 alias - Shorten Module Names
defmodule S4.Services.Auth do
def authenticate(user, _pass), do: {:ok, "#{user} authenticated"}
end
defmodule S4.Web.LoginController do
alias S4.Services.Auth
def login(user, pass), do: Auth.authenticate(user, pass)
end
S4.Web.LoginController.login("alice", "secret")
# Alias with custom name
defmodule S4.Example do
alias S4.Services.Auth, as: A
def demo, do: A.authenticate("bob", "pass")
end
S4.Example.demo()
4.2 import - Bring Functions Into Scope
defmodule S4.ListOps do
import Enum, only: [map: 2, filter: 2]
def double_evens(list) do
list
|> filter(&(rem(&1, 2) == 0))
|> map(&(&1 * 2))
end
end
S4.ListOps.double_evens([1, 2, 3, 4, 5, 6])
defmodule S4.StringOps do
import String, except: [length: 1]
def process(text), do: text |> trim() |> upcase() |> split(" ")
end
S4.StringOps.process(" hello world ")
4.3 require - Load Macros
defmodule S4.LogExample do
require Logger
def do_something do
Logger.info("Starting operation")
result = 1 + 1
Logger.debug("Result: #{result}")
result
end
end
S4.LogExample.do_something()
4.4 use - Inject Code
use calls a module’s __using__ macro to inject code:
defmodule S4.Greeting do
defmacro __using__(opts) do
greeting = Keyword.get(opts, :greeting, "Hello")
quote do
def greet(name), do: "#{unquote(greeting)}, #{name}!"
end
end
end
defmodule S4.EnglishGreeter do
use S4.Greeting, greeting: "Hello"
end
defmodule S4.SpanishGreeter do
use S4.Greeting, greeting: "Hola"
end
{S4.EnglishGreeter.greet("World"), S4.SpanishGreeter.greet("Mundo")}
Section 5: Putting It All Together
Complete Agent Module Structure
defmodule S4.Agent.Message do
@enforce_keys [:type, :id]
defstruct [:type, :id, :payload, timestamp: nil]
def new(type, id, payload \\ nil) do
%__MODULE__{type: type, id: id, payload: payload, timestamp: DateTime.utc_now()}
end
def task(id, action, params \\ %{}), do: new(:task, id, %{action: action, params: params})
def response(id, result), do: new(:response, id, result)
end
defmodule S4.Agent.Core do
alias S4.Agent.Message
@enforce_keys [:name]
defstruct [:name, state: :idle, inbox: [], outbox: [], memory: %{}]
def new(name), do: %__MODULE__{name: name}
def receive_message(%__MODULE__{inbox: inbox} = agent, %Message{} = msg) do
%{agent | inbox: inbox ++ [msg]}
end
def process_next(%__MODULE__{inbox: []} = agent), do: {:empty, agent}
def process_next(%__MODULE__{inbox: [msg | rest]} = agent) do
agent = %{agent | inbox: rest, state: :processing}
result = handle_message(msg)
{:processed, %{agent | state: :idle}, result}
end
defp handle_message(%Message{type: :task, id: id, payload: %{action: action}}) do
Message.response(id, {:ok, "Completed #{action}"})
end
defp handle_message(%Message{id: id}) do
Message.response(id, {:error, "Unknown message type"})
end
end
alias S4.Agent.{Core, Message}
agent =
Core.new("Worker-1")
|> Core.receive_message(Message.task("t-001", :search, %{query: "Elixir"}))
|> Core.receive_message(Message.task("t-002", :analyze, %{data: [1, 2, 3]}))
{status, updated_agent, response} = Core.process_next(agent)
{status, response, length(updated_agent.inbox)}
Section 6: Hands-On Exercises
Exercise 1: Create a Config Module
defmodule S4.MyConfig do
@default_timeout 5000
@max_retries 3
@log_level :info
def get(:timeout), do: @default_timeout
def get(:max_retries), do: @max_retries
def get(:log_level), do: @log_level
def get(_), do: nil
end
{S4.MyConfig.get(:timeout), S4.MyConfig.get(:max_retries), S4.MyConfig.get(:unknown)}
Exercise 2: Define a Task Struct
defmodule S4.MyTask do
@enforce_keys [:id, :action]
defstruct [:id, :action, status: :pending, result: nil, created_at: nil]
def new(id, action) do
%__MODULE__{id: id, action: action, created_at: DateTime.utc_now()}
end
def start(%__MODULE__{} = task), do: %{task | status: :running}
def complete(%__MODULE__{} = task, result), do: %{task | status: :completed, result: {:ok, result}}
def fail(%__MODULE__{} = task, reason), do: %{task | status: :failed, result: {:error, reason}}
def completed?(%__MODULE__{status: :completed}), do: true
def completed?(_), do: false
end
task =
S4.MyTask.new("task-001", :search)
|> S4.MyTask.start()
|> S4.MyTask.complete("Found 5 results")
{task, S4.MyTask.completed?(task)}
Exercise 3: Module Composition
defmodule S4.Utils.Str do
def normalize(text), do: text |> String.trim() |> String.downcase()
end
defmodule S4.Utils.Lst do
def compact(list), do: Enum.reject(list, &is_nil/1)
end
defmodule S4.MyProcessor do
alias S4.Utils.{Str, Lst}
import Enum, only: [map: 2]
def process(items) do
items |> Lst.compact() |> map(&Str.normalize/1)
end
end
S4.MyProcessor.process([" HELLO ", nil, " WORLD ", nil, " ELIXIR "])
Summary
| Concept | Syntax | Purpose |
|---|---|---|
| Module |
defmodule Name do |
Organize functions |
| Nested module |
defmodule A.B.C do |
Hierarchical namespacing |
| Module attribute |
@name value |
Compile-time constants |
| Documentation |
@moduledoc, @doc |
Document modules/functions |
| Struct |
defstruct [:field] |
Typed maps with defaults |
| Enforce keys |
@enforce_keys [:field] |
Required struct fields |
alias |
alias Long.Name |
Shorten module names |
import |
import Module |
Bring functions into scope |
require |
require Module |
Load macros |
use |
use Module |
Inject code via macros |
Key Takeaways for Agent Framework
- Organize by responsibility - Separate modules for State, Messages, Handlers
-
Use structs for data -
%Agent{},%Message{}with enforced keys -
__MODULE__- Reference current module (useful in structs) - Functions with structs - Keep data and operations together
- Alias for clarity - Clean up long module references
Next Session
Session 5: Mix & Project Setup - Create a real Elixir project with Mix, manage dependencies, and set up tests.