Elixir coding guidelines
The Bliss Framework's general coding guidelines were written for application services. Elixir — built on Erlang's actor model, with Phoenix's "contexts" already shaping the application — fits the trio (I/O → Management → Providers) very naturally, but it adds its own dialect: two top-level OTP modules (MyApp and MyAppWeb), a UserContext struct threaded as the first argument to almost every business call, autogenerated database wrapper modules, and tagged-tuple result shapes that determine control flow.
This section captures the adjustments specific to an Elixir/Phoenix service the way we build them at KeenMate: Phoenix contexts as Management, one wrapper module per stored function as Providers, structs in Models.* as the typed boundaries, the :result package as the unified return shape (constructors and combinators on top of plain tagged tuples), and the conventions that keep a multi-thousand-function codebase navigable.
Read the general coding guidelines and general naming conventions first — everything here builds on them. We also assume the official Elixir style guide and mix format defaults as the baseline; this page only covers the points where Bliss adds or constrains a rule.
What's different from a typical Bliss service
| Topic | C# / generic service | Elixir / Phoenix |
|---|---|---|
| Runtime | One process per app, threads inside | BEAM — thousands of lightweight processes, supervised in trees |
| Top-level module | One namespace (MyApp.*) |
Two: MyApp.* (business) and MyAppWeb.* (HTTP/Channels/I/O) |
| Layering | I/O → Management → Providers as folders/projects | Same trio: MyAppWeb.*Controller → Phoenix contexts (MyApp.<Subject>) → Database.DbContext and external clients |
| Identifiers | PascalCase / camelCase | snake_case everywhere; modules are PascalCase aliases of underscored filenames |
| Errors | Exceptions with types | Tagged tuples {:ok, value} / {:error, reason} via the result package — always constructed with Result.ok/1 / Result.error/1, composed with Result.map/2 / Result.and_then/2 / Result.map_error/2. Library structs (%Postgrex.Error{}, %Ecto.Changeset{}) never escape a context. Never raise across a context boundary. See Result type and error handling. |
| Validation | Often inside services | At the I/O layer (controller) and at the boundary of each context function |
| State | Request-scoped or service-scoped | Mostly request-scoped data + supervised stateful processes (GenServer, GenStage, PubSub, ETS caches) |
| User identity | DI-injected ICurrentUser |
UserContext.t() struct built once by the controller and passed as the first argument to every context call |
| "Side layer" | Models, Helpers, Mappers, Constants | Models.*, Helpers.*, <Context>.<Subject>Mapper, <App>.Consts (memoized) |
| Pub/sub | Message bus | Phoenix.PubSub, one named instance per domain (MyApp.PubSub, Notifications.PubSub, …) started in the supervision tree |
| Background work | Hosted services / queues | Task.Supervisor, GenStage, Memoize with TTL, periodic supervised workers |
What stays the same
These Bliss principles apply unchanged:
- Be replaceable — your contexts, your structs, your function signatures. A future maintainer should be able to read a context module cold and know what it does. Don't lean on cleverness that only the original author understands.
- Ubiquitous Language —
user,tenant,permission,groupmean the same thing inauth.user_info(DB),Database.DbContext.auth_get_user_by_id/2,MyApp.Users, the/api/usersendpoint, andUsers.svelte. The general rule that a database table is singular while a collection-shaped function/endpoint is plural is the same here:MyApp.Users.get_user/2,MyApp.Users.search_users/3. - DRY — pagination metadata lives in one
BaseMapper.new_paged_results/3, not re-implemented per context. Logging shape lives in oneHelpers.LoggingHelpersmacro. Lookup data lives in one memoizedConstsmodule. - Use only what you need — no premature
GenServer, no prematureSupervisor, no prematureProtocol. A function that maps one struct does not need aBehaviour. Add the abstraction only when a second caller appears. - Restrain yourself — one verb registry, one result type (the
:resultpackage), one logging shape, one way of building theUserContext. Pick once, apply everywhere. Elixir's flexibility makes this rule especially load-bearing — without it, every caller pays for every callee's shape choices. - Side layer purity —
Models.*,Helpers.*, andDatabase.Models.*carry no dependencies on contexts or on the web app. They could be lifted into a separate umbrella application tomorrow.
The two-OTP-app split
Every Phoenix service we ship has two top-level modules under lib/:
lib/
├── my_app/ # The business application
│ ├── application.ex # Supervision tree
│ ├── repo.ex # Ecto.Repo
│ ├── consts.ex # Memoized lookup loaders
│ ├── users/ # One folder per business subject
│ │ ├── users.ex # Context module — Management layer
│ │ ├── user_mappers.ex # Side layer / Mappers
│ │ └── pub_sub.ex # Domain-specific PubSub topics
│ ├── locations/
│ │ ├── locations.ex
│ │ └── locations_mapper.ex
│ └── base_mapper.ex # Shared mapper helpers
│
├── my_app_web/ # The HTTP/Channels application
│ ├── endpoint.ex
│ ├── router.ex
│ ├── controllers/ # I/O layer — thin wrappers
│ ├── views/ # JSON view modules
│ ├── plugs/ # Cross-cutting request middleware
│ ├── auth/ # Authentication-specific I/O
│ └── helpers/ # Web-only helpers (Conn, JSON, HTML)
│
├── database/ # Generated provider layer
│ ├── db_context.ex # One function per stored proc — Providers
│ ├── models/ # One struct per stored-proc result row
│ └── processors/ # One result parser per stored proc
│
├── models/ # Cross-cutting structs (Side layer)
│ ├── user_context.ex
│ ├── ad_user.ex
│ └── ident/user.ex
│
└── helpers/ # Cross-cutting helpers (Side layer)
├── date_helpers.ex
├── string_helpers.ex
├── logging_helpers.ex
└── ...
MyAppWeb.* may call MyApp.* freely, but never the reverse — the business app must not know there is an HTTP layer above it. The only exception is a few MyAppWeb.*Helpers (e.g. GeoHelpers) that contain pure transformations and are aliased by contexts; treat those as Side layer, not as I/O.
Impureim sandwich in Elixir
The layer rules below are not arbitrary folder hygiene — they are how Bliss applies the Impureim Sandwich (functional core, imperative shell) in an Elixir codebase. Read that section first; everything here is the Elixir-specific mapping of it. The general coding guidelines call it "the main principle of our code structure" — it is, in Elixir too.
The principle: push every side effect to the top and bottom of a call; keep everything in between pure. Impure code (I/O, DB, HTTP, time, randomness, logging) lives in a thin shell at the edges; the core that transforms data is pure — same input, same output, no side effects, trivially testable. In Elixir that maps onto the modules you already have:
| Sandwich part | Pure / impure | Elixir modules |
|---|---|---|
| Top of the shell (I/O in) | Impure | MyAppWeb.*Controller, MyAppWeb.Plugs.* — read the request, build ctx |
| Bottom of the shell (I/O out) | Impure | Providers: Database.DbContext, MyApp.<Subject>.GraphApi, MyApp.Communications.Mailer, a TwilioProvider — anything that physically talks to the outside world |
| The pure core | Pure | <Subject>Mapper, Helpers.*, Models.*, the data-shaping inside the context — no ctx-driven I/O, no DB, no logger |
| The seam that holds it together | Impure orchestration only | MyApp.<Subject> context — calls the impure providers, branches on the result, hands the data to the pure core |
The load-bearing consequence — and the one a mechanical reading of "contexts orchestrate providers" misses:
If it physically calls an external service, it is a Provider
A call that crosses the process boundary — an SMS send through Twilio, a SQL query, a mail send, a Redis GET — is impure and belongs in its own provider module (TwilioProvider, GraphApi, DbContext, Mailer), never inlined into a context, a mapper, or a helper. "Twilio communication" means a TwilioProvider whose only job is the wire call, returning {:ok, value} / {:error, reason}. The context orchestrates it; the mapper shapes its result; the controller binds it to HTTP. The impurity stays sandwiched at the bottom edge — it does not leak into the core.
What this buys you
Keeping the impurity isolated at the edges is not theory — it pays off the moment you test or reuse the code:
- The Management layer becomes unit-testable in isolation. Because a context calls providers through a known contract (
{:ok, value}/{:error, reason}), you can test it with stub providers — a fakeTwilioProviderreturning canned responses, a fakeDbContextrecording the calls it received — and assert on the orchestration and mapping logic without spinning up Postgres, an HTTP server, or a job queue. When the wire call is welded into the context, every test has to stand up the real infrastructure; once it's a provider, the test injects a double. (Introduce aBehaviourfor the provider only when you actually need the mock contract — see Behaviour and protocol naming.) - The same Management path runs from any I/O shell. Because the context knows nothing about how it was invoked, the same business call can be driven from a
MyAppWebcontroller, an Oban worker, aMixtask, or a scheduled job — each is just a different top-of-the-shell entry point building actxand calling the same context function. Amix my_app.send_reminder --user-id 123that callsNotifications.send_reminder(ctx, 123)directly is zero extra work; if the orchestration lived inside the worker, you'd have to copy half of it to reuse it. - "Where does Twilio get called?" has exactly one answer —
TwilioProvider. One place to add retries, a timeout, a circuit breaker, or a test double; one place to look when the external service changes.
This is why the rules in the next section exist: thin controllers (don't fatten the top of the shell), one provider per external system (keep the bottom of the shell isolated and replaceable), mappers with no I/O (protect the pure core), and "providers don't talk to providers" (the core, not another shell, does the composing). When the layering looks like bureaucracy, this is the principle it is serving.
Layering, in Elixir terms
The three-layer Bliss model expressed as Elixir modules:
sequenceDiagram
Browser->>MyAppWeb.Router: HTTP request
MyAppWeb.Router->>MyAppWeb.Plugs: pipeline (auth, locale, request-id)
MyAppWeb.Plugs->>MyAppWeb.UserController: dispatched action
MyAppWeb.UserController->>MyAppWeb.ConnHelpers: ctx = user_ctx(conn)
MyAppWeb.UserController->>MyApp.Users: search_users(ctx, params)
MyApp.Users->>Database.DbContext: search_users(ctx.user_id, ...)
Database.DbContext->>Database.Processors: parse_result(rows)
Database.Processors-->>Database.DbContext: {:ok, [%Model{}]}
Database.DbContext-->>MyApp.Users: {:ok, items}
MyApp.Users->>MyApp.Users.UserMappers: map_user_info_users(items)
MyApp.Users-->>MyAppWeb.UserController: {:ok, value} or {:error, reason}
MyAppWeb.UserController->>MyAppWeb.UserView: render("index.json", ...)
MyAppWeb.UserView-->>Browser: JSON
MyAppWeb.*Controller= I/O. The boundary. Buildsctx, calls one context function, renders. Action body is usually three to ten lines. Errors fall through toaction_fallback MyAppWeb.FallbackController.MyAppWeb.Plugs.*= I/O / boundary middleware. Authentication, locale resolution, request-id assignment, rate limiting, CORS. Each plug does one thing.MyApp.<Subject>(e.g.MyApp.Users,MyApp.Locations) = Management. The Phoenix context. Receivesctx+ plain inputs, orchestrates one or more provider calls, runs the mapper, returns the result struct/tuple. Never calls another context's internals directly — if two contexts genuinely need to share, extract a helper or a Side layer module.Database.DbContext= Providers (database). Autogenerated bydb-gen. One function per stored procedure, mirroring the SQL function signature. Never callRepo.querydirectly from a context — go throughDbContext.MyApp.<Subject>.GraphApi,MyApp.<Subject>.Redis,MyApp.<Subject>.Mailer,MyApp.ExternalAddressesProviders= Providers (external). One module per external system. Same contract asDbContext: a context calls it, gets a tuple back.Models.*,Database.Models.*,<Context>.<Subject>Mapper,Helpers.*,MyApp.Consts= Side layer.
The "providers don't talk to other providers" rule from the general guidelines holds: DbContext does not call GraphApi, GraphApi does not call Mailer. Orchestration lives in the context.
Controllers stay thin
A controller action does four things and nothing else:
defmodule MyAppWeb.LocationController do
use MyAppWeb, :controller
alias MyApp.Locations
action_fallback MyAppWeb.FallbackController
def index(conn, params) do
ctx = user_ctx(conn)
with {:ok, locations} <- Locations.search_locations(ctx, params) do
render(conn, "index.json", locations: locations)
end
end
end
That's the shape. If a controller action grows past ~15 lines or starts touching DbContext, the work belongs in the context. Use action_fallback so error tuples ({:error, :not_found}, {:error, :validation_failed}, …) are translated to HTTP responses in one place.
Contexts orchestrate, mappers shape, providers do
A context function follows the same shape every time: validate or transform inputs if needed, call one or more providers, branch on the result, hand off to a mapper, log + tag on error, return.
def search_locations(ctx, search_text, address_filters, search_filters, page, page_size) do
DbContext.search_locations(
ctx.user_id, ctx.locale,
search_text, address_filters,
%{"tags" => search_filters[:search_tags]},
page, page_size
)
|> case do
{:ok, items} ->
LocationsMapper.map_items(items, page_size)
{:error, reason} ->
Logger.error("Error occurred while searching locations",
reason: inspect(reason),
detail: inspect_user_action(:search_locations)
)
Result.error(:search_locations_error)
end
end
Three things to notice:
ctxis the first argument — always. Even if the function doesn't use every field today, the next variant will.|> case do— pipe into a case for a single provider call;withchains for multi-step orchestrations. Pick the shape that reads cleanest; don't mix in the same function.- Errors are logged with
inspect(reason)andinspect_user_action(:atom)— theHelpers.LoggingHelpersmacro pullsctx.user_idandctx.usernamefrom the surrounding scope automatically. This is the project-wide log shape; do not invent a new one per context.
Providers stay mechanical
The Database.DbContext module is autogenerated from the SQL function catalogue (db-gen). Do not hand-edit it. If you need a new provider call, add the SQL function and regenerate. Each call is a thin wrapper that:
- Builds the parameter list, filtering out
:eg_value_not_providedsentinels (the way Elixir expresses "this optional SQL parameter was omitted"). - Executes the query through
MyApp.Repo. - Hands the raw rows to a
Database.Processors.*Processorto parse into typed structs. - Returns
{:ok, [%Model{}]}or{:error, reason}.
External-system providers (HTTP clients, mail, Redis, AAD/Graph) follow the same return contract — {:ok, value} or {:error, reason} — so contexts can compose them with with without caring whether the provider is a DB call or an HTTP call.
The UserContext rule
Every business operation has an actor and a request. We capture both in one struct and pass it as the first argument to every context-level function:
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__{...}
def system_ctx(username \\ "system"), do: %__MODULE__{user_id: 1, username: username || "system", ...}
end
- Built in the controller by
MyAppWeb.ConnHelpers.user_ctx/1(oranonymous_ctx/1for unauthenticated calls). Never built in a context. - System actor: when a background process or another context needs to act without an authenticated user (cache warm-up, scheduled job, internal recalc), call
UserContext.system_ctx().user_id: 1is reserved across all our apps for the system actor — see the PostgreSQL multi-tenant rule. request_idflows end-to-end — pulled fromLogger.metadata, attached to every outbound HTTP header (X-Correlation-ID), included in every log line. This is the Elixir-side of the PostgreSQL_correlation_idstory.ctxis the first argument everywhere — including private helpers within a context. Don't reach for module attributes or process dictionary; pass the context. If a function genuinely doesn't needctx, that is a hint it belongs inHelpers.*or in a mapper.
Side layer
Models — Models.* and Database.Models.*
Two flavours, same rules:
Models.*— hand-written cross-cutting structs (UserContext,ADUser,Ident.User). Definedefstruct,@enforce_keysfor required fields,@type t() :: %__MODULE__{...}. No logic beyond constructors.Database.Models.*— autogenerated, one per stored-procedure result row. Always@derive Jason.Encoder(so a controller can hand them straight to a JSON view), alwaysuse Accessible(so callers can usemodel[:field]syntax), always at()typespec.
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
Models carry no behaviour, no validation, no defaults beyond the trivial. They are vessels. See Models in the general guide.
Mappers — one per subject, plus a BaseMapper
Every context that returns shaped data has a sibling <Subject>Mapper module:
MyApp.Locations→MyApp.Locations.LocationsMapperMyApp.SourceAddresses→MyApp.SourceAddresses.SourceAddressesMapperMyApp.Users→MyApp.Users.UserMappers(plural when the module covers several map shapes)
Mappers have no logic of their own — no DB calls, no logger, no ctx. They take raw rows from a provider and produce the shape the controller renders. Pagination metadata, geometry decoding, and timestamp templating are factored into MyApp.BaseMapper:
def map_items(items, page_size) do
items |> BaseMapper.new_paged_results(&map_item/1, page_size)
end
def map_item(item) do
Map.take(item, ~w(location_id title tags employee_count)a)
|> Map.merge(%{
location_type: %{code: item.location_type_code, title: item.location_type_title},
primary_address: %{...}
})
end
See Mappers in the general guide.
Helpers — Helpers.*
Cross-cutting pure utilities live in lib/helpers/, namespaced under Helpers.* regardless of the app: Helpers.DateHelpers, Helpers.StringHelpers, Helpers.MapHelpers, Helpers.LoggingHelpers. Web-only helpers live under MyAppWeb.*Helpers (ConnHelpers, JsonHelpers, HtmlHelpers) because they take a Plug.Conn.
The same "no internal state, no side effects beyond what's obvious from the name" rule from the general guide applies. If a function in Helpers.* ever reaches for Application.get_env, makes an HTTP call, or hits a database — it does not belong in Helpers.*.
Constants and lookups — Consts with Memoize
Reference data (lookup tables fetched from the DB) lives in a single MyApp.Consts module that wraps DbContext.const_* calls with Memoize.defmemo and a TTL:
@common_expiration :timer.seconds(60)
defmemo get_business_units(), expires_in: @common_expiration do
case DbContext.const_get_business_units() do
{:ok, items} -> Result.ok(items)
{:error, reason} ->
Logger.error("Error occurred while getting business_units", reason: inspect(reason))
Result.error(:get_business_units_error)
end
end
The Elixir equivalent of the const.* schema on the PostgreSQL side. Locale-dependent lookups take locale as part of the memo key; user-dependent ones use Memoize.Cache.get_or_run/2 with a composite key.
Configuration
Configuration is layered, with three distinct concerns separated:
- Compile-time, checked-in defaults —
config/config.exsandconfig/<env>.exs(dev.exs,test.exs,prod.exs). - Per-developer overrides, never committed —
config/.local.exsandconfig/<env>.local.exs. - Runtime, environment-driven values —
config/runtime.exs, loaded after compilation, before the supervision tree starts.
The four-tier load order
config/config.exs ends with three import lines, in this exact order:
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
File.regular?("config/.local.exs") && import_config(".local.exs")
File.regular?("config/#{config_env()}.local.exs") && import_config("#{config_env()}.local.exs")
That gives four layers, each overriding the previous:
| Layer | File | Checked in? | Purpose |
|---|---|---|---|
| 1. Project defaults | config/config.exs |
Yes | Cross-environment defaults, shared structure, integrations that don't vary per environment |
| 2. Environment | config/dev.exs / test.exs / prod.exs |
Yes | What differs by environment but is the same for every developer / deploy in that environment |
| 3. Machine-global overrides | config/.local.exs |
No (gitignored) | Per-developer values that apply to every environment (e.g. a personal DB password used in dev and test) |
| 4. Machine + environment overrides | config/dev.local.exs / prod.local.exs / ... |
No (gitignored) | Per-developer values for one specific environment (the most common shape) |
Both .local.exs variants are loaded with File.regular?(...) && import_config(...). The File.regular?/1 guard means a missing file is silent — checking out a fresh clone still boots, even though no .local.exs exists yet.
.gitignore must include both patterns so an absent-minded git add config/ cannot leak overrides:
What belongs in .local.exs vs runtime.exs
These two mechanisms exist for different reasons; don't mix them.
Goes in .local.exs |
Goes in runtime.exs |
|---|---|
| The thing only this developer's machine needs | The thing every deploy needs to reconfigure without recompile |
| Local DB credentials, local Redis port, local SMTP relay you use for testing | Production DB credentials, production Redis, production SMTP |
A locally checked-out shared library overridden via path: in mix.exs |
Endpoint URL/port, secret key base |
| Personal Azure / AAD app registration for local OIDC | Production AAD client ID / secret / tenant |
| A locally-running FastAPI on a non-default port | Feature flags that should flip per deployment |
Pointing cors_plug at your front-end dev server |
External service URLs (Graph API, Nominatim, FastAPI) |
The rule of thumb: if checking the value into git would force every other developer to either also have your value, or to override it locally, it belongs in .local.exs. If the value needs to differ between staging and production deploys of the same compiled release, it belongs in runtime.exs.
Sentinels for "you must override this"
Checked-in config files use "FILL_ME_UP" as a sentinel string for values every deployment must override:
config :dhl_locations_factory_backend, :openid_connect_providers,
aad: [
discovery_document_uri: "https://login.microsoftonline.com/{FILL_ME_UP}/v2.0/.well-known/openid-configuration",
client_id: "FILL_ME_UP",
client_secret: "FILL_ME_UP",
...
]
FILL_ME_UP is greppable across the codebase, never matches a real value, and produces an obvious failure if someone forgets to override it. It is the Elixir analogue of the SQL error.raise_NNNNN story: one well-known sentinel that means "this must be replaced". Pick the same sentinel project-wide.
runtime.exs — production reconfiguration
runtime.exs runs after compilation, before the supervision tree starts, and is the only config file that runs inside an OTP release. Everything we want to reconfigure between deploys lives here.
The whole interesting block is wrapped in if config_env() == :prod do — dev and test get their values from dev.exs / test.exs / .local.exs:
import Config
if config_env() == :prod do
db_hostname =
System.get_env("DB_HOSTNAME") ||
raise """
environment variable DB_HOSTNAME is missing.
For example: MAIN_DB_SERVICE_HOST
"""
config :my_app, MyApp.Repo,
hostname: db_hostname,
username: System.fetch_env!("DB_USERNAME"),
password: System.fetch_env!("DB_PASSWORD"),
database: System.fetch_env!("DB_DATABASE"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
...
end
Four things to notice:
-
One env-var-reading style per required-ness shape. Three patterns, used consistently:
Pattern When System.fetch_env!("FOO")Required, terse failure (raises ArgumentError) — fine for self-evident namesSystem.get_env("FOO") \|\| raise """..."""Required, with a hand-written error message explaining what the value looks like — preferred for non-obvious vars System.get_env("FOO", "default")Optional, with a hardcoded fallback System.get_env("FOO") \|> case do ... endOptional, with a transformation when present (CSV splits, integer parses) The custom
|| raise """..."""shape is the project default for the high-signal required vars — it includes an example value in the error message so a deploy operator who hits the failure knows immediately what to set. -
All values that need to vary per deploy live in
runtime.exs. Database, Redis, OIDC client + secret, endpoint host/port, secret key base, mailer credentials, external API URLs and keys, CORS origins, feature toggles, formats, fonts — every one. Compile-timeprod.exsshould be nearly empty, holding only configuration that genuinely cannot change at runtime (CSP headers, logger format). -
Parse-and-validate at boot, not at use.
String.to_integer(...)on a port value happens inruntime.exs, not in the supervisor that consumes it. IfPOOL_SIZE=potato, we want the release to fail at boot with a clear message — not the first time someone opens a DB connection. -
runtime.exsruns after compilation. Anything that affects compiled bytecode (module attributes computed from config,@otp_app, dialyzer types) must stay inconfig.exs/<env>.exs. Theruntime.exsdocstring comment from the Phoenix generator says this — keep that comment in place; it is a reminder to future-you, not boilerplate.
Reading config at runtime
Three idiomatic shapes for reading config inside the application; pick by required-ness:
# Required — raise at the read site if missing. Use when the absence is a deploy bug.
Application.fetch_env!(:my_app, :graph_api_field_mapping)
# Optional — returns nil if missing.
Application.get_env(:dhl_locations_factory_backend, :httpoison_options)
# Optional with default at the call site.
Application.get_env(:my_app, :http_proxy, [])
Read config at the point of use, not at compile time, unless there is a measurable hot-path reason. Reading via @compile_time_value Application.compile_env(...) is appropriate for values consumed inside macros or guards; for everything else, runtime reads are simpler and survive runtime.exs overrides.
Supervision and processes
The supervision tree is declared in MyApp.Application.start/2. Order it from most-fundamental to most-dependent:
children = [
MyApp.Repo,
redis_app(),
MyAppWeb.Telemetry,
pubsub_spec(MyApp.PubSub),
pubsub_spec(Notifications.PubSub),
GraphApi.Supervisor,
{Task.Supervisor, name: Communications.TaskSupervisor},
{PlugAttack.Storage.Ets, name: MyAppWeb.PlugRateLimit.Storage, clean_period: 60_000},
MyAppWeb.Endpoint
]
- One
Phoenix.PubSubper domain, not one shared bus.MyApp.PubSubfor the application itself,Notifications.PubSubfor the notification subsystem,ChangeRequests.PubSubfor the change-request workflow. Each domain owns its topics. - Stateful workers (
GenServer,GenStage, ETS owners) are always supervised. No bareGenServer.start_linkoutside the supervision tree. Task.Supervisorfor fire-and-forget work that should not crash the caller (outbound email, audit writes, fan-out notifications). Name them by purpose (Communications.TaskSupervisor).- External-API supervisors (
GraphApi.Supervisor) own their own child specs — token caches, request poolers, retry workers. Don't scatter those into the top-level tree.
Don't cross the streams (again)
The Bliss rule that providers don't call providers applies inside the supervision tree too. A GenServer that wraps an external API is a Provider — it must not reach into another provider's process to "borrow" a connection or piggy-back on its retry logic. If two providers need to share state, that state belongs in a Side layer module (an ETS table owned by a small dedicated GenServer, or :persistent_term for read-mostly config).
Result type and error handling
This is the single most consequential decision in an Elixir codebase. Get it wrong and every caller does it differently; get it right and every layer can compose, log, and translate results uniformly.
The problem: tagged tuples are a convention, not a contract
Elixir's built-in shape for "I might succeed or fail" is the tagged tuple — {:ok, value} / {:error, reason}. The language and with make this idiomatic, but the contract is unenforced. Across a real codebase you will see, for the same logical operation:
:ok # success, no value
{:ok} # success, no value — but as a 1-tuple
{:ok, nil} # success, value happens to be nil
{:ok, []} # success, empty list
{:ok, [item]} # success, exactly one row
{:ok, :no_data} # success, but the value is "no data"
{:ok, %Postgrex.Result{}} # success, raw library struct leaked
{:ok, data, metadata} # success, with side-channel info
{:error, :not_found} # failure, atom reason
{:error, "User missing"} # failure, string reason
{:error, %Ecto.Changeset{}} # failure, raw library struct leaked
{:error, %{code: "x", msg: "y"}}# failure, ad-hoc map
{:error, {:validation, [errs]}} # failure, nested tuple
Every caller has to handle every shape any of its providers can return. The combinatorics explode at every layer. Pattern matching becomes guesswork. with chains gain extra clauses for shapes that "shouldn't happen but did". This is the cost of an unenforced contract.
The Bliss answer is library-enforced discipline on tagged tuples: use the :result package's constructors (Result.ok/1, Result.error/1) and combinators (Result.map/2, Result.and_then/2, Result.map_error/2, …) everywhere, and never write raw {:ok, _} / {:error, _} literals in your own code. The tuples stay — every Hex library produces them — but in your code they only ever come out of one of the library's named constructors, which gives you the same de-facto contract as a struct would, without paying the interop translation tax.
The Bliss default: the result package
What you get:
Result.ok(42) # => {:ok, 42}
Result.error(:not_found) # => {:error, :not_found}
Result.ok?({:ok, 42}) # => true
Result.error?({:error, :x}) # => true
Result.map({:ok, 5}, & &1 * 2) # => {:ok, 10}
Result.map_error({:error, :x}, & "err: #{&1}") # => {:error, "err: x"}
Result.and_then({:ok, 5}, fn n -> {:ok, n + 1} end) # => {:ok, 6}
Result.and_then({:error, :x}, fn n -> {:ok, n + 1} end) # => {:error, :x} (short-circuit)
Result.with_default({:ok, 5}, 0) # => 5
Result.with_default({:error, :x}, 0) # => 0
Result.catch_error({:error, :timeout}, :timeout, fn _ -> {:ok, :retried} end)
# => {:ok, :retried}
@spec my_fun(...) :: Result.t(error_type, value_type)
Plus Result.fold/1, Result.from/2, Result.perform/2 (side-effect tap), Result.resolve/1 (flatten), Result.retry/4, and combinator algebra (Result.product/1, Result.sum/1, Result.r_and/2, Result.r_or/2). Submodules Result.Ok.of/1 and Result.Error.of/1 exist as algebraic-data-type-style constructors for the FP-style reader and for passing as &Result.Ok.of/1 function references — they delegate to the flat Result.ok/1 / Result.error/1 and produce the same tuples.
Why a library and not just raw tuples
Two reasons:
- One named place for every operation. When everyone writes
Result.ok(value)instead of{:ok, value}, code review can spot a raw tuple literal as a smell. A grep forResult.okenumerates every success path; a grep for{:ok,enumerates every success path AND every Ecto/Postgrex/HTTPoison call AND every test fixture. The naming gives you a search index for free. - Combinators eliminate hand-written
caseladders. A pipeline of three transformations on a result becomesResult.map(...) |> Result.and_then(...) |> Result.map_error(...)— three lines, each named for what it does. The same logic spelled out withcaseis fifteen lines with three places where the wrong tuple shape can slip in.
What goes inside {:ok, _} — the shape rules
The success payload is rule-bound by operation class. The library can't enforce this — we do, by convention:
| Operation class | Shape inside {:ok, _} |
Empty case |
|---|---|---|
get_* returning one entity |
The mapped entity (a struct or map) | {:error, :<entity>_not_found} — not {:ok, nil} |
get_* returning many entities (unpaged) |
A list of mapped entities | {:ok, []} — empty is success |
search_* (paged) |
A map: %{items: [...], count: n, pages: p, page_size: s} |
{:ok, %{items: [], count: 0, pages: 1, page_size: N}} |
create_* |
The new entity, or %{<entity>_id: id} when only the id matters |
n/a |
update_* / delete_* / set_*_as_* |
nil — use Result.ok(nil) or the bare :ok atom if your project uses that |
n/a |
ensure_* |
The (existing or newly created) entity | n/a |
| Pure side-effect (notify, broadcast, clear) | nil |
n/a |
Predicate-style with a Result.t() return |
boolean() |
n/a — Result.ok(true) / Result.ok(false) |
Three rules fall out:
{:ok, nil}means "the operation produced no value worth returning" — typical for update/delete/notify. Pick one convention per project for "no-payload success": either alwaysResult.ok(nil)(tuple form) or always bare:ok(atom form). Don't mix.- "Entity not found on a
get_one" is an error, not{:ok, nil}. A missing entity is a domain outcome the caller must handle explicitly; folding it into a nullable success forces every caller to add anis_nil(data)check. - Empty collections on a
get_many/search_*are success.{:ok, []}is a perfectly good answer to "give me all permissions" — zero is a real count.
For pagination, prefer putting metadata inside data as a map (%{items: [...], count, pages, page_size}) over the three-tuple {:ok, data, metadata} shape. The three-tuple is sometimes seen in the wild (and the legacy Simplificator3000.Result.wrap/1 understood it), but it breaks every standard combinator — Result.map/2, Result.and_then/2, and the rest of :result's API only deal with two-tuples. A map inside data composes cleanly.
What goes inside {:error, _} — the reason
A call-site-specific atom in the common case. Three rules:
- Atom is the default shape.
Result.error(:not_found),Result.error(:ga_already_used). Never a string ({:error, "User missing"}— can't be pattern-matched safely). Never a raw library struct ({:error, %Ecto.Changeset{}}— leaks DB types past the boundary). - Named in the caller's vocabulary, not the underlying mechanism.
:ga_already_usedis what the caller cares about;:unique_violationis a Postgres detail.:search_locations_errorover:db_error. - When you need structured context, use a
{reason_atom, metadata_map}two-tuple inside the error tuple — and only when the caller actually destructures it. Don't add it speculatively.
Result.error(:not_found)
Result.error({:not_found, %{user_id: id}})
Result.error({:validation_failed, %{errors: changeset_errors}})
The shape {:error, atom} is the default; {:error, {atom, map}} is the escape hatch when one caller needs to act on the metadata. If you find yourself reaching for {:error, {atom, map}} everywhere, you're probably putting context where it belongs in a log line instead.
The interop boundary rule
Third-party libraries (Ecto, Postgrex, HTTPoison, Req, Swoosh, Redix, OpenIDConnect, every gen_*/ex_* package on Hex) return tagged tuples plus their own structs as error payloads. We cannot make Hex change; we contain the inconsistency at one layer:
- Providers (
Database.DbContext.*,MyApp.<Subject>.GraphApi,MyApp.Communications.Mailer,MyApp.Users.Redis) return raw tagged tuples — they wrap libraries that speak that way. Their@specsays{:ok, [%Model{}]} | {:error, term()}and that's the contract. Inside a provider, raw{:ok, _}/{:error, _}literals are fine. - Contexts translate provider results to the Bliss shape using
Result.ok/1/Result.error/1and the:resultcombinators. The translation step is where you turn a%Postgrex.Error{}into a domain atom, a%Ecto.Changeset{}into{:validation_failed, %{errors: ...}}, a%HTTPoison.Error{}into:<service>_unavailable. - Controllers see only the result of a context call —
{:ok, value}or{:error, atom}. They never pattern-match on a%Postgrex.Error{}or any other library struct, because those never escape the context.
%Postgrex.Error{}, %Ecto.Changeset{}, %HTTPoison.Error{} never escape the context. The fallback controller has one job: turn {:error, :not_found} into a 404, {:error, :validation_failed} into a 422, and so on — atom by atom.
Composing results — Result.and_then/2 over case
The Elixir with macro works fine with :result tuples (they're just tagged tuples). Two equivalent ways to chain:
# Option A: `with` — short and familiar
with {:ok, user} <- Users.get_user(ctx, user_id),
{:ok, perms} <- Permissions.get_permissions(ctx, user.user_id) do
Result.ok(%{user: user, permissions: perms})
end
# Option B: pipeline with combinators — short-circuits on first error
Users.get_user(ctx, user_id)
|> Result.and_then(fn user ->
Permissions.get_permissions(ctx, user.user_id)
|> Result.map(fn perms -> %{user: user, permissions: perms} end)
end)
Pick by readability. with is more familiar to Elixir-first readers and handles multi-result destructuring nicely. The pipeline form is shorter for linear chains and stays in pipe-style — useful when the rest of the context function is already a pipeline.
Other useful combinators:
result |> Result.map(&transform/1) # pure transform on success
result |> Result.map_error(&translate/1) # rewrite the error reason
result |> Result.and_then(&fallible_step/1) # railway: step that itself returns a Result
result |> Result.with_default(default_value) # extract or fall back
result |> Result.perform(&log_it/1) # side effect, value passes through unchanged
result |> Result.catch_error(:timeout, &retry/1) # selective recovery from one reason
The point of preferring combinators over hand-written case ladders is the same as preferring Enum.map/2 over a recursive def loop: the named operation tells the reader what's happening; the manual version has the same shape but more places to get it wrong.
Five rules, in summary
- No
raiseacross a context boundary. Inside a context you mayraisefor "this should never happen" assertion failures (catastrophic invariant breaks), but the public function returns a result tuple. Controllers must nevertry/rescuearound a context call. - Use
:resultconstructors and combinators, never raw tuple literals in new code.Result.ok(value)over{:ok, value};Result.map/2andResult.and_then/2over hand-writtencase. Raw tuples are allowed inside providers (where you're wrapping a library) and at pattern-match sites (where you're destructuring) — but never as the output literal you write. - The success payload follows the operation-class shape rules above.
:no_data/:nothing/:emptysentinel atoms inside{:ok, _}are forbidden; useResult.ok(nil)for no-payload operations andResult.error(:<thing>_not_found)for missing entities. - Error reasons are call-site-specific atoms. Strings, library structs, and ad-hoc nested tuples are out. Use
{atom, metadata_map}only when a caller actually destructures the metadata. - The interop boundary is the context. Providers may return raw tagged tuples from third-party libraries; contexts translate to
:resultshapes; controllers see onlyResult.t()tuples.%Postgrex.Error{},%Ecto.Changeset{},%HTTPoison.Error{}never escape the context.
Postgres errors get the same treatment as every other library — match on the pg_code (your domain-specific 5-digit codes from error.raise_NNNNN) at the context layer and translate to a domain atom:
Note on Simplificator3000.Result
Earlier KeenMate projects (including the DHL Location Factory reference) use Simplificator3000.Result — a struct-based %Ok{} / %Error{} design that predates this guidance. That module is deprecated as of simplificator_3000 v1.0.0; new code uses :result. Existing projects can migrate at their own pace (the structs and the deprecated functions keep working), but no new projects should adopt Simplificator3000.Result. The architectural rules above were updated to match :result's tagged-tuple model — the underlying discipline (operation-class shapes, atom-only reasons, interop boundary at the context) is the same; only the syntactic form differs.
What this section covers
- Naming conventions — modules and namespaces (
MyAppvsMyAppWeb), context vs subject filenames, structs and types, function names and the verb registry, parameter conventions (ctxfirst, the:eg_value_not_providedsentinel, optional arity), Mapper / Helper module shape, struct +Accessiblerules, PubSub topic format, anti-patterns, and worked examples. - This page also covers the four-tier configuration layering (
config.exs→<env>.exs→.local.exs→<env>.local.exs), the split between compile-time config andruntime.exs, theFILL_ME_UPsentinel, and theSystem.fetch_env!/|| raise/System.get_env(default)shapes for env-var reads. - And the unified result type — the
resultpackage as the Bliss default, the operation-class shape rules for what goes inside{:ok, _}, atom-only error reasons (with{atom, metadata_map}as the escape hatch), the interop boundary at the context layer, and theResult.ok/1/Result.error/1/Result.map/2/Result.and_then/2/Result.map_error/2family as the discipline that replaces raw tuple literals in new code.
See also
- General naming conventions — the shared verb registry and the singular/plural rule that connects this layer to every other layer.
- General coding structure — the three-layer model and Side layer rules.
- PostgreSQL coding guidelines — the sister page; the SQL function names you see in
Database.DbContextare governed by it. - JavaScript / web-component guidelines — the front-end mirror of these rules.