Imported from BadBeta/elixir-phase-skills (
elixir-planning/SKILL.md). Install upstream withnpx skills add BadBeta/elixir-phase-skills --skill elixir-planning. Copyright stays with the author.
Elixir — Planning Skill
Architectural decisions made before writing implementation code. This skill sits upstream of elixir-implementing: planning answers what to build and how to structure it; implementing answers how to type it idiomatically.
About this skill family
- elixir-planning (this) — upfront architecture: project layout, contexts, data ownership, process shape, supervision, integration mechanism, resilience, architectural style.
- elixir-implementing — the moment of writing: decision tables for constructs, idiomatic templates, anti-patterns Claude produces, TDD, testing essentials, OTP callback patterns.
- elixir-reviewing — code inspection: review PRs, debug bugs, profile performance.
The three skills follow the skill-authoring three-modes framework. This skill leans heavily on decision tables (the "at the moment of designing" mode) and process-style rules (constraints that fire during review). Code templates appear mainly as supervision-tree shapes and context API patterns — the bulk of code templates lives in elixir-implementing.
Subskills — deep references within elixir-planning
This SKILL.md covers the decision tables and quick-reference material. For depth on any topic, load the relevant subskill:
| Subskill | Scope | Load when |
|---|---|---|
| building-blocks.md | Building-block as a planning lens — the six-axis checklist (input closure, determinism, spec, totality, side-effect freedom, errors-as-values) plus input-guard axis, module / context classification (building_block / orchestrator / interface), refactor decision tree, context-as-building-block, cross-link to Archdo Blackbox enforcement (CE-54/55/56/57) |
Designing a new module / context; deciding what's pure vs orchestrator; making more code property-test-friendly |
| architecture-patterns.md | Hexagonal, layered, modular monolith, event-driven, CQRS, microservices — deep walkthroughs | Deciding architectural style for a project or context |
| process-topology.md | Supervision tree design, error kernel, process-per-service vs per-entity, instructions pattern, callback module pattern | Designing the supervision tree; placing processes |
| otp-design.md | OTP construct choice — GenServer vs Task vs Agent vs :gen_statem vs ETS vs :persistent_term vs GenStage/Broadway vs Oban |
Picking the OTP primitive for a use case |
| integration-patterns.md | Six inter-context mechanisms in depth, capacity planning, escalation path, sagas, process managers | Designing how contexts / services communicate |
| data-ownership-deep.md | Aggregates, multi-tenancy strategies, cross-context transactions, sagas, idempotency | Designing the data model, tenant isolation, retry-safe operations |
| test-strategy.md | Test pyramid, mock boundaries, factory architecture, async isolation design, CI strategy, contract tests | Planning test infrastructure at project start; fixing slow/flaky suites |
| networking-design.md | TCP/UDP server architecture, active vs passive mode, protocol framing, connection supervision, TLS placement | Designing a network server or protocol |
| growing-evolution.md | Stage 1→2→3 evolution, refactoring decision tree, when to split / merge / escalate mechanisms | Growing an existing app; deciding whether a refactor is needed |
| distributed-elixir.md | Multi-node design — cross-node communication (:erpc, :pg), distributed registries (Horde, :global), state distribution (owner/replicated/sharded), partition handling, libcluster topology, distribution anti-patterns |
Designing multi-node / clustered / multi-region deployments |
| long-running-projects.md | Meta-workflow for projects spanning multiple sessions and milestones: the three-document model (PLAN.md / continue.md / commit messages), milestone-boundary checklist (incl. Ecto-specific invariants), SSOT invariant verification (mix commands + greps), pending-items pruning, cross-session handoff quality, hibernation preparation. |
Starting/resuming a long-running project; writing continue.md; milestone commit discipline |
Cross-references: subskills link to each other and to the other main skills' subskills (when they exist) via relative paths.
How to use this skill
- Resuming a long-running project (M-prefix commits,
continue.md, multi-session work)? — Loadlong-running-projects.mdfirst. It's the meta-workflow for everything below: which document records what (PLAN.md vs continue.md vs commit messages), milestone-boundary checklist (incl. Ecto-specific invariants), SSOT invariant verification, cross-session handoff quality. If the project has 5+ commits withM\d+:prefixes, this subskill is the primary reference; the rest of this SKILL.md becomes lookup material for specific design questions within a milestone. - Starting a new project? — Read §0 (Plan-Completeness Gate) first, then §1 (Rules), §2 (Planning Workflow), §5 (Project Layout). Walk through the decisions in sequence. Before each module, run the building-block classification (§1 rule 11b; depth in
building-blocks.md). - Adding a new feature to an existing project? — §0 (Plan-Completeness Gate), §2 (Planning Workflow), §3 (Master Decision Table). Check §6 (do I need a new context?) and §8 (do I need a new process?). For each new module: classify as building-block / orchestrator / interface (
building-blocks.md§3 checklist). - Refactoring? — §13 (Growing Architecture) to identify where you are, §14 (Anti-patterns) to find what to fix, §6 and §9 for context/integration rework. For making more code property-test-friendly: load
building-blocks.md§5 (extract vs refactor-in-place) and run ArchdoBlackbox.refactor_distance/1to rank candidates. - Choosing how two parts of the system should talk? — §9 (Inter-Context Communication) — the 6 mechanisms and the decision guide.
- Designing for failure? — §11 (Resilience) — where circuit breakers, retries, and degradation live.
- About to write code? — Verify the plan passes §0 before handing off. Load
elixir-implementingalongside this skill.
Scope — what this skill does NOT cover:
- Moment-of-writing construct choice (
if/case/with/multi-clause, Enum vs Stream, pipeline shape) →elixir-implementing. - Individual library selection (which HTTP client, which JSON lib) — this skill gives the boundary shape (behaviour); the library is an implementation detail.
- Runtime debugging, performance profiling, and post-hoc review →
elixir-reviewing. - Long-form architecture decision records (ADRs) — the skill gives the decisions but not the prose document format.
0. The Plan-Completeness Gate ⛔
Stop. A plan is not a plan until it is complete. A plan with stubs, TODOs, "we'll figure this out later", or named-but-unresolved questions is a partial plan — and a partial plan is how production bugs get designed in rather than typed in.
0.1 The gate — a plan is complete when…
Every item below has a concrete answer before any implementation begins. If any answer is "TBD", "we'll pick later", "something like X", or "depends", the plan is incomplete. Go resolve it before coding.
Layout & boundaries
- Project layout chosen: single app, umbrella (name each sub-app), or poncho (§5).
- Every context named with its responsibility in one sentence (§6).
- Data ownership: every table/entity has exactly one owning context (§7.1).
- Context relationships mapped: caller → callee, and the public API function names that bridge them (§6.5).
- For every struct that crosses a context boundary: opacity decision is made — "opaque handle" (
@opaque+ accessor functions, like MapSet/Ecto.Query) or "public data structure" (fields documented as API, like Plug.Conn/Date). See §6.7 + architecture-patterns.md §4.12. "We'll decide when callers reach in" is not an answer — once they reach in, the field shape IS public API and renaming is a breaking change.
Processes & supervision
- The full supervision tree drawn (ASCII, Mermaid, or list — doesn't matter which, but it must be drawn). Every supervised process named with its restart strategy (§8.3).
- Start order justified by dependency order (§8.3).
- Every GenServer/Task/Agent/
:gen_statem/Broadway/Oban worker has a concrete child spec in mind — not "some GenServer" (§8.4). - Registry / DynamicSupervisor decisions named if the app has dynamic processes (§8.6).
State
- For every piece of state: where it lives (process, ETS, DB,
:persistent_term, cache) and why (§8.5). - What survives a crash vs what's volatile — explicit per piece of state (§8.2).
- Multi-tenancy strategy chosen if applicable — row-level / schema-per-tenant / DB-per-tenant (§7.4).
Communication
- Every inter-context call path chosen from §9.8 decision guide: direct call / PubSub / Registry / GenStage / Oban / event-sourced.
- Every payload (PubSub topic, Oban job args, GenStage event) has a concrete shape — field names and types.
- Every cross-context transaction either fits in one aggregate or has a named saga/idempotency strategy (§7.2, §7.3).
External boundaries
- Every external dependency behind a
@callbackbehaviour with the exact callback names and signatures listed (§4.4). No "we'll introduce the behaviour later." - Config keys named for each adapter swap (test/stub vs prod) — including the exact
config :my_app, MyBoundary, ...line. - Every external call's failure mode classified: retry / circuit break / degrade / propagate (§11.2–11.4).
- Every external payload format is named (JSON / Protobuf / Avro / line-delimited / fixed binary). ETF (
:erlang.binary_to_term) is reserved for trusted intra-cluster traffic AND only routed throughPlug.Crypto.non_executable_binary_to_term/2(or equivalent — never bare:erlang.binary_to_term/1). No boundary usesCode.eval_string/eval_quoted/compile_stringon runtime data; runtime dispatch from external input goes through a compile-time@commands-style registry (seeelixir-implementing§5.10–5.11). - Logger.metadata propagation strategy named per async boundary. Every Phoenix endpoint sets
Plug.RequestId(or equivalent). Every Phoenix Channeljoin/3callsLogger.metadata/1. Every Oban worker'sperform/1hydrates metadata from the job'sargs. EveryTask.async/Task.Supervisor.start_childsite captures parent metadata before the closure. Cross-node calls pass an explicitTraceContextstruct. See §11.7 for the design table. - Every struct that carries a secret declares its
Inspectshape at the samedefstructblock. No secret-bearing struct ships without@derive {Inspect, only: [...]}ordefimpl Inspect. Same forJason.Encoderif the struct will ever serialize. Reference:Plug.Conn'sdefimpl Inspect(lib/plug/conn.ex) replaces:secret_key_basewith:.... Seeelixir-implementing§5.13 for the templates. - Every response boundary (controller, channel, LiveView, GraphQL resolver, JSON view) drains stacktraces to
Logger/:telemetry.execute, never returns them. Domain errors are mapped through one error-translator module to bounded response shapes. Reference:Phoenix.Endpoint.RenderErrors.__catch__/5routes__STACKTRACE__toinstrument_render_and_send, never the body. Seeelixir-implementing§5.14.
Configuration
- Every config value classified as compile-time vs runtime (§3.10, §10.2).
- runtime.exs validation strategy chosen: which env vars are required, what the error message says (§10.4, §13.X runtime.exs discipline).
- Config-accessor module named (§10.5 — see Config-module shape).
Resilience
- Every timeout chain cascaded: endpoint → GenServer.call → HTTP client — with numbers (§11.5).
- Every retryable operation either idempotent by design or has a dedup/idempotency key strategy (§7.3).
Test strategy
- For each context: which functions are unit-tested, which are property-tested, which need integration tests against the boundary (§test-strategy).
- Mock boundaries chosen (Mox for behaviours, sandbox for Repo) — not "we'll mock something."
0.2 Stubs, TODOs, and "later" — banned
The following phrases signal an incomplete plan. Hunt them, kill them, replace them with concrete decisions before handing off to implementation:
| Phrase in a plan | Replace with |
|---|---|
| "TODO: pick a library" | The library name + version, installed via mix.exs |
| "We'll introduce a behaviour when needed" | The @callback signatures, now |
| "Some kind of PubSub event" | Topic name + payload field/type list |
| "Stubbed for milestone N, wired later" | Delete the milestone split or finish the wiring now |
| "Config TBD" | The exact config :app, Key, value line |
| "Retry strategy to be determined" | A named strategy (bounded retries with backoff X, circuit breaker, or propagate) |
| "Validate the env var later" | The validator function and its error message |
| "Module-level TODO for error handling" | The error-return shape and who catches it |
A plan that contains any of these phrases is not done. Do not begin implementation. Do not commit the plan. Resolve the decision now — planning is 10× cheaper than fixing a wrong decision discovered mid-implementation.
0.3 Why plans fail mid-implementation
The failure mode this gate prevents:
- A plan is written with "good-enough" placeholders.
- Implementation begins on well-specified parts.
- When implementation reaches a placeholder, the decision is now made under pressure (velocity, context switches, sunk-cost fallacy for what's already built).
- The late decision either constrains an already-built piece (causing rework) or is deferred again (causing architectural drift).
- The drifted architecture ships. Discovered in review or production.
Every TODO in a plan is a decision moved from cheap (planning) to expensive (under-pressure-mid-build).
0.4 Milestone splits are not placeholders
Splitting implementation into milestones (M1, M2, M3…) is fine and often correct. But the decisions for all milestones must be made at planning time. M3's supervision shape is decided at planning time even if M3's code is written last. "We'll design M3 when we get there" means you haven't planned.
Acceptable milestone split: "M1 wires the scaffold with the M3-decided behaviour trait; M2 adds the adapter; M3 adds the scheduler." All three shapes are known.
Unacceptable: "M1 gets us running; M2/M3 TBD." That's one milestone and a shrug.
0.5 The completion check before handoff
Before declaring planning done and loading elixir-implementing:
- Read the plan end-to-end.
- Grep the plan text for:
TODO,TBD,later,figure out,decide when,something like,probably,maybe. Any hit → incomplete. - For each ✔ in §0.1, confirm it's answered with a concrete noun (a name, a type, a number, a function signature), not an adjective.
- If the plan passes, commit it (or stamp it done) and proceed to implementation.
- If it fails, return to the failed items and resolve them. Planning iteration is cheap.
1. Rules for Architecting Elixir Applications (LLM)
- ALWAYS start with the simplest architecture: single Mix application + contexts + one-level supervision. Add complexity (umbrella, PubSub, GenStage, Oban, event sourcing) only when the specific problem it solves is present.
- ALWAYS think in three modes of content — this skill itself follows them: rules (constrain design at review), decision tables (guide at moment of designing), BAD/GOOD (verify). For each planning decision, check the relevant decision table in §3 or §9 first.
- ALWAYS sketch the supervision tree before writing code. The supervision tree IS the architecture. Start order = dependency order. Strategy encodes coupling. If you cannot draw it, you cannot build it.
- ALWAYS put external dependencies behind a
@callbackbehaviour. Database, HTTP, email, payment gateway, hardware — every boundary that crosses out of your app. Config picks the implementation. This gives you hexagonal architecture for free. - NEVER split a modular monolith into microservices for "loose coupling" or "fault isolation" — OTP already provides both. Only split for different languages, compliance isolation, wildly different scaling needs, or genuinely separate teams/release cycles.
- ALWAYS use single Mix application over umbrella until you need hard compile-time boundaries across teams or separate deployment targets. "Feels like it's getting big" is not a reason to split.
- ALWAYS name modules after the domain, not the framework (
Accounts,Catalog,Billing— notControllers,Models,Services). Scream the domain. - NEVER organize code into
models/,services/,helpers/directories. These are anti-patterns from other ecosystems. Elixir uses contexts (boundary modules) with internal modules marked@moduledoc false. - NEVER call
Repoacross context boundaries. Each table is owned by exactly one context. Other contexts read through the owning context's public API. - NEVER put business logic in interface modules (controllers, LiveViews, CLI handlers, GenServer callbacks). Interfaces translate input, delegate to a context, format output. Business logic lives in pure functions inside contexts.
- PREFER pure functions over processes. Use a GenServer only when you need shared mutable state, serialized access to a resource, or scheduled work. If two concepts always change together in the same flow, they belong in the same process (or no process at all).
11b. ALWAYS classify every new module as
building_block/orchestrator/interfaceBEFORE typing the moduledoc. A building-block is a module whose every public function passes the seven-axis checklist (input closure, determinism,@spec, totality, side-effect freedom, errors-as-values, constrained input domain). An orchestrator is a module that connects building-blocks to side effects (DB, clock, telemetry, PubSub, external services). An interface translates IO (controller, LiveView, worker, CLI). Mixing the three in one module produces hard-to-test code and fails Archdo'sBlackboxanalyzer. Maximize building-block coverage: extract a building-block out of every module that mixes pure logic with side effects (seebuilding-blocks.md§5 for the extract-vs-refactor-in-place decision tree). Aim for ≥ 60% oflib/my_app/modules to be building-blocks; ≥ 80% of pure-domain modules. Context-level: a context IS a building-block when every module under its namespace is a building-block; the orchestrator lives outside (typicallyMyApp.Catalog.Workflow). Cross-link: this rule is enforced by Archdo CE-54/55/56/57 (BlackboxQuadrant,UntestedBuildingBlock,EffectLeak,UnguardedBuildingBlock); the design-time discipline that makes those rules silent is inbuilding-blocks.md. - ALWAYS design for replaceability. Can you swap this component's implementation without changing business logic? If not, introduce a behaviour at the boundary.
- ALWAYS define the aggregate consistency boundary for domain operations. One aggregate per transaction. Cross-aggregate operations are sagas or eventual consistency, never multi-aggregate
Repo.transaction. - ALWAYS identify which operations can be retried (Oban workers, webhook handlers, event handlers, distributed calls) and design them to be idempotent from the start.
- ALWAYS choose a multi-tenancy strategy before starting if the app is tenant-aware. Retrofitting multi-tenancy is painful. Row-level (tenant_id) is the default; escalate to schema-per-tenant or DB-per-tenant only when isolation requirements demand it.
- NEVER place circuit breakers or retry logic in domain modules. They belong in infrastructure adapters, wrapping external calls.
- ALWAYS cascade timeouts correctly: outer > middle > inner (endpoint > GenServer.call > HTTP client). Otherwise outer timeouts fire before inner ones with meaningless errors.
- ALWAYS start with direct function calls between contexts. Escalate to PubSub when you need decoupling, GenStage when you need backpressure, Oban when events must survive restarts, event sourcing when you need audit/replay. Don't pre-select the complex solution.
- NEVER introduce distribution (multi-node clustering) until single-node is maxed out.
Task.async_stream, process pools, Broadway, read replicas — exhaust these first. Distribution brings network partitions, split-brain, and eventual consistency. - ALWAYS hand off to
elixir-implementingfor the actual code. This skill decides what to build; the implementing skill covers how to type it idiomatically. - ALWAYS pass the plan-completeness gate (§0) before handing off. A plan with TODOs, "TBD", "we'll pick later", or unresolved decisions is not a plan — it's a partial plan that will force decisions under pressure mid-implementation. Every checklist item in §0.1 must have a concrete noun answer (a name, a type, a signature, a number) before implementation begins. This rule has priority over all others: an incomplete plan poisons every downstream decision.
22b. ALWAYS pick a composition primitive at the design stage for every multi-step operation. The four mechanisms — pipeline, railway (
with-chain), protocol/behaviour, process — compose different kinds of things and must NOT be mixed in one operation. A "service" that's both a pipeline AND a process AND a behaviour-dispatch in the same function is undisciplined; pick one at planning time, layer the others around it (§4.7). For ok/error chains specifically: railway (with) is the dominant pattern — Elixir's barewithIS railway-oriented programming, with each<-as a track-switch. Plan the chain length (2–4 steps healthy, 7+ split into orchestrated phases). Split the monadic (with) and applicative (accumulating reduce) cases at planning time, not in implementation: monadic for sequential dependencies, applicative for independent validations whose errors should all surface. Cross-link: design-time vocabulary in §4.7; at-keyboard templates inelixir-implementing/SKILL.md§5.10. 22c. ALWAYS pass capabilities (clock, random, config, secrets) as arguments in modules destined to be building-blocks. Hidden reads (Application.get_env,:rand.uniform,DateTime.utc_now,:persistent_term.get,:ets.lookup) fail axis 1 of the building-block checklist (building-blocks.md§3.1). The Elixir form of the Reader-monad pattern is actxstruct (or a few explicit args) threaded through the call chain. Behaviour-based DI is the right shape when there are 2–3 implementations chosen per environment (mailer, HTTP client); plain capability-arguments for everything else. Decide which axis-7 strategy you'll use BEFORE writing the building-block — retrofitting capability arguments to deeply-nested helpers is the painful refactor that drives "this code is hard to test" complaints (§4.7.4). 22d. ALWAYS plan effects-as-data when a building-block must signal "this should happen" but isn't allowed to do it. Return events as a list ({:ok, value, [{:emit, :user_registered, %{}}]}); the orchestrator interprets and dispatches. This is the writer-monad shape adapted to Elixir's tagged-tuple convention. Use it whenever the decision and the execution live in different layers OR when multiple potential effects need a single source of truth. Skip it for trivial single-effect cases (§4.7.3,elixir-implementing/SKILL.md§5.10.7). - ALWAYS use battle-tested libraries for authentication, password hashing, session management, cryptography, JWTs, OAuth, secret comparison, and access tokens — NEVER hand-roll these. Hand-rolling a security-critical primitive is an exception that requires explicit justification AND a security review; the default answer is "no." A "custom requirement" (non-standard
Authorizationscheme, custom claims shape, unusual session storage, exotic token format) is almost always a configuration parameter on a real library, NOT a reason to write your own. If you find yourself reaching for raw primitives (Joken,:crypto, hand-written plug pipelines, hand-rolled token format) ask: which library wraps these and exposes the customization I need? Almost always the answer exists. Canonical Elixir picks:phx.gen.auth(sessions+cookies for browser apps), Guardian (JWT for JSON APIs —VerifyHeader'sscheme:option handles non-standard headers), Ueberauth (OAuth),bcrypt_elixir/argon2_elixir(password hashing — lower rounds inconfig/test.exs, never via a behaviour-and-mock),Plug.Crypto.secure_compare/2(constant-time secret compare). The cost of using a library is one dep + one config block; the cost of hand-rolling is the bug you ship and don't notice. - ALWAYS design observability in from the start, with explicit Logger.metadata propagation across every async boundary.
Logger.metadatais per-process (documented Logger behaviour). A request'srequest_id/trace_id/tenant_iddoes NOT travel withTask.async,Task.Supervisor.start_child,Oban.Job, or:erpccalls. Pick the propagation strategy at planning time: capture-and-restore for single-hop async, an explicitTraceContextstruct for multi-hop / cross-node. See §11.7 for the design table; every boundary in §0.1 implicitly requires its metadata-setter and metadata-propagation strategy named. - NEVER plan a system that converts external string identifiers to atoms. Atoms are not garbage-collected; the BEAM atom table cap is ~1M. A request → atom path is a remote DoS primitive — an attacker sending a stream of unique strings permanently consumes table space, after which the node crashes and cannot recover until restart. Strings work as map keys, tuple tags, and HTTP/JSON identifiers; the only safe path from external string to atom is
String.to_existing_atom/1against a closed compile-time allowlist.Ecto.Enumis the canonical bounded-vocabulary pattern —field :status, Ecto.Enum, values: [:foo, :bar, :baz]casts to atoms safely, raising on unknown values. Production libs (Phoenix, Plug, Guardian) DO useString.to_atomextensively — but every call site is on developer/config-time data (compiled module names,.beamfilenames, route definitions, app-config permission keys), never on request data. The discipline is: trace the source of every string-that-becomes-atom; if it can be reached by an external request, it must go through an allowlist instead.
2. The Planning Workflow
Walk through this sequence before starting any Elixir project or significant feature. Answer each question; defer to the named section for detail.
2.1 Opening questions for a new project
| Question | Defer to |
|---|---|
| What IS the domain? Name the business concepts (the contexts). | §6 Domain Boundaries |
| What are the inputs (interfaces) and outputs (side effects / external systems)? | §4 Principles (Hexagonal), §10 Config |
| Is this a library or an application? Who owns the supervision tree? | §5.3 Library vs App |
| Single Mix app, umbrella, or poncho? | §5 Project Layout |
| Does the app have tenants? Which isolation strategy? | §7.4 Multi-Tenancy |
| What state needs to survive a crash? What's volatile? | §8 Error Kernel |
| What state lives in processes vs. in the database? | §8.5 Stateful vs Stateless |
| How will contexts communicate? Any cross-context consistency needs? | §9 Inter-Context Communication |
| What external services are involved? What happens when they fail? | §11 Resilience |
| What failure modes must the system tolerate gracefully? | §11 Graceful Degradation |
2.2 Opening questions for a new feature in an existing project
| Question | Defer to |
|---|---|
| Does this feature belong in an existing context, or does it warrant a new one? | §6.2 When to create a new context |
| Which context owns the data this feature operates on? | §7.1 Data Ownership |
| Does the feature cross context boundaries? If yes, how? | §9 Inter-Context Communication |
| Does it need a new process, or pure functions in an existing context? | §8.1 Do you need a process? |
| Is there a retry / failure path? Is the operation idempotent? | §7.3 Idempotency |
| Does the feature need to degrade gracefully when a dependency is down? | §11.4 Graceful Degradation |
2.3 Opening questions when refactoring
| Question | Defer to |
|---|---|
| Which growth stage is this app at? | §13 Growing Architecture |
| Are there contexts doing more than one job (mixed responsibilities)? | §6.2 When to split |
| Are there cross-context Repo calls, or contexts reaching into each other's internals? | §7.1 Data Ownership |
| Are there GenServers doing CRUD-y stuff that could be pure functions? | §8.1 Do you need a process? §14 Anti-Patterns |
| Is the supervision tree expressing the architecture, or is it flat? | §8.3 Supervision as Architecture |
| Are there processes simulating objects (agent-per-entity)? | §14 "Simulating Objects with Processes" |
2.4 The "what's needed now vs later" test
Elixir architecture is additive. The progression is:
Phase 0 (MVP): Contexts + supervision + one behaviour (Repo)
Phase 1: + PubSub for UI updates / decoupling
Phase 2: + Oban for guaranteed async work
Phase 3: + GenStage / Broadway for backpressured pipelines
Phase 4: + Event sourcing for audit / replay (only if needed)
Phase 5: + Distributed architecture (only if single-node maxed)
Never adopt a phase before its triggering problem appears. Each phase adds complexity; unjustified complexity compounds.
3. Master "Planning Decision" Table
This is the spine of the skill. Every major architectural question maps to a row. Find your question in the left column; the right columns show the decision and the defer-to section.
3.1 Project layout
| Question | Answer | Details |
|---|---|---|
| New project, one team, one deployable | Single Mix application + contexts | §5.1 |
| New project, multiple teams with hard boundaries | Umbrella | §5.2 |
| New project, cleanly separate runtime deployables (central API + edge worker + shared payload lib) | Umbrella with one app per deployable + a shared *_wire app for cross-deploy types |
§5.2 |
| New project, apps need different dep versions | Poncho | §5.3 |
| Building a reusable library (will be a Hex dep) | Single Mix application, NO supervision tree, behaviour-based extension points | §5.3 (Library vs App) |
| "Should I split this monolith?" | Almost certainly no. Add contexts first. | §13 (Growing Architecture) |
| Need code in multiple languages (Rust NIFs, Python ML) | Stay single-deploy, use NIFs / external processes | Defer to rust-nif |
| Feels like it's getting big | Add contexts, do NOT split | §13 |
3.2 Domain boundaries (contexts)
| Question | Answer | Details |
|---|---|---|
| Does this feature need a new context? | Check §6.2 rules: different business domain? different team? different data lifecycle? | §6.2 |
| Where does this function live? | In the context that OWNS the primary data being manipulated | §7.1 |
| How big is too big for one context? | Multiple unrelated aggregates = too big | §6.3 |
| Two contexts need the same table? | One owns it; the other reads through the owner's public API | §7.1 |
| Multiple entities that must stay consistent | Same aggregate, same context, one Repo.transaction |
§6.3 (Aggregates) |
| Entities that MAY be consistent | Different aggregates → saga or eventual consistency | §7.2 (Cross-Context Transactions) |
| Cross-context transaction? | Sign of missing boundary OR need for saga pattern | §7.2 |
| Integrating with an external system / legacy | Anti-corruption layer at the adapter | §6.5 |
3.3 Data ownership and consistency
| Question | Answer | Details |
|---|---|---|
| Who owns this table? | Exactly one context — the one that writes | §7.1 |
| Can two contexts write to the same table? | No. Always one writer context | §7.1 |
| How do other contexts read the data? | Through the owning context's public API | §7.1 |
| Cross-context update in one transaction? | Merge contexts, or saga, or eventual consistency | §7.2 |
| Is idempotency needed? | Yes for: Oban workers, webhook handlers, event handlers, distributed calls, anything retryable | §7.3 |
| Multi-tenant isolation? | Row-level (default), schema-per-tenant, or DB-per-tenant | §7.4 |
3.4 Process architecture
| Question | Answer | Details |
|---|---|---|
| Do I need a process for this? | Probably not. Most code is pure functions | §8.1 |
| Need shared mutable state across callers | GenServer (one writer) | §8.4 |
| Need fast concurrent reads | ETS (one writer, many readers) | §8.4 |
| Need to serialize access to a resource | GenServer | §8.4 |
| Need state per entity (user, game, device) | DynamicSupervisor + Registry | §8.6 (Process-per-Entity) |
| State must survive crash | Database + stateless process, OR stateful + recovery strategy | §8.5 |
| Long-running background work | Supervised Task, or Oban for persistence | §8.4 |
| Scheduled / periodic work | Process.send_after in a GenServer, or Oban cron |
§8.4 |
| Multi-step process with states and transitions | gen_statem |
Defer to state-machine skill |
3.5 Supervision strategy
| Question | Answer | Details |
|---|---|---|
| What restarts when child X crashes? | :one_for_one (just X), :rest_for_one (X + later), :one_for_all (all) |
§8.3 |
| Children are independent | :one_for_one |
§8.3 |
| Child B depends on A's state | :rest_for_one (A before B) |
§8.3 |
| Registry + DynamicSupervisor pairing | :one_for_all (tightly coupled) |
§8.6 |
| Startup order | Infrastructure (Repo, PubSub) → Domain → Endpoint last | §8.3 |
3.6 Inter-context communication
| Question | Answer | Details |
|---|---|---|
| Simplest case, synchronous, need result | Direct function call via public API | §9.1 |
| Fire-and-forget notification, loss OK | Phoenix.PubSub / :pg |
§9.2 |
| Per-entity subscriptions (one order, one user) | Registry with :duplicate keys |
§9.3 |
| Producer can exceed consumer throughput | GenStage / Broadway (backpressure) | §9.4 |
| Event must survive restart | Oban (persistent queue) | §9.5 |
| Full audit trail / replay / complex workflow | Event sourcing (Commanded) | §9.6 |
| Consuming from Kafka / SQS / RabbitMQ | Broadway | §9.4 |
| Multi-step orchestration with compensation | Saga (explicit) or process manager (Commanded) | §9.7 |
3.7 Integration boundaries (external systems)
| Question | Answer | Details |
|---|---|---|
| Calling an HTTP API | Behaviour + adapter + config switch | §4 (Principle 2), §10 |
| Sending email | @callback Mailer behaviour, Swoosh adapter |
§10 |
| Payment gateway | @callback PaymentGateway behaviour, Stripe/etc. adapter |
§10, §6.5 (ACL) |
| Hardware (I2C, SPI, GPIO) | @callback adapter behaviour |
Defer to nerves, i2c, spi |
| Database | Ecto (already behaviour-based via Ecto.Adapter) |
§4 |
| Another service over HTTP / gRPC | Behaviour + adapter | §10 |
| Need to swap implementation in tests | Behaviour + Mox | elixir-implementing §4.4 |
3.8 Resilience
| Question | Answer | Details |
|---|---|---|
| External service may be slow/flaky | Circuit breaker in the adapter | §11.2 |
| Operation may fail transiently | Retry at the right layer (HTTP client, Oban, supervisor) | §11.3 |
| External service is down | Graceful degradation — return partial / cached / default | §11.4 |
| Timeouts across layers | Outer > middle > inner | §11.5 |
| Prevent one subsystem from failing another | BEAM processes as bulkheads (separate supervisors) | §11.1 |
| Prevent retries from duplicating | Idempotency | §7.3 |
3.9 Architectural style
| Question | Answer | Details |
|---|---|---|
| Default Elixir app | Contexts + supervision + behaviours (modular monolith, hexagonal, MVC all at once) | §12.1, §4 |
| Separate read path from write path | Light CQRS (same context, both kinds of functions) | §12.3 |
| Reads need very different optimization | Separated read path (query module, optional read replica) | §12.3 |
| Full audit trail + replay + multiple read projections | Event sourcing + full CQRS | §12.4 |
| Decouple contexts with async notifications | PubSub (event notification or event-carried state transfer) | §12.5 |
| Split into microservices | Last resort — only for different languages, compliance, or org boundaries | §12.2 |
3.10 Configuration
| Question | Answer | Details |
|---|---|---|
| Value fixed at build, app-owned | config/config.exs + Application.compile_env |
§10.1 |
| Env var, per-deployment | config/runtime.exs + System.fetch_env! |
§10.1 |
| Library consumer configures | Accept runtime config via options; NEVER compile_env in a library |
§10.2 |
| Test-specific overrides (Mox wiring, small pool sizes) | config/test.exs |
§10.1 |
| Feature flag, toggleable at runtime | External (FunWithFlags, Flagsmith, database row) | §10.3 |
3.11 Distribution (usually NO)
| Question | Answer | Details |
|---|---|---|
| Need more throughput | Task.async_stream, Broadway, more cores — NOT distribution |
§15.3 |
| Need high availability | Supervisor restarts + health checks + rolling deploys | §15.3 |
| Need 1M+ WebSocket connections | Single node handles it — verify before clustering | §15.3 |
| Geographic data locality | Distribution — genuinely needs it | §15.2 |
| Legitimate need to distribute | Design state ownership first, then pick communication | §15.1 |
4. Architectural Principles
Eleven principles that govern every structural decision. When in doubt, return here.
Depth: architecture-patterns.md — full walkthrough of each architectural style (hexagonal, layered, modular monolith, event-driven, CQRS) with worked examples, common mistakes, and migration paths.
4.1 The eleven principles
| # | Principle | Core idea |
|---|---|---|
| 1 | Dependencies point inward | Interface→Domain→ nothing. Infrastructure implements contracts the Domain defines. Domain must never alias/import framework modules. |
| 2 | Behaviours are ports, implementations are adapters | Every external dependency (DB, API, email, hardware) sits behind a @callback behaviour the domain owns. Config picks the impl. Ecto.Adapter + Postgres/MySQL is the canonical example. |
| 3 | Side effects live in infrastructure | The ideal. In practice, Phoenix contexts intentionally mix Repo into domain-adjacent modules (mix phx.gen.context does this). Full separation is event-sourcing territory. For non-Repo side effects (HTTP, email, I/O), apply the boundary. |
| 4 | The supervision tree IS the architecture | Start order = dependency order. Strategy encodes coupling. Not fault-tolerance plumbing — a structural expression of what-depends-on-what. |
| 5 | Error kernel design | Stable, critical-state processes near the top; volatile workers below. A worker crash must not topple critical state. Design for recovery, not prevention. |
| 6 | Pure core, impure shell | GenServers own process mechanics; pure functions own domain logic. Test domain logic without processes. For complex dispatch, use the instructions pattern (§8.7). |
| 7 | One reason to change per boundary module | If a module changes for both business rules AND DB schema reasons, it has too many responsibilities. Boundary modules are public-API facades; internals take @moduledoc false. |
| 8 | Design for replaceability | Can you swap a component's implementation without touching business logic? If not, add a behaviour at the boundary. |
| 9 | Small, focused behaviours | Prefer Chargeable + Refundable + Subscribable over one 20-callback PaymentGateway. Clients shouldn't depend on callbacks they don't use. |
| 10 | The testability test | If you can't test a business rule without a DB, web server, or external service, the architecture has a boundary problem. Pure domain logic → plain ExUnit, no Repo, no HTTP, no processes. |
| 11 | Scream the domain | Top-level module names reflect business (Accounts, Catalog, Billing) not technical (Controllers, Services, Helpers). A new dev should read the module tree and understand what the system does. |
Depth: architecture-patterns.md walks each principle with worked examples, failure modes, and migration patterns for retrofitting onto existing code.
4.2 The Actor Model — what BEAM gives you for free
| BEAM property | What it means | What it gives you |
|---|---|---|
| Isolated state | Each process has its own heap and GC | No locks, mutexes, or race conditions on shared data |
| Message passing | Processes communicate only by sending messages | All inter-process data is immutable by design (copied) |
| Shared nothing | No shared memory between processes | Scales linearly across cores; GC is per-process |
| Location transparent | send(pid, msg) identical for local and remote pids |
Distribution is a config choice, not a code change |
| Fail independently | One process crash doesn't affect others | Supervision handles recovery; system continues |
4.3 Message passing semantics
| Guarantee | Meaning | Implication |
|---|---|---|
| At-most-once | Messages can be lost if receiver crashes before processing | Application handles reliability (idempotency, retries) |
| Ordering within pair | Messages from A→B arrive in send order | A→B and C→B may interleave arbitrarily |
| No exactly-once | BEAM provides no built-in exactly-once delivery | Design for at-least-once: make operations idempotent |
call semantics |
Caller gets {:reply, _} OR an exit |
Caller always knows the outcome |
Rule: Use GenServer.call when you need to know the outcome. Use cast or send only when fire-and-forget is acceptable. Across node boundaries, ALWAYS use call — network partitions make cast unreliable.
4.4 Hexagonal architecture in Elixir
| Hexagonal concept | Elixir implementation |
|---|---|
| Port (interface) | @callback behaviour |
| Adapter (implementation) | Module implementing the behaviour |
| Domain core | Context modules with pure functions |
| Driving adapter (input) | Phoenix controllers, LiveView, CLI, API |
| Driven adapter (output) | Repo, HTTP clients, email, file I/O, hardware |
| Configuration | Application.compile_env (app) or Application.get_env (library) picks adapter |
Elixir gets hexagonal architecture for free via behaviours. You do not need a framework.
4.5 Layered architecture
The standard three layers:
┌─────────────────────────────────────┐
│ Interface (driving adapters) │ ← Phoenix, CLI, LiveView, GraphQL
├─────────────────────────────────────┤
│ Domain (contexts, pure logic) │ ← Accounts, Catalog, Orders, ...
├─────────────────────────────────────┤
│ Infrastructure (driven adapters) │ ← Repo, HTTP clients, Mailer, Cache
└─────────────────────────────────────┘
Dependencies point downward (Interface → Domain → Infrastructure).
Never upward. Never sideways (Interface → Infrastructure).
- Interface layer translates input, delegates to domain, formats output. No business logic.
- Domain layer contains contexts with entities (pure data + invariants) and use cases (orchestration). No framework references.
- Infrastructure layer implements behaviours defined by domain. Each external dependency has an adapter.
4.6 Polymorphism — Behaviours vs Protocols
Elixir has two polymorphism mechanisms. Choosing the right one shapes every boundary in your system.
Depth: architecture-patterns.md §4.7–4.11 — full decision table, protocol-on-struct strategy pattern, behaviour design guidelines, contract evolution, "behaviour spam" anti-pattern. Implementation templates (
defprotocol,defimpl,@derive,@callback,@impl,use/defoverridable, Mox): ../elixir-implementing/idioms-reference.md §Protocols + §Behaviours.
The fundamental difference:
| Mechanism | Dispatches on | Swapped by | Example |
|---|---|---|---|
| Behaviour | The module the caller invokes | Config, explicit argument | Plug, GenServer, MyApp.Storage → Redis / Mock |
| Protocol | The data type of the first argument | Adding a defimpl for a new type |
Enumerable, Jason.Encoder, String.Chars |
Decision — which to use?
| When you need to… | Use | Why |
|---|---|---|
| Swap implementation per environment (real vs test) | Behaviour | Config chooses the module; Mox generates a test double |
| Pluggable strategies / external adapters (hexagonal ports) | Behaviour | The strategy IS a module, not data |
Multiple data types share a method (encode/1, render/1) |
Protocol | Dispatch comes from the value's type |
Extend a framework (implement GenServer, Plug, Supervisor) |
Behaviour | Framework defines the contract |
| Add support for a new type to an API you don't own | Protocol | defimpl Jason.Encoder, for: MyStruct without touching Jason |
| Separate ports from adapters (hexagonal) | Behaviour | Port = behaviour, adapter = module implementing it |
Offer @derive on user structs |
Protocol | Only protocols support @derive |
| Runtime-pluggable behaviour per entity (not per env) | Protocol on struct (see below) | Each struct carries its own implementation |
| Fail loudly when no implementation matches | Protocol without fallback | Enumerable, Collectable |
| Single implementation today, "just in case" abstraction | Neither — plain module | Introduce when a real second implementation exists |
"Plain module" is the default. Introducing either mechanism has costs (cognitive, consolidation, test fixtures). Add the indirection when a real second implementation or test double exists — not speculatively.
Protocol-on-struct — the strategy/plugin pattern (AshAuthentication):
When you want runtime-pluggable behaviour per entity (not per environment), neither plain behaviour nor plain protocol fits cleanly. The pattern: each strategy is a struct; a protocol is implemented for each strategy struct; the caller dispatches on the struct value.
defprotocol MyApp.AuthStrategy do
def authenticate(strategy, credentials)
end
defmodule MyApp.Strategies.Password do
defstruct [:hash_algorithm, :min_length]
defimpl MyApp.AuthStrategy do
def authenticate(%{hash_algorithm: alg}, %{password: p}), do: ...
end
end
# Strategies are configured with their own state; dispatch on struct value
for s <- Application.fetch_env!(:my_app, :strategies) do
MyApp.AuthStrategy.authenticate(s, creds)
end
Why this beats plain behaviour: strategies can be configured with state (hash algorithm, OAuth credentials) that travels with the dispatch — no separate registry needed. When to reach for it: Ash extension systems, plugin architectures, per-tenant pluggable behaviour.
Behaviour design quick rules:
- Narrow the surface — the behaviour expresses what the domain needs, not what the library offers.
- Return domain types, not library types — translate
%Stripe.Charge{}to%Payment{}in the adapter. @optional_callbackssparingly — each one is afunction_exported?check at the call site.- Version via new modules, not by adding required callbacks to existing behaviours (breaking change).
See architecture-patterns.md §4.7–4.11 for the full treatment.
4.7 Composition — the design vocabulary
Composition is the central concern of functional design: small pieces snap together because their inputs and outputs match. Elixir gives you four composition mechanisms, each with a distinct shape — and the choice of which to use is a design-time decision, not a "we'll see what fits" decision. Pick at planning time; commit at module-creation time.
Building-blocks are the foundation; composition is the payoff. Every composition primitive in this section delivers its full value ONLY when applied to building-blocks (building-blocks.md):
- Railway /
with-chain composes functions that return{:ok, _}/{:error, _}— that's axis 6 (errors-as-values) of the building-block checklist. Result.map/ functor composes pure transforms over success values — axes 1–6 (purity, determinism, no side effects).- Applicative validation accumulates errors from independent pure validators — axes 1, 5, 6.
- Effects-as-data is the only honest way for a building-block to communicate "this should happen" without doing it (axis 5).
- Capability passing IS axis 1 (input closure) restated as a composition pattern — the values needed from outside flow in as arguments.
- Smart constructors push validation to one place and grant axis 7 (input guard) automatically to every downstream consumer.
- Subject-position discipline is what makes building-block functions snap into pipelines without contortion.
Composition built on top of impure code is a half-payoff: you can wire functions together, but you can't property-test the chain, you can't memoize, and you can't reason locally. Composition built on top of building-blocks gives you all three. The slogan: build building-blocks, then COMPOSE them.
The composition × building-block axes matrix:
| Composition primitive | Depends on building-block axes | Why |
|---|---|---|
Railway / with-chain |
6 (errors-as-values) | Steps must return tuples, never raise |
Result.map / functor |
1, 5, 6 | The mapped function must be pure and total |
| Applicative validation | 1, 5, 6 | Validators must be pure to combine cleanly |
| Effects-as-data | 5 (no side effects in the producer) | The whole pattern exists to keep the producer pure |
| Capability passing | 1 (input closure) | This IS the technique that satisfies axis 1 |
| Smart constructors | 7 (input guard, downstream) | Push axis-7 from N consumers to 1 constructor |
Pipeline (|>) |
(subject-first discipline) | Every step's first arg = data; orthogonal to purity but required for chaining |
| Stream (lazy pipeline) | 1, 5 | Laziness preserves purity ONLY if the elements + transforms are pure; an impure step inside a Stream chain runs at materialization time, not at chain-construction time — the bug is harder to trace |
| Threading-builder | (subject-first discipline; axis-orthogonal otherwise) | Multi/Conn/Socket subjects are intrinsically effectful but the SHAPE is composable; re-binding instead of piping breaks the shape |
Lens / update_in |
5 (the update must be pure) | Side-effect mid-update breaks the lens algebra |
| Reduce-as-fold | 1, 5, 6 | The reducer fn must be pure; its accumulator is the fold's state |
| Memoization | 1, 2 (input closure + determinism) | Caching impure or non-deterministic functions LIES — the cache returns a stale or wrong value |
| Encoder/decoder pair | 1, 2, 5, 6 | Round-trip property decode(encode(x)) == x requires deterministic, pure pair |
| Phantom / branded types | 7 (input guard at construction) | The validated state is encoded in the type — downstream consumers inherit axis-7 for free |
4.7.1 The six composition mechanisms
| Mechanism | Composes... | Mechanism in Elixir |
|---|---|---|
Pipeline (|>) |
Eager data transformations | Subject-first functions threaded through |> (subject TYPE may change at each step) |
| Stream | Lazy / I/O-sourced / unbounded data transformations | Stream.* chain ending in one terminal Enum.* — same shape as a pipeline but lazy |
| Threading-builder | A subject that ACCUMULATES state across steps (subject type doesn't change) | Multi.new() |> Multi.insert() |> Multi.update() |> Repo.transaction(); socket |> assign() |> stream() |> push_event(); Plug.Conn chains |
Railway / with-chain |
Sequential ok/error operations | with {:ok, _} <- f(), {:ok, _} <- g(_) — Elixir's adaptation of railway-oriented programming |
| Protocol / Behaviour | Type or module dispatch | defprotocol (data-type dispatch), @callback (module-identity dispatch) |
| Process / GenServer | Stateful or concurrent collaborators | Supervised processes; messages are the protocol |
The first four compose pure values; the last two compose modules / processes. Building-blocks (see building-blocks.md) max-out the value-composition layer; orchestrators connect that layer to the module/process layer.
Distinguishing the four value-composition shapes:
- Pipeline — subject type CHANGES at each step (
String.t()→[String.t()]→Map.t()). Each step is a transformation. - Stream — same shape as pipeline but every intermediate is lazy. Use when source is
File.stream!,Repo.stream,IO.stream,Stream.resource/3, or when collection size is unknown / unbounded. Materialize with one terminalEnum.*at the end. - Threading-builder — subject type is FIXED (always
Multi.t(), alwaysPlug.Conn.t(), alwaysPhoenix.LiveView.Socket.t()); each step ENRICHES the subject with more state. Failure mode is re-binding (x = step1(); x = step2(x)) instead of piping. - Railway — subject is wrapped in
{:ok, _}/{:error, _}; failure short-circuits. Each step is a sequential dependency.
4.7.2 Railway-Oriented Programming, adapted to Elixir's with
Wlaschin's railway model: every step is a switch in a two-track railway — success-track on top, failure-track on bottom. A function on the success-track returns either a success value (continue on top) or an error (drop to bottom, skip the rest). Elixir's with IS the railway — bare with (no else) propagates errors exactly as the railway predicts: a non-matching <- step's value becomes the whole expression's value.
Connection to building-blocks: the railway only works because each step returns ok/error tuples and never raises — that's axis 6 of the building-block checklist. Plan the railway in the building-block layer (typically MyApp.Catalog); plan the orchestrator that drives it in MyApp.Catalog.Workflow. The orchestrator is allowed to wrap impure steps (Repo, HTTP, mailer) in the same with chain, but the impure steps must themselves return ok/error — the orchestrator's job is to keep the railway shape consistent across pure and impure stops.
Plan a railway when: you have 2+ ok/error steps where each step depends on the previous step's value (extract from a request, validate, transform, persist). Each <- is one step on the railway.
Plan an accumulating reduce instead when: the steps are independent and the user wants to see ALL errors (form validation across fields, batch import, parallel HTTP fetches). with short-circuits on first error — wrong UX for these.
The split is between monadic (sequential, short-circuit) and applicative (independent, accumulate) — the same FP distinction, mapped to Elixir's two natural shapes (with vs Enum.reduce). See elixir-implementing/SKILL.md §5.10.1–§5.10.6 for the templates.
4.7.3 Effects-as-data — the writer pattern, adapted to Elixir
A building-block can't emit Logger, Repo, PubSub, or :telemetry calls (axis 5 of the building-block checklist). But the building-block KNOWS what should happen. Plan the return shape to carry the effects as data — {:ok, value, [events]} — and let the orchestrator interpret. This is the writer-monad pattern, naturally expressed in Elixir's tagged-tuple convention without any library.
When to plan for effects-as-data:
- The decision (what should happen) and the execution (do it) live in different layers — typical for any building-block + orchestrator split
- Multiple potential effects (telemetry + email + audit-log entry) need a single source of truth on which fired
- You want the building-block property-tested without effect mocks
When NOT to:
- Exactly one effect, transactional — the orchestrator's
withchain handles it inline - Effects vary so wildly per call that the events-list shape is harder to reason about than direct calls
4.7.4 Capability passing — Reader-monad shape, adapted to Elixir
Anything a building-block needs from the environment — now(), random, configuration, secrets — comes in as an argument. The orchestrator resolves the capability at call time. For multiple capabilities, bundle them in a ctx struct (%MyApp.Clock.Ctx{}) — that struct IS a Reader-monad value being threaded through the call chain.
Capability passing is non-negotiable for axis 1 (input closure) of the building-block checklist. A building-block that calls DateTime.utc_now/0 directly fails the checklist; one that takes now :: DateTime.t() as an argument and uses it passes.
Behaviour-based DI (config-pick the impl per environment) is a degenerate form of capability passing: useful for 2–3 implementations chosen at boot (mailer, HTTP client), not for 5+ runtime-varying capabilities. Use it sparingly; prefer plain capability-arguments for runtime variation.
4.7.5 Smart constructors — opacity makes axis 7 free
A struct with an @opaque type and a validating new/1 constructor is the upstream trick that simplifies axis 7 (input guard) for every downstream function. Once a %Email{} exists, you know it's valid; downstream functions taking %Email{} don't need their own validation. The constructor is the only place that validation lives — every consumer is automatically input-guarded by the type.
Plan opacity at the design stage (architecture-patterns.md §4.12) for any value that has invariants: %Email{}, %Slug{}, %MonetaryAmount{}, %PhoneNumber{}, %TenantId{}. The cost is one constructor + accessor function per struct; the payoff is propagated input-safety across every function that takes that type.
Bidirectional pairs — every encoder ships its decoder. Smart constructors are one half of a bidirectional pair. The other half is the inverse: render the value to a serialized form, then parse it back. The round-trip property parse(render(x)) == {:ok, x} is property-test gold. Stdlib examples: Date.to_iso8601/1 + Date.from_iso8601/1, URI.to_string/1 + URI.parse/1, Jason.encode/1 + Jason.decode/1. When you ship a value type with new/1, also ship to_serialized/1 (and the reverse parser) — and add a property test that asserts the round-trip. See architecture-patterns.md §4.12.3 for the Ecto.Type dump/load mechanics that follow the same pattern.
When to plan a phantom/branded type instead — if the value has multiple lifecycle states (verified vs. unverified email, sealed vs. open envelope, draft vs. published document) and downstream consumers should ONLY accept the validated state, plan two structs (%UnverifiedEmail{} returned from raw input, %Email{} returned from verify/1). Functions take
Truncated - read the full file at https://github.com/BadBeta/elixir-phase-skills/blob/e1300f8e7f205eb29c35f42c8a34c8b28cecd336/elixir-planning/SKILL.md.