Where Does Code Live?
In OOP, the answer is always: in a class. Classes are the organizing unit for everything. They hold state, define behavior, inherit from parent classes, and form hierarchies that model relationships between concepts.
In Elixir, the organizing unit is the module. Modules hold functions. That’s it. They don’t hold state, they don’t inherit from other modules, and they don’t form hierarchies. This changes how you think about organizing code.
Modules as Namespaces
An Elixir module is a namespace for functions. You group related functions together and give the group a name.
defmodule MyApp.Accounts do
def create_user(attrs) do
# ...
end
def get_user(id) do
# ...
end
def authenticate(email, password) do
# ...
end
end
defmodule MyApp.Accounts.Permissions do
def can_access?(user, resource) do
# ...
end
def grant(user, role) do
# ...
end
endThe dot in MyApp.Accounts.Permissions looks like a hierarchy, but it’s just a naming convention. There’s no parent-child relationship between MyApp.Accounts and MyApp.Accounts.Permissions. They don’t share state, they don’t inherit functions, and neither one knows the other exists unless it explicitly calls a function from it.
Contrast: OOP Class Hierarchies
In OOP, nesting implies relationships:
// Java - class hierarchy with inheritance
public abstract class Account {
protected String email;
public abstract boolean authenticate(String password);
public String getEmail() {
return email;
}
}
public class UserAccount extends Account {
private String hashedPassword;
@Override
public boolean authenticate(String password) {
return BCrypt.verify(password, hashedPassword);
}
}
public class AdminAccount extends Account {
private String hashedPassword;
private List<String> permissions;
@Override
public boolean authenticate(String password) {
return BCrypt.verify(password, hashedPassword);
}
public boolean canAccess(String resource) {
return permissions.contains(resource);
}
}# Python - class hierarchy
class Account:
def __init__(self, email):
self.email = email
def authenticate(self, password):
raise NotImplementedError
class UserAccount(Account):
def __init__(self, email, hashed_password):
super().__init__(email)
self.hashed_password = hashed_password
def authenticate(self, password):
return bcrypt.verify(password, self.hashed_password)
class AdminAccount(Account):
def __init__(self, email, hashed_password, permissions):
super().__init__(email)
self.hashed_password = hashed_password
self.permissions = permissions
def authenticate(self, password):
return bcrypt.verify(password, self.hashed_password)
def can_access(self, resource):
return resource in self.permissionsThe OOP approach ties data and behavior together in a tree. AdminAccount inherits from Account, which means it gets email and the contract for authenticate, but also everything else Account carries. If Account changes, every subclass changes too. This is the fragile base class problem.
The Elixir Way: Flat Modules, Explicit Data
defmodule MyApp.Accounts do
def authenticate(%{hashed_password: hashed} = _user, password) do
Bcrypt.verify_pass(password, hashed)
end
end
defmodule MyApp.Accounts.Permissions do
def can_access?(%{role: :admin, permissions: perms}, resource) do
resource in perms
end
def can_access?(_, _), do: false
endData flows in as arguments. Functions pattern match on what they need. There’s no shared mutable state between modules, no inheritance chain to trace, and no hidden coupling through a base class. If Accounts changes, Permissions is unaffected unless you change the shape of the data they both operate on.
Organizing a Real Project
In OOP, you model your domain as a class hierarchy. In Elixir, you model it as groups of functions organized around a concept. Phoenix calls these contexts.
OOP Project Elixir Project
─────────── ──────────────
models/ lib/my_app/
User.java accounts/
AdminUser.java (extends User) user.ex (schema)
Post.java permissions.ex (functions)
Comment.java (extends Post?) accounts.ex (context)
services/ blog/
UserService.java post.ex (schema)
PostService.java comment.ex (schema)
AuthService.java blog.ex (context)
controllers/
UserController.javaThe Elixir side is flat. User doesn’t extend anything. AdminUser doesn’t exist as a separate type. The difference between a regular user and an admin is a field on the struct and pattern matching in Permissions.
defmodule MyApp.Accounts.User do
defstruct [:id, :email, :hashed_password, :role, :permissions]
end
defmodule MyApp.Accounts do
alias MyApp.Accounts.User
def create_user(attrs) do
%User{
email: attrs.email,
hashed_password: hash(attrs.password),
role: :user,
permissions: []
}
end
def promote_to_admin(user, permissions) do
%{user | role: :admin, permissions: permissions}
end
end
defmodule MyApp.Accounts.Permissions do
def can_access?(%{role: :admin, permissions: perms}, resource) do
resource in perms
end
def can_access?(%{role: :user}, _resource), do: false
end
# Usage
user = MyApp.Accounts.create_user(%{email: "[email protected]", password: "secret"})
admin = MyApp.Accounts.promote_to_admin(user, ["dashboard", "reports"])
MyApp.Accounts.Permissions.can_access?(admin, "dashboard") # true
MyApp.Accounts.Permissions.can_access?(user, "dashboard") # falseNo inheritance. No abstract base class. One struct with a role field, and functions that pattern match on it. Adding a new role means adding function clauses, not a new class in a hierarchy.
Behaviours: Contracts Without Inheritance
OOP uses abstract classes and interfaces to define contracts. Elixir uses behaviours.
A behaviour defines a set of functions that a module must implement. It’s a contract, not a parent class. It doesn’t provide implementation. It doesn’t share state. It just says “if you claim to be this kind of module, you must have these functions.”
defmodule MyApp.Notifier do
@doc "Send a notification to a user"
@callback notify(user :: map(), message :: String.t()) :: :ok | {:error, String.t()}
@doc "Check if the notifier is available"
@callback available?() :: boolean()
endNow any module can implement this behaviour:
defmodule MyApp.Notifier.Email do
@behaviour MyApp.Notifier
@impl true
def notify(user, message) do
# send email to user.email
:ok
end
@impl true
def available?, do: true
end
defmodule MyApp.Notifier.SMS do
@behaviour MyApp.Notifier
@impl true
def notify(user, message) do
# send SMS to user.phone
:ok
end
@impl true
def available?, do: true
end
defmodule MyApp.Notifier.Slack do
@behaviour MyApp.Notifier
@impl true
def notify(user, message) do
# post to user's Slack channel
:ok
end
@impl true
def available? do
# check if Slack integration is configured
Application.get_env(:my_app, :slack_token) != nil
end
endIf a module declares @behaviour MyApp.Notifier but doesn’t implement notify/2 or available?/0, the compiler warns you. The @impl true annotation makes it explicit which functions satisfy the contract.
Using Behaviours for Dispatch
defmodule MyApp.Notifications do
@notifiers [
MyApp.Notifier.Email,
MyApp.Notifier.SMS,
MyApp.Notifier.Slack
]
def send_all(user, message) do
@notifiers
|> Enum.filter(& &1.available?())
|> Enum.each(& &1.notify(user, message))
end
def send_via(notifier, user, message) do
if notifier.available?() do
notifier.notify(user, message)
else
{:error, "Notifier not available"}
end
end
end
# Send through all available channels
MyApp.Notifications.send_all(user, "Your order shipped!")
# Send through a specific channel
MyApp.Notifications.send_via(MyApp.Notifier.Email, user, "Reset your password")Modules are values in Elixir. You can store them in a list, pass them to functions, and call their functions dynamically. This gives you polymorphic dispatch without any inheritance.
Contrast: OOP Interfaces and Abstract Classes
// Java - interface with implementations
public interface Notifier {
void notify(User user, String message);
boolean isAvailable();
}
public class EmailNotifier implements Notifier {
@Override
public void notify(User user, String message) {
// send email
}
@Override
public boolean isAvailable() {
return true;
}
}
public class SMSNotifier implements Notifier {
@Override
public void notify(User user, String message) {
// send SMS
}
@Override
public boolean isAvailable() {
return true;
}
}
// Usage - need to instantiate objects
List<Notifier> notifiers = List.of(
new EmailNotifier(),
new SMSNotifier()
);
for (Notifier n : notifiers) {
if (n.isAvailable()) {
n.notify(user, "Your order shipped!");
}
}The structure is similar. But there’s a difference in what’s happening underneath. In Java, Notifier is a type. EmailNotifier is a subtype. Dispatch happens through the object’s vtable at runtime. In Elixir, MyApp.Notifier is just a set of callback definitions. MyApp.Notifier.Email is a module that happens to implement those callbacks. Dispatch happens because you called a function on a module, the same way you call any function.
The practical difference: Java’s approach ties you to an object hierarchy. You need an instance of EmailNotifier to call notify. Elixir’s approach is just modules and functions. You can swap, compose, and configure them without worrying about object lifecycle.
Protocols: Polymorphism on Data
Behaviours let you define contracts that modules implement. Protocols let you define contracts that data types implement. This is Elixir’s answer to the question OOP solves with inheritance-based polymorphism: how do you make the same function work differently depending on what you pass it?
defprotocol Displayable do
@doc "Convert a value to a display string"
def display(value)
endNow implement it for different types:
defimpl Displayable, for: Map do
def display(map) do
map
|> Enum.map(fn {k, v} -> "#{k}: #{v}" end)
|> Enum.join(", ")
end
end
defimpl Displayable, for: List do
def display(list) do
"[#{Enum.join(list, ", ")}]"
end
end
defimpl Displayable, for: BitString do
def display(string), do: string
end
defimpl Displayable, for: Integer do
def display(number), do: Integer.to_string(number)
endDisplayable.display(%{name: "Alice", age: 30}) # "age: 30, name: Alice"
Displayable.display([1, 2, 3]) # "[1, 2, 3]"
Displayable.display("hello") # "hello"
Displayable.display(42) # "42"The same function name, different behavior based on the data type. No class hierarchy required.
Protocols for Your Own Structs
Where protocols really shine is when you define them for your own data types:
defmodule MyApp.User do
defstruct [:name, :email, :role]
end
defmodule MyApp.Product do
defstruct [:name, :price, :sku]
end
defimpl Displayable, for: MyApp.User do
def display(user) do
"#{user.name} (#{user.role})"
end
end
defimpl Displayable, for: MyApp.Product do
def display(product) do
"#{product.name} - $#{product.price}"
end
enduser = %MyApp.User{name: "Alice", email: "[email protected]", role: :admin}
product = %MyApp.Product{name: "Widget", price: 9.99, sku: "W-001"}
Displayable.display(user) # "Alice (admin)"
Displayable.display(product) # "Widget - $9.99"
# Works in collections with mixed types
[user, product]
|> Enum.map(&Displayable.display/1)
# ["Alice (admin)", "Widget - $9.99"]Protocols vs Behaviours: When to Use Which
| Behaviours | Protocols | |
|---|---|---|
| Dispatch on | Which module you call | What data you pass |
| Define with | @callback in a module |
defprotocol |
| Implement with | @behaviour + @impl |
defimpl |
| Use when | Multiple modules need the same API (notifiers, storage backends) | One function needs to work on multiple data types |
Behaviours answer: “these modules all do the same kind of thing.” Protocols answer: “this operation works differently on different kinds of data.”
Contrast: OOP Inheritance-Based Polymorphism
In OOP, polymorphism comes from inheritance:
// Java - polymorphism through inheritance
public abstract class Displayable {
public abstract String display();
}
public class User extends Displayable {
private String name;
private String role;
@Override
public String display() {
return name + " (" + role + ")";
}
}
public class Product extends Displayable {
private String name;
private double price;
@Override
public String display() {
return name + " - $" + price;
}
}
// Usage
List<Displayable> items = List.of(new User("Alice", "admin"), new Product("Widget", 9.99));
for (Displayable item : items) {
System.out.println(item.display());
}This works, but there’s a catch. What if User already extends Person and Product already extends Inventory? Java doesn’t have multiple inheritance. You’d need to switch to an interface, which means changing the existing class hierarchy.
Elixir protocols don’t have this problem. You can implement any protocol for any type at any time, without modifying the type itself. The implementation lives in a separate defimpl block, not inside the struct definition. You can even implement protocols for types you didn’t write.
# Implement Displayable for Elixir's built-in Date type
defimpl Displayable, for: Date do
def display(date) do
Calendar.strftime(date, "%B %d, %Y")
end
end
Displayable.display(~D[2026-03-20]) # "March 20, 2026"Try doing that in Java without modifying the Date class or wrapping it in a new one.
The Bigger Picture
Here’s how OOP and Elixir answer the same organizational questions:
| Question | OOP Answer | Elixir Answer |
|---|---|---|
| Where do I put related functions? | In a class | In a module |
| How do I share behavior across types? | Inheritance | Behaviours or protocols |
| How do I define a contract? | Interface / abstract class | Behaviour (@callback) |
| How do I get polymorphism? | Subclass + override | Protocol (defimpl) |
| How do I extend a type I didn’t write? | Wrapper class / adapter | defimpl for the protocol |
| How do I model “is-a” relationships? | Class hierarchy | You usually don’t. Use a field and pattern match. |
The last row is the key insight. OOP models relationships as types: AdminUser is a kind of User. Elixir models relationships as data: a user has a :role field that might be :admin. The difference is that data is easier to change. Adding a new role is adding a function clause, not a new class with its own constructor, methods, and place in the hierarchy.
Key Takeaways
- Modules are namespaces, not classes. Dotted names like
MyApp.Accounts.Userare naming conventions, not inheritance - No inheritance. Elixir doesn’t have it because it doesn’t need it. Composition through functions replaces it
- Behaviours define callback contracts that modules must implement. They replace OOP interfaces and abstract classes
- Protocols define functions that dispatch on data type. They replace OOP’s inheritance-based polymorphism
- Behaviours dispatch on which module you call. Protocols dispatch on what data you pass
- You can implement a protocol for any type, including types you didn’t write, without modifying them
- “Is-a” relationships become data. Instead of
AdminUser extends User, you have a user struct with a role field and functions that pattern match on it
Try It Yourself
Exercise 1: Context Module
Build a Library context module with a Book struct. Books have a title, author, and status (:available, :checked_out, or :reserved). Write functions to create a book, check it out, return it, and list only available books from a collection:
defmodule Library.Book do
defstruct [:title, :author, :status]
end
defmodule Library do
alias Library.Book
def new_book(title, author) do
???
end
def check_out(%Book{status: :available} = book) do
???
end
# What should check_out return for a book that's not available?
def check_out(%Book{} = book) do
???
end
def return_book(%Book{status: :checked_out} = book) do
???
end
def available_books(books) do
???
end
end
# Library.new_book("Elixir in Action", "Sasa Juric")
# => %Library.Book{title: "Elixir in Action", author: "Sasa Juric", status: :available}
#
# book = Library.new_book("Elixir in Action", "Sasa Juric")
# {:ok, checked_out} = Library.check_out(book)
# Library.check_out(checked_out)
# => {:error, "Book is not available"}Exercise 2: Behaviour
Define a Storage behaviour with two callbacks: save/2 (takes a key and value, returns :ok or {:error, reason}) and fetch/1 (takes a key, returns {:ok, value} or {:error, :not_found}). Then implement it with two modules: one that uses an in-memory map (Agent), and one that writes to a file.
defmodule MyApp.Storage do
@callback save(key :: String.t(), value :: any()) :: :ok | {:error, String.t()}
@callback fetch(key :: String.t()) :: {:ok, any()} | {:error, :not_found}
end
defmodule MyApp.Storage.Memory do
@behaviour MyApp.Storage
# Hint: use Agent to hold state
???
end
defmodule MyApp.Storage.File do
@behaviour MyApp.Storage
# Hint: use File.write/2 and File.read/1
# Store in /tmp/storage/<key>
???
end
# Both should work the same way:
# MyApp.Storage.Memory.save("user:1", "Alice") # :ok
# MyApp.Storage.Memory.fetch("user:1") # {:ok, "Alice"}
# MyApp.Storage.Memory.fetch("user:99") # {:error, :not_found}Exercise 3: Protocol
Define a Summary protocol with a summarize/1 function. Implement it for three types: a BlogPost struct (return the first 50 characters of the body plus “…”), a User struct (return the name and role), and plain maps (return the number of keys). Then write a function that takes a mixed list and summarizes everything in it.
defprotocol Summary do
def summarize(value)
end
defmodule BlogPost do
defstruct [:title, :body, :author]
end
defmodule User do
defstruct [:name, :role]
end
# Implement Summary for BlogPost, User, and Map
???
# Usage:
# items = [
# %BlogPost{title: "FP", body: "Functional programming is a paradigm that treats...", author: "Alice"},
# %User{name: "Bob", role: :editor},
# %{a: 1, b: 2, c: 3}
# ]
#
# Enum.map(items, &Summary.summarize/1)
# ["Functional programming is a paradigm that treats...",
# "Bob (editor)",
# "Map with 3 keys"]Official Documentation to Help You Learn
Part Nine | Functional Programming Through Elixir series
Previous in series: Part Eight - Guards and Pattern Matching in Function Heads
Next in series: Part Ten - Error Handling: Tagged Tuples and Railway-Oriented Programming (Coming Soon)