Structs Protocols
How do you model domain data with compile-time safety and polymorphic behavior? This guide teaches the progression from maps through structs to protocols, showing when each abstraction provides production value for data modeling.
Why It Matters
Maps are Elixir’s standard data structure, but production applications need stronger guarantees. Real-world requirements:
- Type safety - Prevent runtime errors from missing or wrong keys
- Data validation - Enforce business rules at compile time
- Polymorphism - Same operation across different data types
- Domain modeling - Represent business concepts with structure
Real-world scenarios requiring structs and protocols:
- Financial systems - Money with currency validation and arithmetic
- E-commerce - Products, orders with type-specific behavior
- API clients - Response types with polymorphic serialization
- Domain models - User accounts, transactions with guarantees
- Event systems - Event types with polymorphic handling
Production question: When should you upgrade from maps to structs? When do you need protocols for polymorphism? The answer depends on your compile-time safety and extensibility requirements.
Maps - Standard Library Foundation
Maps are Elixir’s built-in key-value data structure.
Basic Map Usage
# Create map with atom keys
account = %{
id: "acc_123", # => Key: :id, Value: "acc_123"
balance: 1000, # => Key: :balance, Value: 1000
currency: "USD" # => Key: :currency, Value: "USD"
}
# => Returns map
# => Type: %{id: binary(), balance: integer(), currency: binary()}
# => No compile-time guarantees
# Access values
account[:id] # => Returns "acc_123"
account.balance # => Returns 1000
# => Dot notation only works with atom keysMaps work for simple cases but lack structure.
Map Limitations in Production
# Typo in key - runtime error
account = %{
id: "acc_123",
balence: 1000 # => Typo: "balence" instead of "balance"
}
# => No compile-time error
# => Returns map with wrong key
account.balance # => KeyError at runtime!
# => No compile-time detection
# Missing required keys
transfer = %{
from: "acc_123",
to: "acc_456"
# Missing: amount
}
# => No compile-time error
# => Runtime error when accessing amount
# Wrong types
payment = %{
amount: "100", # => String instead of integer
currency: :usd # => Atom instead of string
}
# => No compile-time validation
# => Type errors appear in business logicProduction problems with raw maps:
- No compile-time key validation - Typos become runtime errors
- No required keys enforcement - Missing data causes crashes
- No type checking - Wrong types propagate through system
- No default values - Must handle missing keys everywhere
- No polymorphism - Cannot define behavior per type
Structs - Compile-Time Guarantees
Structs add compile-time structure and validation to maps.
Defining Structs
defmodule Money do
@enforce_keys [:amount, :currency] # => Required keys at compile time
defstruct [:amount, :currency] # => Struct definition
# => Creates %Money{} type
end
# => Returns module
# => Defines Money struct with required fields
# Create valid struct
price = %Money{amount: 1000, currency: "USD"}
# => Returns %Money{amount: 1000, currency: "USD"}
# => Type: %Money{}
# => Compile-time validation passed
# Missing required key - compile error
invalid = %Money{amount: 1000}
# => Compile error: required key :currency not found
# => Catches error before runtimeStructs enforce required keys at compile time.
Struct with Default Values
defmodule Account do
@enforce_keys [:id] # => Only :id required
defstruct [
:id, # => Required field
balance: 0, # => Default: 0
currency: "USD", # => Default: "USD"
active: true # => Default: true
]
end
# => Returns module
# => Creates Account struct with defaults
account = %Account{id: "acc_123"}
# => Returns %Account{
# id: "acc_123",
# balance: 0,
# currency: "USD",
# active: true
# }
# => Defaults applied automaticallyDefault values reduce boilerplate and provide safe fallbacks.
Pattern Matching with Structs
defmodule Transfer do
defstruct [:from_account, :to_account, :amount, :currency]
end
# => Returns module
# Pattern match on struct type
def process_transfer(%Transfer{} = transfer) do
# => Matches only Transfer structs
# => Compile-time type narrowing
%Transfer{
from_account: from, # => Extract from_account
to_account: to, # => Extract to_account
amount: amount, # => Extract amount
currency: currency # => Extract currency
} = transfer
validate_transfer(from, to, amount, currency)
# => Calls validation with extracted values
end
def process_transfer(_other) do
# => Matches non-Transfer values
{:error, :invalid_transfer_type}
# => Type mismatch caught
endPattern matching provides compile-time type checking.
Updating Structs
account = %Account{id: "acc_123", balance: 1000}
# => Returns %Account{id: "acc_123", balance: 1000, currency: "USD", active: true}
# Update fields (immutable)
updated = %{account | balance: 1500}
# => Returns new %Account{} with balance: 1500
# => Original account unchanged (immutability)
# => Type: %Account{}
# Cannot add new fields
invalid = %{account | new_field: "value"}
# => Compile error: unknown key :new_field for struct Account
# => Prevents typos and invalid fields
# Cannot update to wrong struct type
other = %Money{amount: 100, currency: "USD"}
mixed = %{other | balance: 200}
# => Compile error: key :balance not found in struct Money
# => Type safety enforcedStruct updates maintain type safety and prevent invalid operations.
Financial Domain Example with Structs
defmodule Money do
@enforce_keys [:amount, :currency]
defstruct [:amount, :currency]
def new(amount, currency) when is_integer(amount) and amount >= 0 do
# => Validates amount is non-negative integer
%Money{amount: amount, currency: currency}
# => Returns validated Money struct
end
def new(_amount, _currency) do
# => Invalid amount
{:error, :invalid_amount}
# => Returns error tuple
end
def add(%Money{currency: curr} = m1, %Money{currency: curr} = m2) do
# => Pattern match: same currency required
%Money{amount: m1.amount + m2.amount, currency: curr}
# => Returns new Money with sum
# => Preserves currency
end
def add(%Money{}, %Money{}) do
# => Different currencies
{:error, :currency_mismatch}
# => Cannot add different currencies
end
end
# => Returns module
# Valid operations
price1 = Money.new(1000, "USD") # => %Money{amount: 1000, currency: "USD"}
price2 = Money.new(500, "USD") # => %Money{amount: 500, currency: "USD"}
total = Money.add(price1, price2) # => %Money{amount: 1500, currency: "USD"}
# Invalid operations caught
invalid_money = Money.new(-100, "USD") # => {:error, :invalid_amount}
mixed_money = Money.new(100, "EUR") # => %Money{amount: 100, currency: "EUR"}
error = Money.add(price1, mixed_money) # => {:error, :currency_mismatch}Structs enable domain-specific validation and business rules.
Protocols - Polymorphic Behavior
Protocols define behavior that multiple types can implement independently.
Built-in Protocols
Elixir provides standard protocols for common operations.
String.Chars Protocol
# String.Chars defines to_string/1 behavior
price = Money.new(1000, "USD")
# Without protocol implementation
IO.puts(price)
# => Error: protocol String.Chars not implemented for %Money{}
# => Cannot convert to string
# Implement String.Chars for Money
defimpl String.Chars, for: Money do
def to_string(%Money{amount: amount, currency: currency}) do
# => Extract amount and currency
formatted_amount = :erlang.float_to_binary(amount / 100, decimals: 2)
# => Converts cents to dollars
# => Returns "10.00" for amount 1000
"#{currency} #{formatted_amount}"
# => Returns "USD 10.00"
end
end
# => Returns implementation module
# Now works with to_string/1
IO.puts(price) # => Output: USD 10.00
"Price: #{price}" # => Returns "Price: USD 10.00"
# => String interpolation uses to_string/1Protocols enable polymorphic behavior across types.
Enumerable Protocol
defmodule OrderItems do
defstruct items: []
def add_item(%OrderItems{items: items}, item) do
# => Adds item to order
%OrderItems{items: [item | items]}
# => Returns new OrderItems with prepended item
end
end
# => Returns module
# Implement Enumerable for OrderItems
defimpl Enumerable, for: OrderItems do
def count(%OrderItems{items: items}) do
# => Returns item count
{:ok, length(items)}
# => Tuple format required by protocol
end
def member?(%OrderItems{items: items}, item) do
# => Checks membership
{:ok, Enum.member?(items, item)}
# => Returns tuple with boolean
end
def reduce(%OrderItems{items: items}, acc, fun) do
# => Delegates to List reduce
Enumerable.List.reduce(items, acc, fun)
# => Enables all Enum functions
end
def slice(%OrderItems{items: items}) do
# => Enables slicing operations
{:ok, length(items), &Enumerable.List.slice(items, &1, &2, 1)}
# => Returns size and slice function
end
end
# => Returns implementation module
# Now works with Enum module
order = %OrderItems{items: [
Money.new(1000, "USD"),
Money.new(500, "USD")
]}
# => Returns OrderItems struct
Enum.count(order) # => Returns 2
Enum.map(order, &(&1.amount)) # => Returns [1000, 500]
total = Enum.reduce(order, 0, fn item, acc ->
acc + item.amount # => Sums amounts
end)
# => Returns 1500Enumerable protocol enables standard collection operations.
Custom Protocols
Define your own protocols for domain-specific polymorphism.
Arithmetic Protocol for Money
defprotocol Arithmetic do
@doc "Add two values"
def add(a, b)
@doc "Subtract second value from first"
def subtract(a, b)
end
# => Returns protocol definition
# => Any type can implement this
# Implement for Money
defimpl Arithmetic, for: Money do
def add(%Money{currency: curr} = m1, %Money{currency: curr} = m2) do
# => Same currency required
{:ok, %Money{amount: m1.amount + m2.amount, currency: curr}}
# => Returns result tuple
end
def add(%Money{}, %Money{}) do
# => Different currencies
{:error, :currency_mismatch}
# => Cannot add different currencies
end
def subtract(%Money{currency: curr} = m1, %Money{currency: curr} = m2) do
# => Same currency required
result = m1.amount - m2.amount
# => Calculate difference
if result >= 0 do
{:ok, %Money{amount: result, currency: curr}}
# => Non-negative result
else
{:error, :negative_balance}
# => Prevent negative money
end
end
def subtract(%Money{}, %Money{}) do
# => Different currencies
{:error, :currency_mismatch}
end
end
# => Returns implementation module
# Polymorphic arithmetic
price1 = Money.new(1000, "USD") # => %Money{amount: 1000, currency: "USD"}
price2 = Money.new(300, "USD") # => %Money{amount: 300, currency: "USD"}
{:ok, total} = Arithmetic.add(price1, price2)
# => Returns {:ok, %Money{amount: 1300, currency: "USD"}}
{:ok, difference} = Arithmetic.subtract(price1, price2)
# => Returns {:ok, %Money{amount: 700, currency: "USD"}}
Arithmetic.subtract(price2, price1)
# => Returns {:error, :negative_balance}
# => Prevents invalid stateCustom protocols enable domain-specific polymorphism with business rules.
Serialization Protocol
defprotocol Serializable do
@doc "Convert value to JSON-compatible map"
def to_json(value)
end
# => Returns protocol definition
# Implement for Money
defimpl Serializable, for: Money do
def to_json(%Money{amount: amount, currency: currency}) do
# => Extract fields
%{
amount: amount, # => Integer amount in cents
currency: currency, # => Currency code
formatted: to_string(%Money{amount: amount, currency: currency})
# => Human-readable format
}
# => Returns JSON-compatible map
end
end
# => Returns implementation module
# Implement for Account
defimpl Serializable, for: Account do
def to_json(%Account{id: id, balance: balance, currency: currency, active: active}) do
# => Extract all fields
%{
id: id,
balance: balance,
currency: currency,
active: active,
type: "account" # => Type discriminator
}
# => Returns JSON-compatible map
end
end
# => Returns implementation module
# Polymorphic serialization
serialize_for_api = fn value ->
value
|> Serializable.to_json() # => Protocol dispatch
|> Jason.encode!() # => JSON encoding
end
# => Returns anonymous function
price = Money.new(1500, "USD")
serialize_for_api.(price)
# => Returns JSON string with Money representation
account = %Account{id: "acc_123", balance: 1000}
serialize_for_api.(account)
# => Returns JSON string with Account representation
# => Same function handles different typesSerializable protocol enables polymorphic conversion without type checking.
Production Pattern - Domain Events
# Define event types with structs
defmodule Events do
defmodule PaymentReceived do
@enforce_keys [:account_id, :amount, :timestamp]
defstruct [:account_id, :amount, :timestamp, metadata: %{}]
end
defmodule PaymentSent do
@enforce_keys [:account_id, :amount, :timestamp]
defstruct [:account_id, :amount, :timestamp, metadata: %{}]
end
defmodule AccountClosed do
@enforce_keys [:account_id, :timestamp]
defstruct [:account_id, :timestamp, reason: nil]
end
end
# => Returns module with event definitions
# Define protocol for event handling
defprotocol EventHandler do
@doc "Process domain event"
def handle(event)
end
# => Returns protocol definition
# Implement for each event type
defimpl EventHandler, for: Events.PaymentReceived do
def handle(%Events.PaymentReceived{account_id: id, amount: amount}) do
# => Extract event data
# Increase account balance
AccountService.increase_balance(id, amount)
# => Returns {:ok, updated_account}
end
end
defimpl EventHandler, for: Events.PaymentSent do
def handle(%Events.PaymentSent{account_id: id, amount: amount}) do
# => Extract event data
# Decrease account balance
AccountService.decrease_balance(id, amount)
# => Returns {:ok, updated_account} or {:error, reason}
end
end
defimpl EventHandler, for: Events.AccountClosed do
def handle(%Events.AccountClosed{account_id: id, reason: reason}) do
# => Extract event data
# Mark account as closed
AccountService.close_account(id, reason)
# => Returns {:ok, closed_account}
end
end
# => Returns implementation modules
# Generic event processor
def process_event(event) do
# => Accepts any event type
EventHandler.handle(event)
# => Protocol dispatches to correct implementation
# => No type checking or case statements needed
end
# => Returns function
# Process different event types with same function
payment_received = %Events.PaymentReceived{
account_id: "acc_123",
amount: Money.new(1000, "USD"),
timestamp: DateTime.utc_now()
}
process_event(payment_received) # => Calls PaymentReceived handler
payment_sent = %Events.PaymentSent{
account_id: "acc_123",
amount: Money.new(300, "USD"),
timestamp: DateTime.utc_now()
}
process_event(payment_sent) # => Calls PaymentSent handler
account_closed = %Events.AccountClosed{
account_id: "acc_123",
timestamp: DateTime.utc_now(),
reason: "User request"
}
process_event(account_closed) # => Calls AccountClosed handlerProtocols enable extensible event handling without modifying core processor.
When to Use Each Approach
Use Maps When
- Prototyping - Quick experimentation without structure
- External data - JSON responses, dynamic payloads
- Configuration - App settings, feature flags
- Temporary data - Intermediate transformations
Use Structs When
- Domain models - Business entities (User, Order, Payment)
- Compile-time safety - Required fields, type checking
- Pattern matching - Type-based function dispatch
- API contracts - Request/response schemas
- Data validation - Enforcing business rules
Use Protocols When
- Polymorphism - Same operation across different types
- Extensibility - Adding behavior to existing types
- Decoupling - Separating interface from implementation
- Type-specific behavior - Different implementations per type
- Library design - Public interfaces for user types
Key Takeaways
Maps provide flexibility:
- Built-in key-value data structure
- No compile-time guarantees
- Use for dynamic or external data
Structs provide safety:
- Compile-time key validation
- Required keys enforcement
- Default values support
- Pattern matching with type checking
Protocols provide polymorphism:
- Define behavior across types
- Implement per type independently
- Built-in protocols (String.Chars, Enumerable)
- Custom protocols for domain behavior
Production progression: Start with maps for prototyping → Add structs for domain models → Use protocols for polymorphic behavior. Each layer adds structure and guarantees appropriate for production systems.
Financial modeling pattern: Structs (Money, Account) + Custom protocols (Arithmetic, Serializable) = Type-safe domain with polymorphic operations.