How Elixir organizes code without classes
Primary source: Phoenix Contexts guide, which explains the architectural pattern behind how Speechwave is organized.
In Ruby, organization revolves around classes. A Talk class holds both data (attributes) and behavior (methods). You call talk.save! and the object knows how to persist itself.
In Elixir, data and behavior are separated:
Struct: just fields, no methods)A %Talk{} struct doesn't know how to save itself. You pass it to Repo.insert/1, a function in a completely different module. Data flows through functions; it never "does" anything on its own.
Here's how your project maps this principle:
The pattern: Context modules (talks.ex, accounts.ex) sit next to directories of the same name containing the schemas they operate on. The context is the public API; the schemas are the data definitions.
| Phoenix / Elixir | Rails / Ruby |
|---|---|
Schema (talk.ex) | Model (data shape + validations only) |
Context (talks.ex) | Model (queries + business logic) or Service Object |
LiveView (dashboard_live.ex) | Controller + view + JS, combined |
Router (router.ex) | routes.rb |
speechwave_web.ex | ApplicationController (shared setup) |
The key difference: Rails combines data + queries + validations + callbacks in the Model. Phoenix splits them: the schema defines shape and validations; the context module provides all query and business logic functions.
A schema maps a database table to an Elixir struct and defines how to validate changes.
defmodule Speechwave.Talks.Talk do
use Ecto.Schema
import Ecto.Changeset
schema "talks" do
field :title, :string
field :slug, :string
has_many :talk_sessions, Speechwave.Talks.TalkSession
belongs_to :user, Speechwave.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(talk, attrs) do
talk
|> cast(attrs, [:title, :slug])
|> validate_required([:title, :slug])
|> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*$/,
message: "only lowercase letters, numbers, and hyphens"
)
|> validate_length(:slug, max: 100)
|> unique_constraint(:slug)
end
end
Let's break this down line by line:
| Line | What it does |
|---|---|
use Ecto.Schema | Injects the schema macro and struct-building machinery |
import Ecto.Changeset | Makes cast, validate_required, etc. available without prefix |
schema "talks" | Maps to the talks database table and defines a %Talk{} struct |
field :title, :string | Declares a field: the struct will have a .title key |
has_many / belongs_to | Declares associations (like ActiveRecord, but loaded explicitly via Repo.preload) |
timestamps() | Adds inserted_at and updated_at (note: inserted_at, not created_at) |
schema block does two things at once: it defines a database mapping and creates a struct. After this module is compiled, %Talk{} is a valid struct with fields :id, :title, :slug, :user_id, :inserted_at, :updated_at, plus association fields.
Every schema has a changeset/2 function. It's the validation pipeline: it takes an existing struct and a map of new attributes, and returns a changeset describing the proposed changes with any validation errors attached.
def changeset(talk, attrs) do
talk
|> cast(attrs, [:title, :slug]) # pick allowed fields from attrs
|> validate_required([:title, :slug]) # must be present
|> validate_format(:slug, ~r/.../) # must match regex
|> validate_length(:slug, max: 100) # max length
|> unique_constraint(:slug) # DB-level uniqueness
end
Functions like cast, validate_required, validate_format all come from Ecto.Changeset, available without a prefix because of the import Ecto.Changeset at the top.
class Talk < ApplicationRecord
validates :title, :slug, presence: true
validates :slug, format: { with: /\A[a-z0-9][a-z0-9-]*\z/ },
length: { maximum: 100 },
uniqueness: true
end
In Rails, validations are declared on the class and run implicitly on save. In Elixir, you explicitly build a changeset and inspect it. changeset.valid? tells you if it passed, changeset.errors gives you the details, and nothing touches the database until you call Repo.insert or Repo.update.
Not every struct maps to a database table. Scope is a plain struct: just defstruct, no schema.
lib/speechwave/accounts/scope.ex
defmodule Speechwave.Accounts.Scope do
alias Speechwave.Accounts.User
defstruct user: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
Three things to notice:
defstruct user: nil: defines a struct with one field (:user) defaulting to nil. No database table involved.%__MODULE__{user: user}: __MODULE__ is a compile-time constant that expands to the current module name. This is the same as writing %Speechwave.Accounts.Scope{user: user} but won't break if you rename the module.for_user/1 has two clauses: one for a real user (pattern matches %User{}), one for nil. This is the multi-clause pattern from Lesson 1.These appear at the top of almost every module and control which functions are available. They look similar but do very different things:
| Keyword | What it does | Example from Speechwave |
|---|---|---|
alias |
Creates a shortcut name for a module. No new functions, just less typing. | alias Speechwave.Talks.TalkNow Talk works instead of Speechwave.Talks.Talk |
import |
Pulls functions into scope so you can call them without the module prefix. | import Ecto.ChangesetNow cast(talk, attrs, fields) works instead of Ecto.Changeset.cast(...) |
use |
Runs a macro that can inject code, imports, aliases, anything. The nuclear option. | use Ecto.SchemaInjects the schema macro, struct definition, and more |
import lines: the function is probably in one of those modulesuse lines: the function may have been injected by a macrospeechwave_web.ex: it chains additional use and import callsYou can also multi-alias with braces:
alias Speechwave.Accounts.{User, UserConsent, UserIdentity, UserNotifier, UserToken}
This creates five aliases in one line. Equivalent to writing five separate alias statements.
Context modules are where behavior lives. They're the boundary between "what the app does" and "how data is shaped."
Compare the schema and context for Talks:
Speechwave.Talks.Talk (schema) | Speechwave.Talks (context) |
|---|---|
Defines the %Talk{} struct shape | Provides functions that use %Talk{} structs |
Contains the changeset function (validation rules) | Contains create_talk, delete_talk, list_talks, etc. |
| Knows about fields and associations | Knows about business rules, queries, scoping |
| Never touches the database directly | Calls Repo.insert, Repo.all, Repo.delete |
The context is what LiveViews and controllers call. They never reach into schemas for queries:
# LiveView calls the context
talks = Talks.list_talks(scope)
# NOT this (don't query from LiveViews):
talks = Repo.all(from t in Talk, where: ...)
Talk.where(user: user).order(created_at: :desc) directly in a controller (which Rails allows) versus going through a service object or scope. Phoenix enforces this separation by convention. The context is the only module that touches Repo for its domain.
Elixir has a simple convention: the module name maps to the file path.
| Module name | File path |
|---|---|
Speechwave.Talks | lib/speechwave/talks.ex |
Speechwave.Talks.Talk | lib/speechwave/talks/talk.ex |
SpeechwaveWeb.DashboardLive | lib/speechwave_web/live/dashboard_live.ex |
Speechwave.Accounts.User | lib/speechwave/accounts/user.ex |
Unlike Ruby, this is convention only. The compiler doesn't enforce it. But every Elixir project follows it, and your editor's "go to file" will work predictably.
Speechwave.Talks.Talk is not defined "inside" Speechwave.Talks. The dots are just part of the atom name. There's no implicit relationship. Each module is completely independent. This is different from Ruby, where class Talks::Talk actually nests inside the Talks namespace and can access its constants.
You see this at the top of talks.ex:
import Ecto.Query
alias Speechwave.Talks.Talk
What does each line let you do?
import makes another module's functions callable without a prefix, so from(t in Talk, ...) works instead of Ecto.Query.from(...). alias is just a name shortcut: no new functions, just less typing.import brings functions into scope (you can call them without the module name). alias creates a name shortcut for a module (it doesn't import any functions). Try again.Looking at Speechwave.Accounts.User, where does the cast function in the changeset pipeline come from?
defmodule Speechwave.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
# ...
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_required([:email])
# ...
end
end
import Ecto.Changeset makes all changeset functions (cast, validate_required, validate_format, put_change, get_field, etc.) available without the Ecto.Changeset. prefix. You can verify with h Ecto.Changeset.cast in IEx.import lines. One of them brought it into scope. Try again.What does %__MODULE__{user: user} in Scope expand to?
__MODULE__ is a compile-time constant that expands to the current module's name. Inside Speechwave.Accounts.Scope, it becomes Speechwave.Accounts.Scope. This idiom avoids hardcoding the module name, so it survives renames.__MODULE__ is a special compile-time value that expands to the fully qualified name of the module it appears in. Inside the Scope module, it's Speechwave.Accounts.Scope. Try again.You want to find where the function validate_format is defined. You see import Ecto.Changeset at the top of the file. What's the fastest way to read its docs?
import tells you the module. Use the fully qualified name in IEx. h validate_format alone won't work in a bare IEx session because the function isn't imported there. And the hex package is ecto, not ecto_changeset. Ecto.Changeset is a module inside the ecto package.validate_format only work in IEx if they're imported there (which they aren't). You need the full module path. And the function is in the ecto hex package, not a separate ecto_changeset package. Try again.Open two files side by side:
lib/speechwave/accounts/user.ex (the schema)lib/speechwave/accounts.ex (the context)For each public function in accounts.ex, trace which schema it touches and how. Notice that the context never reaches into another context's schemas. Accounts works with User, UserToken, UserIdentity; Talks works with Talk and TalkSession. Each context owns its domain.