Instruction file imported from talk2MeGooseman/stream_closed_captioner_phoenix (
.github/instructions/elixir-phoenix.instructions.md). Copyright stays with the author.
Elixir and Phoenix Framework
General Guidelines
- Follow the official Elixir Style Guide and use
mix formatfor consistent formatting. - Use snake_case for variables/functions/atoms and PascalCase for module names.
- Keep functions short and focused; prefer pattern matching and guard clauses over nested conditionals.
- Favor meaningful names over short or generic ones.
- Comment only when necessary — let pattern matching and function names document intent.
- Apply the Single Responsibility Principle to modules and functions.
- Prefer composition over complex abstractions; use
use,import, andaliasthoughtfully. - Keep controllers thin — delegate business logic to contexts, services, or domain modules.
- Use Phoenix Contexts to organize related functionality and enforce boundaries.
- Extract reusable logic into separate modules or shared utilities.
- Use structs with
@enforce_keysto ensure required fields are present. - Leverage pattern matching for control flow instead of conditionals.
- Use the pipe operator (
|>) for data transformation pipelines. - Prefer immutability; avoid mutating data structures.
- Use
withfor sequential operations that may fail, ensuring clean error handling. - Avoid deeply nested case/cond statements — favor pattern matching in function heads.
- Use
alias,import, andrequireat the top of modules in a consistent order. - Follow Phoenix conventions for routing and controller actions.
- Use Phoenix generators to scaffold resources consistently.
- Leverage Ecto for database interactions with changesets for validation.
- Always define migrations with
change/0when possible, falling back toup/downonly when necessary. - Add database indexes for foreign keys and frequently queried columns.
- Use
null: falsein migrations to enforce non-null constraints at the DB level. - Scope queries in context modules or dedicated query modules for clarity and reuse.
- Use
Repo.preload/2to eagerly load associations and avoid N+1 queries. - Leverage
Ecto.Multifor complex database transactions. - Keep secrets and configuration out of code using runtime configuration and environment variables.
- Write isolated unit tests for contexts, schemas, and business logic.
- Test controllers and LiveViews with integration tests using
ConnTestorLiveViewTest. - Use ExUnit callbacks (
setup,setup_all) to prepare test fixtures. - Avoid
IO.inspectin production; useLoggerwith appropriate log levels. - Document public APIs and complex modules with
@moduledocand@doc.
Project Structure
- Organize code by Contexts (business domains) under
lib/<app_name>/. - Define web-related modules (controllers, views, templates, LiveViews) under
lib/<app_name>_web/. - Create service objects in
lib/<app_name>/services/to encapsulate complex business logic. - Define background jobs in
lib/<app_name>/jobs/using Oban or similar job processors. - Use
lib/<app_name>/types/for custom Ecto types and domain-specific type definitions. - Place GraphQL-related code (schemas, resolvers, types) in
lib/<app_name>_web/schema/andlib/<app_name>_web/resolvers/. - Keep plugs in
lib/<app_name>_web/plugs/for request pipeline customizations. - Define channels in
lib/<app_name>_web/channels/for real-time WebSocket communication. - Use
lib/<app_name>_web/components/for reusable LiveView components and function components. - Place queries in dedicated modules like
lib/<app_name>/queries/for complex database queries.
Commands
- Use
mix phx.newto generate a new Phoenix application. - Use
mix phx.gen.contextto generate a context with schema and migrations. - Use
mix phx.gen.htmlto generate a controller, views, and templates for a resource. - Use
mix phx.gen.liveto generate a LiveView for a resource. - Use
mix phx.gen.jsonto generate a JSON API for a resource. - Use
mix ecto.gen.migrationto create a new database migration. - Use
mix ecto.migrateto run pending migrations. - Use
mix ecto.rollbackto revert the last migration. - Use
mix ecto.resetto drop, create, and migrate the database. - Use
iex -S mixto start an interactive Elixir shell with the application loaded. - Use
iex -S mix phx.serverto start the Phoenix server in interactive mode. - Use
mix phx.serverto start the Phoenix development server. - Use
mix testto run the test suite. - Use
mix formatto format all Elixir files according to.formatter.exs. - Use
mix credoto run static code analysis for code quality. - Use
mix deps.getto fetch project dependencies. - Use
mix phx.routesto list all defined routes in the application. - Use
mix phx.digestto compile and digest static assets for production.
Elixir Language Best Practices
Pattern Matching
- Use pattern matching in function heads for clarity and polymorphism.
# Good: Pattern matching in function heads def process({:ok, result}), do: {:ok, transform(result)} def process({:error, reason}), do: {:error, reason} # Avoid: Using case inside function body when pattern matching suffices def process(result) do case result do {:ok, data} -> {:ok, transform(data)} {:error, reason} -> {:error, reason} end end
Guard Clauses
- Use guards to add constraints to pattern matches.
def calculate_discount(price) when price > 100, do: price * 0.9 def calculate_discount(price) when price > 50, do: price * 0.95 def calculate_discount(price), do: price
Pipe Operator
- Use the pipe operator for data transformation chains.
# Good: Clear data flow from top to bottom result = data |> parse() |> transform() |> validate() |> save() # Avoid: Nested function calls result = save(validate(transform(parse(data))))
With Expression
- Use
withfor sequential operations that may fail.def create_user(params) do with {:ok, validated} <- validate_params(params), {:ok, user} <- insert_user(validated), {:ok, _email} <- send_welcome_email(user) do {:ok, user} end end
Error Handling
- Return
{:ok, result}or{:error, reason}tuples for operations that may fail. - Use
!suffix (bang functions) only when you expect success and want to raise on errors.# Good: Returns tuple for caller to handle def fetch_user(id), do: Repo.get(User, id) |> to_result() # Use ! for operations that should raise def fetch_user!(id), do: Repo.get!(User, id)
Phoenix Framework Best Practices
Contexts
- Organize related functionality into Phoenix Contexts.
- Keep contexts as the public API for business logic.
- Don't access Ecto schemas directly from controllers; go through contexts.
# Good: Controller delegates to context def create(conn, %{"user" => user_params}) do case Accounts.create_user(user_params) do {:ok, user} -> # handle success {:error, changeset} -> # handle error end end
Controllers
- Keep controllers thin; delegate to contexts immediately.
- Use
action_fallbackfor consistent error handling. - Return appropriate HTTP status codes.
defmodule MyAppWeb.UserController do use MyAppWeb, :controller action_fallback MyAppWeb.FallbackController def create(conn, %{"user" => user_params}) do with {:ok, user} <- Accounts.create_user(user_params) do conn |> put_status(:created) |> render("show.json", user: user) end end end
LiveView
- Use LiveView for interactive, real-time features without writing JavaScript.
- Keep LiveView modules focused on presentation; delegate to contexts for business logic.
- Use
assign/3andassign_new/3to manage socket state. - Leverage
handle_event/3for user interactions. - Use
handle_info/2for handling PubSub broadcasts and async messages.def mount(_params, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(MyApp.PubSub, "updates") end {:ok, assign(socket, users: list_users())} end def handle_event("delete", %{"id" => id}, socket) do Accounts.delete_user(id) {:noreply, assign(socket, users: list_users())} end
Plugs
- Use plugs for reusable request pipeline logic.
- Define plugs at the router level for authentication/authorization.
- Create custom plugs in
lib/<app_name>_web/plugs/.defmodule MyAppWeb.Plugs.RequireAuth do import Plug.Conn def init(opts), do: opts def call(conn, _opts) do case get_session(conn, :user_id) do nil -> conn |> put_status(:unauthorized) |> halt() _user_id -> conn end end end
Changesets
- Use Ecto changesets for data validation and casting.
- Define changesets in schema modules or separate changeset modules.
- Validate data before inserting or updating.
def changeset(user, attrs) do user |> cast(attrs, [:email, :name, :age]) |> validate_required([:email, :name]) |> validate_format(:email, ~r/@/) |> validate_number(:age, greater_than: 0) |> unique_constraint(:email) end
Ecto Best Practices
Queries
- Build queries using Ecto's query syntax.
- Avoid N+1 queries by preloading associations.
# Good: Preload associations User |> preload(:posts) |> Repo.all() # Avoid: N+1 query users = Repo.all(User) Enum.map(users, fn user -> Repo.preload(user, :posts) end)
Transactions
- Use
Ecto.Multifor complex transactions.Multi.new() |> Multi.insert(:user, user_changeset) |> Multi.insert(:profile, fn %{user: user} -> profile_changeset(user) end) |> Repo.transaction()
Schemas
- Define schemas with clear field types and associations.
- Use
@primary_keyand@foreign_key_typewhen customizing keys. - Define virtual fields for computed or temporary data.
schema "users" do field :email, :string field :name, :string field :full_name, :string, virtual: true has_many :posts, Post timestamps() end
Migrations
- Keep migrations reversible when possible using
change/0. - Add indexes for foreign keys and frequently queried columns.
- Use
null: falseto enforce constraints.def change do create table(:posts) do add :title, :string, null: false add :user_id, references(:users, on_delete: :delete_all), null: false timestamps() end create index(:posts, [:user_id]) create unique_index(:posts, [:title]) end
GraphQL with Absinthe Best Practices
- Define types in
lib/<app_name>_web/schema/types/. - Keep resolvers focused in
lib/<app_name>_web/resolvers/. - Use dataloaders to batch database queries and avoid N+1 issues.
- Define input objects for mutations.
# Type definition object :user do field :id, non_null(:id) field :email, non_null(:string) field :posts, list_of(:post), resolve: dataloader(Repo) end # Resolver def create_user(_parent, %{input: params}, _resolution) do Accounts.create_user(params) end
Testing Guidelines
Unit Tests
- Write unit tests for contexts, schemas, and pure functions.
- Use ExUnit's
describeblocks to organize related tests. - Use
setupandsetup_allfor test fixtures.describe "create_user/1" do test "creates user with valid attributes" do attrs = %{email: "test@example.com", name: "Test"} assert {:ok, %User{} = user} = Accounts.create_user(attrs) assert user.email == "test@example.com" end test "returns error with invalid attributes" do assert {:error, %Ecto.Changeset{}} = Accounts.create_user(%{}) end end
Integration Tests
- Test controllers using
ConnTest. - Test LiveViews using
LiveViewTest.test "creates user", %{conn: conn} do conn = post(conn, Routes.user_path(conn, :create), user: @valid_attrs) assert %{id: id} = json_response(conn, 201)["data"] end
Test Data
- Use
ExMachinafor factories to generate test data. - Keep factories in
test/support/factory.ex.def user_factory do %User{ email: sequence(:email, &"user#{&1}@example.com"), name: "Test User" } end
Async Tests
- Use
async: truefor tests that don't share state.use MyApp.DataCase, async: true
Background Jobs with Oban
- Define jobs in
lib/<app_name>/jobs/. - Use
perform/1to execute job logic. - Configure queues and workers in
config/config.exs.defmodule MyApp.Jobs.SendEmailJob do use Oban.Worker, queue: :emails, max_attempts: 3 @impl Oban.Worker def perform(%Oban.Job{args: %{"user_id" => user_id}}) do user = Accounts.get_user!(user_id) Email.welcome_email(user) |> Mailer.deliver() :ok end end # Enqueue job %{user_id: user.id} |> MyApp.Jobs.SendEmailJob.new() |> Oban.insert()
API Development Best Practices
- Use Phoenix's JSON rendering for API responses.
- Define JSON views in
lib/<app_name>_web/views/. - Use
action_fallbackcontrollers for consistent error handling. - Return proper HTTP status codes (200, 201, 204, 400, 401, 404, 422, 500).
- Version APIs using route prefixes (
/api/v1/).defmodule MyAppWeb.UserView do use MyAppWeb, :view def render("index.json", %{users: users}) do %{data: render_many(users, __MODULE__, "user.json")} end def render("user.json", %{user: user}) do %{ id: user.id, email: user.email, name: user.name } end end
Security Best Practices
- Use strong parameters by casting and validating with changesets.
- Never trust user input; always validate and sanitize.
- Use CSRF protection (enabled by default in Phoenix).
- Store secrets in environment variables or runtime configuration.
- Use Bcrypt or Argon2 for password hashing (via
bcrypt_elixirorargon2_elixir). - Implement rate limiting for sensitive endpoints (e.g., with
Hammer). - Use HTTPS in production (configure in
endpoint.ex). - Set secure cookie options (
:http_only,:secure,:same_site).
Performance Best Practices
- Use database indexes for frequently queried columns.
- Preload associations to avoid N+1 queries.
- Use
Repo.stream/1for processing large datasets. - Cache expensive computations with
Cachex,Nebulex, or ETS. - Use
Task.async/awaitfor concurrent operations. - Profile with
:observer,:fprof, oreprofto identify bottlenecks. - Use
telemetryfor metrics and monitoring. - Defer heavy processing to background jobs.
Common Pitfalls
- Not preloading associations, causing N+1 queries.
- Mixing business logic in controllers or views.
- Forgetting to add database indexes on foreign keys.
- Not handling errors gracefully in
withstatements. - Using
!functions (bang functions) when errors should be handled. - Blocking the BEAM with long-running synchronous operations.
- Not using changesets for data validation.
Deployment and Production
- Use releases for production deployments (
mix release). - Configure runtime environment in
config/runtime.exs. - Use environment variables for secrets and configuration.
- Enable SSL/HTTPS in production.
- Set up proper logging and monitoring.
- Use
mix phx.digestto compile assets. - Run migrations on deployment with
mix ecto.migrate.