← docs.speechwave.live

Lesson 3: modules, structs & the shape of an Elixir project

How Elixir organizes code without classes

Primary source: Phoenix Contexts guide, which explains the architectural pattern behind how Speechwave is organized.

The mental model shift

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:

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.

Speechwave's architecture

Here's how your project maps this principle:

lib/ ├── speechwave.ex ← Root module (empty namespace) ├── speechwave/ │ ├── application.ex ← Supervision tree (boots the app) │ ├── repo.ex ← Database connection │ │ │ ├── accounts.ex ← Context: functions for user operations │ ├── accounts/ │ │ ├── user.ex ← Schema + struct: data shape for users │ │ ├── scope.ex ← Plain struct: wraps current user │ │ ├── user_token.ex ← Schema: auth tokens │ │ ├── user_identity.ex ← Schema: OAuth identities │ │ └── user_consent.ex ← Schema: GDPR consent records │ │ │ ├── talks.ex ← Context: functions for talk operations │ ├── talks/ │ │ ├── talk.ex ← Schema + struct: data shape for talks │ │ └── talk_session.ex ← Schema + struct: live sessions │ │ │ ├── reactions.ex ← Context: functions for reactions │ └── reactions/ │ └── reaction.ex ← Schema + struct: emoji reactions │ ├── speechwave_web.ex ← Web module setup (use/import injector) └── speechwave_web/ ├── router.ex ← URL → controller/LiveView mapping ├── endpoint.ex ← HTTP entry point ├── user_auth.ex ← Auth plugs ├── live/ │ ├── dashboard_live.ex ← LiveView: speaker dashboard │ ├── talk_live.ex ← LiveView: audience view │ └── session_analytics_live.ex ├── controllers/ ← Traditional request/response handlers └── components/ ← Reusable UI components

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.

Rails mapping:
Phoenix / ElixirRails / 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.exApplicationController (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.

Schemas: defining data shape

A schema maps a database table to an Elixir struct and defines how to validate changes.

lib/speechwave/talks/talk.ex

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:

LineWhat it does
use Ecto.SchemaInjects the schema macro and struct-building machinery
import Ecto.ChangesetMakes cast, validate_required, etc. available without prefix
schema "talks"Maps to the talks database table and defines a %Talk{} struct
field :title, :stringDeclares a field: the struct will have a .title key
has_many / belongs_toDeclares associations (like ActiveRecord, but loaded explicitly via Repo.preload)
timestamps()Adds inserted_at and updated_at (note: inserted_at, not created_at)
Key insight: The 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.

The changeset function

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.

Ruby equivalent:
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.

Plain structs (no database)

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:

The three import keywords

These appear at the top of almost every module and control which functions are available. They look similar but do very different things:

KeywordWhat it doesExample from Speechwave
alias Creates a shortcut name for a module. No new functions, just less typing. alias Speechwave.Talks.Talk
Now Talk works instead of Speechwave.Talks.Talk
import Pulls functions into scope so you can call them without the module prefix. import Ecto.Changeset
Now 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.Schema
Injects the schema macro, struct definition, and more
Key insight for reading code: When you see a function you can't find, work backwards through the top of the file:
  1. Check import lines: the function is probably in one of those modules
  2. Check use lines: the function may have been injected by a macro
  3. For LiveViews and controllers, check speechwave_web.ex: it chains additional use and import calls

You can also multi-alias with braces:

lib/speechwave/accounts.ex:9

alias Speechwave.Accounts.{User, UserConsent, UserIdentity, UserNotifier, UserToken}

This creates five aliases in one line. Equivalent to writing five separate alias statements.

Contexts: the public API layer

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 shapeProvides functions that use %Talk{} structs
Contains the changeset function (validation rules)Contains create_talk, delete_talk, list_talks, etc.
Knows about fields and associationsKnows about business rules, queries, scoping
Never touches the database directlyCalls 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: ...)
Rails parallel: This is like the difference between calling 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.

How naming maps to files

Elixir has a simple convention: the module name maps to the file path.

Module nameFile path
Speechwave.Talkslib/speechwave/talks.ex
Speechwave.Talks.Talklib/speechwave/talks/talk.ex
SpeechwaveWeb.DashboardLivelib/speechwave_web/live/dashboard_live.ex
Speechwave.Accounts.Userlib/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.

Note: Module nesting in Elixir is purely cosmetic. 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.

Practice

Question 1

You see this at the top of talks.ex:

import Ecto.Query
alias Speechwave.Talks.Talk

What does each line let you do?

Right. 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.
Remember: 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.

Question 2

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
Exactly. 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.
Look at the top of the module. When a function has no module prefix and isn't defined in the current module, check the import lines. One of them brought it into scope. Try again.

Question 3

What does %__MODULE__{user: user} in Scope expand to?

Right. __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.

Question 4

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?

Right. The 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.
Remember from the docs reference: unqualified names like 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.

Your next step

Open two files side by side:

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.