Elixir naming conventions
These rules extend the general naming conventions. Where the general rules and these overlap, the general rule is canonical; this page adds the Elixir-specific cases.
We assume the official Elixir naming conventions and mix format as a baseline — snake_case for atoms / functions / variables / files, PascalCase for modules, trailing ? for boolean predicates, trailing ! for "raises on failure", @specs on public functions. This page only documents the points where the Bliss Framework constrains or extends those defaults.
Casing summary
| Used for | Casing | Example |
|---|---|---|
| Filenames (modules, scripts) | snake_case.ex / snake_case.exs |
users.ex, locations_mapper.ex, auth_get_user_by_id_model.ex |
| Folder names | snake_case |
dhl_locations_factory_backend/, source_addresses/ |
| Module names | PascalCase, dotted |
MyApp.Users, MyApp.SourceAddresses.SourceAddressesMapper |
| Functions and macros | snake_case |
search_users, get_my_profile_data, inspect_user_action |
Predicates returning boolean |
snake_case? (trailing ?) |
is_active?, has_permission?, nil_or_empty?, sees_admin_page? |
| Raising variants | snake_case! (trailing !) |
fetch_env!, decode! |
| Variables and parameters | snake_case |
user_id, ctx, search_text, page_size |
| Atoms (incl. error reasons) | :snake_case |
:ok, :not_found, :create_location_error |
| Module attributes | @snake_case |
@common_expiration, @pubsub_mod, @repo |
| Compile-time constants | @SCREAMING_SNAKE — don't. Use @snake_case |
@common_expiration, not @COMMON_EXPIRATION |
| Struct fields | snake_case keys |
%UserContext{user_id: ..., request_id: ...} |
| ETS / cache / PubSub topic strings | snake_case with : separators |
"user_preferred_language:#{user_id}", "permission_changes" |
| Database error atoms (domain-translated) | :snake_case_error |
:search_locations_error, :ga_already_used |
A filename is the snake_case of its module name. MyApp.Locations.LocationsMapper lives at lib/my_app/locations/locations_mapper.ex — always.
Top-level namespaces
Two namespaces per project, no more:
| Namespace | Role | What lives here |
|---|---|---|
MyApp.* |
Business application | Phoenix contexts, mappers, business structs, supervision tree, Repo, Consts |
MyAppWeb.* |
HTTP / Channels / I/O | Endpoint, Router, Controllers, Plugs, Views, Channels, web-only helpers |
Database.* |
Generated provider layer | DbContext, Models.*, Processors.* (all autogenerated by db-gen) |
Models.* |
Cross-cutting structs | UserContext, ADUser, Ident.User, CSV row models |
Helpers.* |
Cross-cutting utilities | DateHelpers, StringHelpers, MapHelpers, LoggingHelpers, ... |
Database.*, Models.*, and Helpers.* deliberately have no app-name prefix — they are shared infrastructure and would be hoisted into a separate library on the day we extract one. Resist the urge to "make them more specific" by renaming to MyApp.Helpers.*; the lack of prefix is the convention.
If you need a feature-specific helper that isn't generic enough for Helpers.*, scope it to the context: MyApp.Locations.LocationsMapper, MyApp.Users.UserMappers. Not Helpers.LocationHelpers.
Contexts — MyApp.<Subject>
The Management layer. One module per business subject, named after the subject in plural form (matching the general singular/plural rule):
| Module | Folder | Subject |
|---|---|---|
MyApp.Users |
lib/my_app/users/users.ex |
Users |
MyApp.Locations |
lib/my_app/locations/locations.ex |
Locations |
MyApp.SourceAddresses |
lib/my_app/source_addresses/source_addresses.ex |
Source addresses |
MyApp.GlobalAddresses |
lib/my_app/global_addresses/global_addresses.ex |
Global addresses |
MyApp.Consts |
lib/my_app/consts.ex |
Cached lookup data |
A context module file has the same base name as the folder it lives in (locations/locations.ex). Supporting modules go alongside it:
lib/my_app/locations/
├── locations.ex # MyApp.Locations — the context
├── locations_mapper.ex # MyApp.Locations.LocationsMapper
├── clusters.ex # MyApp.Locations.Clusters — sub-subject
├── clusters_mapper.ex
├── clusters_locations.ex # MyApp.Locations.ClustersLocations — composite
└── clusters_locations_mapper.ex
When a subject grows a sub-subject
When a single context gets too big, split by the secondary concept the same way the general hierarchy rule does: MyApp.Locations.Clusters is allowed because Cluster is its own subject within Locations. The composite-relation table (clusters_locations) gets its own module too: MyApp.Locations.ClustersLocations.
What is not allowed: splitting one context into "Reads" and "Writes" modules, or by HTTP verb, or by "internal" vs "public". A context's public API is the set of functions in the module; if it's getting unwieldy, the answer is a sub-subject, not a horizontal split.
Controllers — MyAppWeb.<Subject>Controller
| Pattern | Example |
|---|---|
MyAppWeb.<SubjectPlural>Controller for collection endpoints |
MyAppWeb.GlobalAddressController (matches /api/global-addresses) |
MyAppWeb.<DomainAction>Controller for verb-shaped endpoints |
MyAppWeb.AddressesExportController, MyAppWeb.AuthController |
Always action_fallback MyAppWeb.FallbackController |
translates {:error, _} to HTTP responses in one place |
| One controller file per route scope | me_controller.ex, auth_controller.ex, system_controller.ex |
Standard action names follow Phoenix defaults: index, show, create, update, delete. For non-CRUD verbs use a snake_case action name that matches the route: def export_global_addresses(conn, params), def set_current_locale(conn, params). Don't invent do_* / handle_* prefixes.
The body of an action is ctx = user_ctx(conn) + one with chain + render(...). If a controller action is more than ~15 lines, the missing work belongs in the context.
Plugs — MyAppWeb.Plugs.<Purpose> or top-level MyAppWeb.<Purpose>Plug
Two acceptable shapes, pick one project-wide:
MyAppWeb.Plugs.Authentication,MyAppWeb.Plugs.Authorization,MyAppWeb.Plugs.RateLimiter— grouped underPlugs.*.MyAppWeb.RequestIdPlug,MyAppWeb.LocalePlug,MyAppWeb.LanguagesPlug— bare with aPlugsuffix.
Each plug does one thing — the name is the thing. No MyAppWeb.RequestPlug that does five different things.
Structs and types
A struct module defines defstruct, @enforce_keys, and a t() typespec:
defmodule Models.UserContext do
@enforce_keys [:user_id, :username]
defstruct [
:user_id, :user_oid, :username, :user,
:ip, :user_agent, :origin,
:locale, :request_id
]
@type t() :: %__MODULE__{
user_id: pos_integer(),
user_oid: binary(),
username: binary(),
user: Ident.User.t(),
ip: binary(),
user_agent: binary(),
origin: binary(),
locale: binary(),
request_id: binary()
}
end
| Rule | Why |
|---|---|
@enforce_keys lists every field that has no sensible default |
Catches partial construction at compile time |
@type t() :: %__MODULE__{...} is mandatory on cross-module structs |
Dialyzer / @spec callers reference it |
@derive Jason.Encoder on structs returned to a JSON view |
View can encode without manual mapping |
use Accessible on DB result models |
Lets callers use model[:field] access uniformly |
No :default for unrequired fields unless the default is nil |
Surprises down the line; prefer explicit construction |
Generated DB models follow the same shape but with @fields factored out:
defmodule Database.Models.AuthGetUserByIdModel do
@fields [:user_id, :code, :uuid, :username, :email, :display_name]
@enforce_keys @fields
@derive Jason.Encoder
defstruct @fields
@type t() :: %__MODULE__{...}
use Accessible
end
The @fields-then-@enforce_keys-then-defstruct triple is the project standard — don't break the pattern in hand-written models either.
Functions — verbs and shapes
The general Bliss verb registry applies. The Elixir shapes:
| Verb | Returns | Example |
|---|---|---|
get_* |
One row as a struct/map, or nil if absent (or the tuple shape) |
get_user(ctx, id), get_location_detail_by_id(ctx, id) |
get_*! |
One row, raises if absent | Repo.get!, Application.fetch_env! |
search_* |
Paged list, takes filters + page + page_size | search_users(ctx, text, page, page_size), search_locations(ctx, ...) |
list_* |
Unpaged collection (small, bounded) | list_source_addresses(ctx, params), list_locations_geo(ctx, ...) |
create_* |
Insert; returns the new row (or :ok) |
create_location(ctx, item), create_manual_validation(ctx, ...) |
update_* |
Update; returns the updated row (or :ok) |
update_location(ctx, id, data) |
delete_* |
Delete; returns :ok or the deleted row |
delete_location(ctx, id) |
ensure_* |
Idempotent upsert | ensure_user_groups_and_permissions(user_id, uuids) |
set_*_as_* |
Categorical state change | set_location_address_as_primary(ctx, id, la_id) |
enable_* / disable_* |
Flip a is_active-shaped field |
enable_user, disable_user_group |
is_*? / has_*? / can_*? / should_*? |
Pure boolean predicate | has_all_roles?, sees_admin_page?, nil_or_empty? |
validate_* |
Boundary check; returns tuple, never raises across context boundary | validate_provider_is_active, validate_token |
verify_* |
Postcondition / claim assertion | verify_user_identity |
process_* |
Multi-step batch | process_external_group_members |
parse_* |
Input parsing | parse_date, parse_socket_auth_token |
map_* |
Side-layer mapper function | map_items, map_item_detail, map_user_from_ad |
inspect_* |
Returns formatted log payload | inspect_user_action(:search_users) |
The shape rules:
- Boolean-returning functions end in
?. Always.has_permission?, nothas_permission. The general Bliss rule says "predicates start withis/has/can/should"; the Elixir version is "and they end in?". - Functions that raise on failure end in
!. Reserve!for the actual raising variant. Don't decorate every function with!to mean "important". map_*is reserved for mappers (data transformation, no I/O). Don't name a "fetch + transform" functionmap_*just because it ends in a transformation.- No
do_*/_helpersuffix on private functions. Usedefpand a clear name. The privacy is communicated bydefp, not by a_prefix on the name.
Two-arity variants
When a function operates on the calling user by default but can be called for another user, define both arities:
def get_user_assigned_groups(ctx), do: DbContext.auth_get_user_assigned_groups(ctx.user_id, ctx.user_id)
def get_user_assigned_groups(ctx, user_id), do: DbContext.auth_get_user_assigned_groups(ctx.user_id, user_id)
Same name, different arity, related semantics. Don't introduce get_my_assigned_groups + get_user_assigned_groups for the same operation.
"My" prefix
Use get_my_* / update_my_* when the function is specifically and only about the calling user (and the caller cannot vary it): get_my_profile_data(ctx), get_my_global_id(access_token, request_id). Don't decorate every context call with my_ just because it takes a ctx.
Parameters
ctx is the first argument
UserContext.t() (aliased everywhere as ctx) is the first parameter of every context function. Always. Even if today's body doesn't read every field, the next variant will, and consistency at the call site matters more than parameter parsimony.
def search_locations(ctx, search_text, address_filters, search_filters, page, page_size)
def get_location_detail_by_id(ctx, item_id)
def update_location(ctx, item_id, update_data)
Parameter order
After ctx, parameters go in this order:
- Identifier of the entity being acted upon —
item_id,user_id,location_id. - Required data —
search_text,update_data, the entity to insert. - Filters and options —
address_filters,search_filters,filters. - Pagination, last —
page,page_size.
This mirrors the PostgreSQL parameter order rule. When passing through to DbContext, the SQL function's parameter order already follows the same convention, so the wrapper is a direct passthrough.
Default arguments
Use \\ default for optional parameters with a sensible default. Use the :eg_value_not_provided sentinel only in autogenerated DbContext wrappers — never in hand-written context code. In a context, the right shape is:
def search_notifications(ctx, filters \\ %{}, page \\ nil, page_size \\ nil) do
DbContext.search_notifications(ctx.user_id, ctx.locale, filters[:search_text], page, page_size)
end
nil means "not provided" for optional database arguments; let the SQL function decide what to do with it.
Standard parameter names
A small ubiquitous vocabulary:
| Parameter | Type | Meaning |
|---|---|---|
ctx |
UserContext.t() |
The actor / request context |
item_id, user_id, <entity>_id |
pos_integer() |
The thing being acted on |
params |
map() |
Raw controller params (only at the controller / context boundary) |
filters |
map() |
Search filters bundle |
page, page_size |
pos_integer() \| nil |
Pagination |
search_text |
binary() \| nil |
Whisper / search input |
correlation_id / request_id |
binary() |
Threaded into logs and outbound headers |
access_token |
binary() |
For calls to external identity providers |
locale |
binary() |
Two-letter language code |
Aliases and imports
At the top of every module, in this order, blank line between groups:
defmodule MyApp.Users do
alias Helpers.LoggingHelpers
alias Models.{ADUser, UserContext}
alias Database.DbContext
alias MyApp.Users.{GraphApi, UserMappers}
require Logger
use LoggingHelpers
end
Result (from the :result package) is used so often it doesn't need a project-level alias — Result.ok/1, Result.error/1, Result.map/2, Result.and_then/2 read fine fully-qualified. Add alias only if you import a submodule like Result.Ok.
aliaslines first, grouped by origin (external libs, thenModels.*, thenDatabase.*, then same-app modules).importandrequirenext (usually onlyrequire Logger).uselast (macros that expand into definitions).
Prefer alias over fully-qualified module names in the body — but only for modules you actually use more than once. A single-use module stays fully qualified.
alias Models.{ADUser, UserContext} is the project-preferred shape when you need three or more aliases from the same parent. For two or fewer, write them on separate lines.
Result type — naming side of the contract
The architectural rules for the result type live in Result type and error handling in the index — the Bliss default is the :result package: tagged tuples {:ok, value} / {:error, reason} always constructed with Result.ok/1 / Result.error/1 and composed with Result.map/2 / Result.and_then/2 / Result.map_error/2. Don't write raw tuple literals as the output of a function in your own code.
The naming-side rules:
| Position | Type | Naming rule |
|---|---|---|
Success payload (inside {:ok, _}) |
Operation-class shape | Mapped entity, list of entities, paged map %{items, count, pages, page_size}, nil, or boolean() — see the shape table. Never a :no_data / :nothing / :empty sentinel atom. |
Error reason (inside {:error, _}) |
Atom (default) or {atom, map} |
Call-site-specific, snake_case, named in the caller's vocabulary. :ga_already_used, :location_not_found, :create_location_error. Never a string, raw library struct, or unstructured nested tuple. Use {:reason_atom, %{metadata_map}} only when a caller actually destructures the metadata. |
| Constructor at call site | Result.ok/1 / Result.error/1 |
Use the constructors, not raw {:ok, _} / {:error, _} literals, in your own code. Constructors are greppable and let :result's combinators thread through. |
| Combinator at call site | Result.map/2 / Result.and_then/2 / Result.map_error/2 / Result.with_default/2 / Result.catch_error/3 / Result.perform/2 |
Reach for the named combinator before reaching for case. A case on a result is fine as a leaf operation; a case ladder is a sign that combinators would read better. |
Function @spec return type |
Result.t(error, value) |
The :result package exports Result.t(error_type, value_type). Use it. Don't expand it into {:ok, value} \| {:error, reason} at every call site. |
| Constructor as function reference | &Result.ok/1 or &Result.Ok.of/1 |
Both work; both produce {:ok, value}. The flat form is shorter; the submodule form reads as "lift via the Ok constructor" for FP-style readers and for code that takes a constructor module as a parameter. |
The Bliss verb registry maps directly onto success payload shapes:
| Function name shape | Returns inside {:ok, _} |
|---|---|
get_<entity>(ctx, id) |
One entity (struct/map). Missing → Result.error(:<entity>_not_found). |
get_<entities>(ctx, ...) / list_<entities>(...) |
List of entities. Empty list is success: Result.ok([]). |
search_<entities>(ctx, ..., page, page_size) |
A map: %{items: [...], count: n, pages: p, page_size: s}. |
create_<entity>(ctx, ...) |
The new entity, or %{<entity>_id: id} when only the id matters. |
update_<entity> / delete_<entity> / set_<entity>_as_<state> / enable_<entity> / disable_<entity> |
nil (use Result.ok(nil)). Pick one project-wide convention if you instead want bare :ok for no-payload success. |
ensure_<entity>(ctx, ...) |
The (existing or newly created) entity. |
is_<thing>? / has_<thing>? returning Result.t() instead of bare boolean() |
Result.ok(true) / Result.ok(false). Most predicates should stay bare boolean(); only wrap when the operation can also fail (e.g. DB error during the check). |
Logging
One shape, project-wide, via Helpers.LoggingHelpers:
{:error, reason} ->
Logger.error("Error occurred while searching locations",
reason: inspect(reason),
detail: inspect_user_action(:search_locations)
)
Result.error(:search_locations_error)
- Message starts with "Error occurred while …" / "Could not …" / a present-tense gerund clause describing the failed operation. No exclamation marks, no stack traces in the message text.
reason: inspect(reason)—inspect/1because reasons can be tuples, structs, or nested.detail: inspect_user_action(:atom)— the macro fromHelpers.LoggingHelpersinjectsuser_id,username, and the action atom. Don't hand-construct the equivalent map.- Add
metadata: inspect(metadata)only when there's structured context worth keeping (HTTP response metadata, batch counters). Don't pad logs with empty maps.
Log levels: Logger.error for "the operation failed", Logger.warning for "degraded but proceeding", Logger.info for state transitions worth recording, Logger.debug for tracing during development. Production runs at :info by default.
PubSub topic strings
Topic naming follows the format <concept>:<id> with optional :<sub-concept> segments:
"user_preferred_language:#{user_id}"
"permission_changes"
"change_request:#{change_request_id}:vote"
Topics live in a <Subject>.PubSub module that owns the topic string construction and the broadcast functions:
defmodule MyApp.Users.PubSub do
alias Phoenix.PubSub
@pubsub_mod MyApp.PubSub
def broadcast_user_preferred_language_changed(user_id, language_code) do
PubSub.broadcast(@pubsub_mod, user_preferred_language_topic(user_id),
{:user_preferred_language_changed, user_id, language_code})
end
def user_preferred_language_topic(user_id), do: "user_preferred_language:#{user_id}"
end
The topic-string function is named <event>_topic/N and is part of the module's public API so subscribers don't reach for string interpolation.
Behaviour and protocol naming
| Pattern | Use |
|---|---|
<Concept>Behaviour (suffix Behaviour) |
Defines a @callback-driven contract |
<Concept>Adapter / <Concept>Mock |
Implementations of a Behaviour |
<Concept> (a defprotocol) |
Polymorphic dispatch on the first argument's type |
Don't introduce a Behaviour for "we might want to swap this someday". Introduce it when there is a second implementation, or when the test suite needs an explicit mock contract.
File header
Every module has at most a brief @moduledoc:
defmodule MyApp.Locations do
@moduledoc """
Context for making basic CRUD operations for Locations entries
"""
...
end
- One sentence. Reads like "Context for …", "Helper for …", "Mapper for …", "Provides …".
@moduledoc falsewhen the module is internal (e.g.Application, autogenerated processors). Don't leave the placeholder triple-quoted string frommix phx.new.- No "this file is part of MyApp" boilerplate. The module name says it.
Comments
Default to writing no comment. Add one only when the why is non-obvious — a hidden constraint, a workaround for a quirk in a third-party library, the rationale for a particular default. Don't restate the function body in English. The autogenerated @doc "Calls database function auth.create_api_key" strings are fine because they survive code generation; don't paraphrase them into a context module.
Commented-out code blocks (TODO scaffolding, abandoned alternatives) are noise. Either ship the code, file an issue, or delete it.
Migrations and database wrappers — see PostgreSQL
The Database.DbContext, Database.Models.*, and Database.Processors.* modules are autogenerated from the SQL function catalogue. Their naming is driven entirely by the PostgreSQL naming conventions:
| SQL | Elixir |
|---|---|
Function auth.get_user_by_id(_user_id, _target_user_id) |
DbContext.auth_get_user_by_id(user_id, target_user_id) returning {:ok, [%Database.Models.AuthGetUserByIdModel{}]} |
Function public.create_journal_message_for_entity(...) |
DbContext.create_journal_message_for_entity(...) |
Function const.get_business_units(_locale) |
DbContext.const_get_business_units(locale) |
Schema prefix in SQL (auth., unsecure., const.) |
Underscore prefix on the Elixir function (auth_*, unsecure_*, const_*) |
Don't rename or wrap a generated function just because you don't like its name. If the name is wrong, fix the SQL function and regenerate.
Anti-patterns
| Anti-pattern | Why | Use instead |
|---|---|---|
Naming a boolean predicate without ? (is_active, has_permission) |
Loses the Elixir convention for boolean-shape | is_active?, has_permission? |
do_create_location, internal_create_location for a defp |
The defp already says private |
defp create_location_row with a real name |
| Per-context invented log shape | Inconsistent grep / parsing | Logger.error("...", reason: inspect(reason), detail: inspect_user_action(:atom)) |
Raw {:ok, value} / {:error, reason} literals as your own function's output |
Bypasses the grep index Result.ok / Result.error give; encourages drift |
Result.ok(value) / Result.error(reason) from :result. Raw tuples are fine when destructuring or inside a provider that wraps a third-party library. |
Adopting Simplificator3000.Result in new projects |
Deprecated as of simplificator_3000 v1.0; struct-based model is no longer the Bliss default |
:result package. Migrate existing code at the team's pace; don't introduce new dependents. |
:error / :db_error / {:error, "string"} as the reason |
Carries no information for the fallback | Result.error(:search_locations_error), Result.error(:ga_already_used), call-site-specific atoms |
{:ok, nil} from a get_<entity> to mean "not found" |
Forces every caller to add is_nil(value) |
Result.error(:<entity>_not_found), optionally {:error, {:<entity>_not_found, %{<entity>_id: id}}} when a caller destructures |
{:ok, :no_data} / {:ok, :nothing} / {:ok, :empty} |
Sentinel atoms inside the success payload re-invent error reasons | Result.error(:<thing>_not_found) for missing; Result.ok([]) for empty collections; Result.ok(nil) for no-payload success |
Bare :ok from one path, {:ok, _} from another, for the same function |
Caller has to handle both shapes | Pick the shape the operation class demands and use it on every clause |
{:ok} (one-element tuple as success) |
Pointless — :ok or Result.ok(nil) already say "success, no value" |
Result.ok(nil) (or bare :ok if that's the project's no-payload convention) |
Leaking %Postgrex.Error{} / %Ecto.Changeset{} / %HTTPoison.Error{} as the error reason |
Controller and view see DB/HTTP types | Translate at the context: Result.error(:<domain_atom>) or Result.error({:<domain_atom>, %{...}}) |
{:error, "Permission denied"} (string reason) |
Strings can't be pattern-matched safely | Result.error(:permission_denied) (put the prose in a translation table or a log line) |
{:error, {:validation, [errs]}} (ad-hoc nested-tuple reason) |
Unstructured, drifts across call sites | Result.error({:validation_failed, %{errors: [...]}}) with the metadata under a known map shape |
Controller pattern-matching on a %Postgrex.Error{} or other library struct |
The context is leaking provider shapes past the boundary | Fix the context to translate library errors to domain atoms; controller only sees {:ok, _} / {:error, atom} |
Hand-written case ladder where two combinators would do |
Same logic, more places to get the tuple shape wrong | Result.map/2 for a pure transform, Result.and_then/2 for the next fallible step, Result.map_error/2 to rewrite the reason |
raise across a context boundary |
Controller must try/rescue |
Return {:error, reason} |
Calling Repo.query directly from a context |
Bypasses DbContext and the processors |
Add the SQL function, regenerate, call DbContext.* |
| Calling another context's private helpers | Couples two contexts | Extract a Side layer module (Helpers.* or a struct in Models.*) |
| Storing the current user in the process dictionary | Hidden state, hard to test | Pass ctx as the first argument |
Building UserContext inside a context |
The context shouldn't know about Plug.Conn |
Build it in MyAppWeb.ConnHelpers.user_ctx/1 |
Bare GenServer.start_link outside the supervision tree |
Orphan processes, no restart | Add it as a child of MyApp.Application (or a nested supervisor) |
One shared Phoenix.PubSub for everything |
Topic collisions across domains | One PubSub per domain (MyApp.PubSub, Notifications.PubSub, ...) |
Hand-editing Database.DbContext / Database.Models.* |
Lost on next regeneration | Fix the SQL function and regenerate |
MyApp.Helpers.LocationHelpers for feature-specific code |
Helpers.* is for generic utilities |
MyApp.Locations.LocationsMapper or a private function in the context |
MyAppWeb modules calling other MyAppWeb modules across feature boundaries |
Web layer becomes its own dependency graph | Move shared logic into MyApp.* (Management) or MyAppWeb.*Helpers (Side) |
@SCREAMING_SNAKE module attributes |
Not the Elixir convention | @snake_case |
| Action name not matching the route verb | Reader has to cross-reference router and controller | def export_global_addresses for get "/export/global-addresses" |
Committing config/.local.exs or config/<env>.local.exs |
Leaks personal overrides; defeats the purpose | Add both patterns to .gitignore; keep checked-in defaults in config.exs / <env>.exs |
Hardcoding production secrets in prod.exs |
Secrets in the binary / VCS | Read with System.fetch_env! / System.get_env \|\| raise in runtime.exs |
runtime.exs without the if config_env() == :prod do guard |
Dev/test boots fail when a prod-only env var is absent | Wrap the prod-specific block; let dev/test use <env>.exs + .local.exs |
System.get_env("FOO") for a value the app cannot run without |
Silent nil, failure at first use, no hint to the operator |
System.fetch_env!("FOO") or System.get_env("FOO") \|\| raise """...""" with an example value |
Reading config with Application.get_env at compile time via @var |
Survives compile-time, ignores runtime.exs overrides |
Read at the point of use; or use Application.compile_env/2 only when truly needed (macros, guards) |
Hand-checked sentinel like "CHANGE_ME", "TODO", "XXX" in checked-in config |
Inconsistent grep, easy to ship | Project-wide single sentinel ("FILL_ME_UP") |
Worked example — a complete context function
The shape every context function follows:
defmodule MyApp.Locations do
@moduledoc "Context for making basic CRUD operations for Locations entries"
alias MyApp.Locations.LocationsMapper
alias Models.UserContext
alias Database.DbContext
use Helpers.LoggingHelpers
require Logger
@spec create(UserContext.t(), map()) :: Result.t(atom(), %{location_id: pos_integer()})
def create(ctx, item) do
DbContext.create_location(
ctx.username, ctx.user_id,
item.primary_address.global_address_id,
item.location_type_code,
item.business_unit_code,
item.primary_address.title,
item.title,
item.location_geom,
nil
)
|> case do
{:ok, [row]} ->
Result.ok(%{location_id: row.location_id})
{:error, %Postgrex.Error{postgres: %{pg_code: "69101"}}} ->
Result.error(:ga_already_used)
{:error, reason} ->
Logger.error("Error occurred while creating location",
reason: inspect(reason),
detail: inspect_user_action(:create_location)
)
Result.error(:create_location_error)
end
end
end
And its matching controller action:
defmodule MyAppWeb.LocationController do
use MyAppWeb, :controller
alias MyApp.Locations
action_fallback MyAppWeb.FallbackController
def create(conn, %{"location" => params}) do
ctx = user_ctx(conn)
with {:ok, data} <- Locations.create(ctx, params) do
conn
|> put_status(:created)
|> render("show.json", location: data)
end
end
end
Five things in five places: the context decides; the mapper shapes; the provider does; the controller binds it to HTTP; the fallback translates failure into an HTTP response. Each module owns its slice.
See also
- General naming conventions — the shared verb registry and the singular/plural rule.
- General coding structure — the three-layer model.
- Elixir coding guidelines (this section's index) — the two-OTP-app split, contexts,
UserContext, supervision, error handling. - PostgreSQL naming conventions — governs every name you see in
Database.DbContext. - Official Elixir naming conventions — the baseline this page extends.