Coding conventions observed in this codebase. Follow these when contributing.
- One module per file, file path mirrors module name (
auth/tokens.ex→ElixirApiCore.Auth.Tokens) - Business logic in contexts:
Accounts,Auth,Audit - Web layer in
ElixirApiCoreWeb.*(controllers, plugs, router) - Schemas live under their context directory (
accounts/user.ex,auth/refresh_token.ex)
Module header order:
use/import/alias- Module attributes (
@valid_roles,@min_password_length) - Type definitions (
@type,@spec) - Public functions
- Private helpers
Alias conventions:
- One alias per line, alphabetical within namespace groups
import Ecto.Query, warn: falsefor query modules
- Functions:
snake_case, descriptive verbs (create_account,rotate_refresh_token) - Predicates end with
?:enabled?/0,demoting_last_owner?/2 - No abbreviations in public APIs:
hash_passwordnotpwd_hash - Error atoms are semantic and specific:
:invalid_refresh_token,:last_owner_required(not:erroror:failed) - Module attributes for constants:
@valid_roles ~w(owner admin member)
All public functions return {:ok, result} or {:error, reason} tuples.
with/1 chains for sequential validation:
with {:ok, user} <- get_user_by_email(email),
:ok <- verify_user_password(user, password) do
{:ok, result}
endTransaction rollbacks use semantic atoms:
Repo.transaction(fn ->
case condition do
nil -> Repo.rollback(:invalid_refresh_token)
result -> result
end
end)Normalization functions translate transaction results to clean tuples:
defp normalize_rotate_result({:ok, {:refresh_token_reuse_detected, _}}),
do: {:error, :refresh_token_reuse_detected}
defp normalize_rotate_result({:ok, %{} = result}), do: {:ok, result}Modules wrap Application.get_env with a private config/2 helper:
defp config(key, default) do
:elixir_api_core
|> Application.get_env(__MODULE__, [])
|> Keyword.get(key, default)
endFail-fast validation runs at boot for production-critical config.
Changeset pipeline order: cast → normalize → validate_required → validators → constraints
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :display_name])
|> normalize_email()
|> validate_required([:email])
|> validate_format(:email, ~r/.../)
|> validate_length(:email, max: 320)
|> unique_constraint(:email, name: :users_email_lower_index)
end- All schemas use
binary_idprimary keys - Timestamps use
:utc_datetime - Sensitive fields use
redact: true - Enum fields use
Ecto.Enumwith a module attribute for values
action_fallback ElixirApiCoreWeb.FallbackControlleron every controller- Actions use
with/1— success renders, errors fall through to fallback - Success responses:
%{data: %{...}} - Error responses:
%{error: %{code: "...", message: "...", details: %{}}} - JSON serialization helpers are private functions at the bottom:
user_json/1,account_json/1
DataCase(async: true) for DB tests,ConnCase(async: true) for controller testsasync: falseonly when shared state requires it (ETS, Application env)describeblocks group related tests, no nesting- Fixtures use
Map.get_lazyfor optional associations - Assert on pattern match:
assert {:ok, result} = ... - Use
errors_on/1for changeset assertions
- Guard clauses on public functions for type safety:
when is_binary(user_id) @specused selectively on pure utility functions, not on all functions@typefor public types referenced across modules
- Comments explain why, not what
- No comments on obvious pipelines or pattern matches
- Architectural justifications get multi-line comments (e.g., locking strategy)
- Always
DateTime.utc_now(), never local time - Truncate to seconds:
DateTime.truncate(:second) - Compare with
DateTime.compare/2, not operators - Tests inject time via
opts:issue_access_token(id, id, :owner, now: ~U[...])
Use ElixirApiCore.Repo.Scoped for all account-scoped queries:
import ElixirApiCore.Repo.Scoped
# Filter any queryable by account
Membership |> where_account(account_id) |> Repo.all()
# Scoped fetch by primary key (returns nil if wrong account)
scoped_get(Membership, id, account_id)
# Scoped fetch that raises on miss
scoped_get!(Membership, id, account_id)- Guards on
account_id(when is_binary(account_id)) prevent nil from slipping through - Queries that are legitimately unscoped (user lookup by email, token lookup by hash) use
Repodirectly - The
RequireAccountScopeplug in the:authenticatedpipeline halts ifcurrent_account_idis missing - Use
setup_tenant_pair/0in tests to create two isolated tenant contexts and assert no cross-tenant leakage
use Oban.Worker, queue: :queue_name@impl Oban.Workeronperform/1- Return
:okfor success require Loggerbefore usingLogger.info/1
Side effects use a with_audit/2 wrapper that logs on success and passes through errors:
|> with_audit(fn data ->
%{action: "user.registered", actor_id: data.user.id, ...}
end)