| name | elixir-planning |
| description | Elixir architectural planning โ the decisions made BEFORE writing code. Covers project layout, domain boundaries (contexts, aggregates), data ownership, multi-tenancy, process architecture and supervision, inter-context communication (direct call, PubSub, Registry, GenStage, Broadway, Oban, event sourcing), configuration, resilience (bulkheads, circuit breakers, retries, timeouts), architectural styles (hexagonal, modular monolith, CQRS, event-driven), growing from small to large, distributed systems, and anti-patterns. ALWAYS use when designing, architecting, structuring, or planning an Elixir application. ALWAYS use when choosing between umbrella/single-app, contexts, process placement, or supervision strategy. ALWAYS use when starting a new Elixir project or major refactor. For writing the code itself, also load elixir-implementing.
|
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)? โ Load long-running-projects.md first. 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 with M\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 Archdo Blackbox.refactor_distance/1 to 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-implementing alongside 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
Processes & supervision
State
Communication
External boundaries
Configuration
Resilience
Test strategy
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
@callback behaviour. 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 โ not Controllers, 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
Repo across 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 / interface BEFORE 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's Blackbox analyzer. Maximize building-block coverage: extract a building-block out of every module that mixes pure logic with side effects (see building-blocks.md ยง5 for the extract-vs-refactor-in-place decision tree). Aim for โฅ 60% of lib/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 (typically MyApp.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 in building-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-implementing for 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 bare with IS 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 in elixir-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 a ctx struct (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
Authorization scheme, 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's scheme: option handles non-standard headers), Ueberauth (OAuth), bcrypt_elixir / argon2_elixir (password hashing โ lower rounds in config/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.metadata is per-process (documented Logger behaviour). A request's request_id / trace_id / tenant_id does NOT travel with Task.async, Task.Supervisor.start_child, Oban.Job, or :erpc calls. Pick the propagation strategy at planning time: capture-and-restore for single-hop async, an explicit TraceContext struct 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/1 against a closed compile-time allowlist. Ecto.Enum is 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 use String.to_atom extensively โ but every call site is on developer/config-time data (compiled module names, .beam filenames, 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_callbacks sparingly โ each one is a function_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 terminal Enum.* at the end.
- Threading-builder โ subject type is FIXED (always
Multi.t(), always Plug.Conn.t(), always Phoenix.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
with chain 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 the state-bearing type they require; callers can't accidentally feed an unverified value into a verified-only function. See architecture-patterns.md ยง4.12.8 for the pattern.
4.7.6 Decision summary โ given a problem, which composition primitive
| Situation | Mechanism |
|---|
| Sequential transformation of one value | Pipeline (|>) |
| Sequential ok/error operations, one fails โ stop | Railway / with-chain (ยง5.10.1) |
| Independent validations, accumulate failures | Accumulating reduce (ยง5.10.6) |
| One-step transform on a Result | with {:ok, v} <- f(), do: {:ok, fn.(v)} (ยง5.10.5) |
| Distinguish which step failed | Tagged-tuple with (ยง5.10.3) |
| Build a value once, reuse across functions | Smart constructor (@opaque + new/1) (ยง4.12 in architecture-patterns) |
| Communicate "this should happen" without doing it | Return events list {:ok, value, [events]} (ยง5.10.7) |
| Read clock / random / config in pure code | Capability argument (ยง5.10.8) |
| Update a deeply nested field | update_in / put_in with Access path (ยง5.10.9) |
| Dispatch on data type | Protocol |
| Dispatch on module identity, swap per env | Behaviour + Application config |
| Coordinate stateful / concurrent collaborators | Supervised process (GenServer / :gen_statem) |
Section references in this row are to elixir-implementing/SKILL.md ยง5.10 โ that's where the at-keyboard templates live.
4.7.7 Composability density โ a metric, not a rule
For each building-block module, ask: how many distinct callers compose with it through its first-arg position? High density (โฅ 3 callers using it via pipelines) means the module is delivering on the composition promise; low density means it's a building-block in shape but not in use. Use this metric to rank which building-blocks to property-test first โ high-density blocks have the most leverage. This is one of Archdo's planned metrics (Archdo.Blackbox.composability_density/2).
4.7.8 Memoization is a building-block payoff
A pure deterministic function (axes 1 + 2 of the building-block checklist) can be memoized safely: same input โ same output โ caching the output is correct. An impure or non-deterministic function CANNOT be memoized without lying โ the cache returns a stale or wrong value. Memoization is therefore a payoff that only building-blocks unlock; any module that fails axis 1 or 2 is structurally ineligible.
Plan memoization at the design stage when:
- The building-block function is on a hot path (called every request, every event, every iteration of a loop).
- The function is genuinely expensive: regex compilation, ISO 8601 parsing, hash computation, large-data serialization.
- Inputs have low cardinality OR high reuse (the same arguments repeat often enough that caching pays off).
Two storage shapes to choose from:
| Storage | Use when | Read cost | Write cost |
|---|
ETS (:public, read_concurrency: true) | Cache changes during runtime; concurrent readers and writers | O(1) | O(1) |
:persistent_term | Read-mostly lookup table; written ONCE at boot or rarely | O(1), faster than ETS | O(N) โ copies across all processes |
:persistent_term is NEVER appropriate for hot-path writes โ every put triggers a global GC sweep proportional to the number of processes. Use it for boot-time tables (config, compiled regex, lookup maps); use ETS for everything else.
Architectural placement: the cache lives in the orchestrator layer, not the building-block. The building-block stays pure; the orchestrator wraps it with a cache_get call:
# Building-block (pure):
defmodule MyApp.Tokens do
@spec sign(payload :: map(), secret :: String.t()) :: String.t()
def sign(payload, secret), do: # ... HMAC + Base64
end
# Orchestrator with ETS-backed cache:
defmodule MyApp.Tokens.Cache do
def signed(payload, secret) do
key = {payload, secret}
case :ets.lookup(:token_cache, key) do
[{^key, signed}] -> signed
[] ->
signed = MyApp.Tokens.sign(payload, secret)
:ets.insert(:token_cache, {key, signed})
signed
end
end
end
The building-block is property-testable without the cache; the cache is integration-testable with the building-block as a real dependency. Both layers are independently swappable.
See also: elixir-implementing/SKILL.md ยง9.2 (ETS templates), ยง9.2.2 (:persistent_term hot-path config). The Archdo rule 5.75 MemoizeOpportunity (planned in M-fp-C4) flags building-block functions with expensive calls but no cache in scope.
5. Project Layout
First decision in any new Elixir project: how to lay it out.
Depth: architecture-patterns.md ยง3 โ modular monolith deep dive, ยง5 โ layered architecture.
5.1 Single Mix application โ the default
One lib/ tree, organized by domain boundaries. This works for Phoenix web apps, Nerves firmware, CLI tools, and pure OTP services alike.
my_app/
โโโ lib/
โ โโโ my_app/ # Domain layer
โ โ โโโ application.ex # Supervision tree
โ โ โโโ repo.ex # Ecto Repo
โ โ โโโ accounts.ex # Context โ public API
โ โ โโโ accounts/
โ โ โ โโโ user.ex # Schema (internal โ @moduledoc false)
โ โ โ โโโ token.ex
โ โ โโโ catalog.ex
โ โ โโโ catalog/
โ โ โ โโโ product.ex
โ โ โ โโโ category.ex
โ โ โโโ mailer.ex # Behaviour
โ โ โโโ mailer/
โ โ โโโ swoosh.ex # Adapter
โ โโโ my_app_web/ # Interface layer (Phoenix)
โ โโโ endpoint.ex
โ โโโ router.ex
โ โโโ controllers/
โ โโโ live/
โ โโโ components/
โโโ config/
โ โโโ config.exs
โ โโโ dev.exs
โ โโโ test.exs
โ โโโ runtime.exs
โโโ test/
โโโ mix.exs
When it's sufficient: Most applications. Contexts provide domain boundaries without the overhead of multiple apps. Scale by adding contexts, not apps.
How to grow it: Add new context files (lib/my_app/orders.ex) and their internal modules (lib/my_app/orders/*.ex). No restructuring needed until you hit a team-boundary or deployment-boundary problem.
5.2 Umbrella project โ multiple apps, shared config
Multiple OTP applications in one repository sharing build artifacts, deps, and config:
my_platform/ # Root โ no code here
โโโ apps/
โ โโโ core/ # Domain logic
โ โ โโโ lib/core/
โ โ โ โโโ accounts.ex
โ โ โ โโโ billing.ex
โ โ โโโ mix.exs
โ โโโ core_web/ # Phoenix web layer
โ โ โโโ lib/core_web/
โ โ โ โโโ endpoint.ex
โ โ โ โโโ router.ex
โ โ โโโ mix.exs # deps: [{:core, in_umbrella: true}]
โ โโโ worker/ # Background processing
โ โโโ mix.exs # deps: [{:core, in_umbrella: true}]
โโโ config/config.exs # Shared config for ALL apps
โโโ mix.exs # apps_path: "apps"
โโโ mix.lock # Single lockfile
Root mix.exs:
defmodule MyPlatform.MixProject do
use Mix.Project
def project, do: [apps_path: "apps", version: "0.1.0", deps: deps()]
defp deps, do: [] # Shared deps go here; app-specific deps in child mix.exs
end
Child mix.exs:
defmodule Core.MixProject do
use Mix.Project
def project do
[
app: :core,
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
deps: [{:ecto, "~> 3.12"}] # App-specific deps
]
end
end
Key properties:
- Single
mix test runs all apps; mix test --app core runs one
- Single
config/config.exs for all apps โ shared configuration
- Sibling deps via
in_umbrella: true
- All apps share the same dependency versions (no version conflicts)
mix new my_platform --umbrella scaffolds the structure
When umbrella wins: Multiple teams working on distinct subsystems. Separate deployment targets (web vs worker vs API). Hard compile-time module boundaries.
When umbrella loses: Single team, single deploy. The split creates maintenance overhead with little benefit. Stay with single Mix app + contexts.
5.3 Poncho project โ full independence
Independent Mix projects in one repository linked by path dependencies:
my_platform/
โโโ core/ # Independent project
โ โโโ lib/core/
โ โโโ config/config.exs # Own config
โ โโโ mix.exs
โ โโโ mix.lock
โโโ web/
โ โโโ mix.exs # deps: [{:core, path: "../core"}]
โโโ worker/
โโโ mix.exs # deps: [{:core, path: "../core"}]
Differences from umbrella:
- Each app has its own config, deps, and lockfile
- Different apps can use different dep versions
- No shared build directory โ fully independent compilation
- No
mix test from root โ test each app separately
When poncho wins: Apps need different dependency versions. Apps have different release cycles. Migrating toward fully independent Hex packages. Teams need complete autonomy.
5.4 Library vs application architecture
A library is code you publish for others to consume (Hex package, path dep in another project). An application is code you deploy as a running system (Mix release).
| Dimension | Application | Library |
|---|
| Owns supervision tree | Yes | No โ added as child to someone else's tree |
| Configuration | Application.get_env in runtime.exs, Application.compile_env in config.exs | Accepts config via function arguments; optionally Application.get_env at runtime only |
| Framework dependencies | Can depend on Phoenix/Ecto/etc. | Framework-agnostic, or optional integration |
| Behaviour for swap | Config-driven | Consumer passes implementation module |
| Global state | Named GenServers, Application.ensure_started | Accepts registry/PubSub refs as options |
Library rules:
- Never use
Application.compile_env in a library โ consumers can't reconfigure after compilation. Use Application.get_env at runtime or accept config as arguments.
- Never hardcode global names โ
name: __MODULE__ means only one instance can run. Accept a name option.
- Minimize dependencies โ each dep is a liability for your consumers.
- Define extension via behaviours โ let consumers customize, don't hardcode implementations.
- Ship a default implementation โ a useful library works out of the box AND can be customized.
# BAD โ library assumes it owns the world
defmodule MyLib.Worker do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, Application.get_env(:my_lib, :config), name: __MODULE__)
# ^^^^^^^^^^^^^^ hardcoded
end
end
# GOOD โ library is a guest in someone else's application
defmodule MyLib.Worker do
use GenServer
def start_link(opts) do
{config, server_opts} = Keyword.split(opts, [:buffer_size, :flush_interval])
GenServer.start_link(__MODULE__, config, server_opts)
end
def child_spec(opts) do
%{id: opts[:id] || __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
end
5.5 Layout decision guide
| Signal | Layout |
|---|
| Single team, one deployable | Single Mix app + contexts |
| Multiple teams, hard compile-time boundaries | Umbrella |
| Apps need different dep versions | Poncho |
| Extracting a library for Hex | Poncho โ eventual Hex package |
| "Should I split?" uncertainty | Don't split. Use contexts inside a single app. |
| Feels like it's getting big | Don't split. Add contexts. |
| Code genuinely can't compile together | Umbrella or poncho (rare โ usually indicates broken contexts) |
Rule: prefer single app + contexts until a concrete, non-ergonomic reason forces a split. Splitting is the hardest architectural decision to reverse.
6. Domain Boundaries (Contexts)
A context is a module that groups related functionality behind a public API. Phoenix calls them "contexts"; the pattern is framework-agnostic and works in any Elixir application. The boundary module is the only public entry point. Internal modules are hidden behind @moduledoc false.
Depth: data-ownership-deep.md โ aggregate design, context boundaries, multi-tenancy. architecture-patterns.md ยง3.4 โ context design within a modular monolith.
6.1 Entities vs use cases
Domain code has two kinds of logic. Distinguish them when designing.
Entities โ core business rules that exist regardless of the application. Pure data structures with functions that enforce invariants:
defmodule MyApp.Orders.Order do
@moduledoc false
defstruct [:id, :items, :status, :total]
def calculate_total(%__MODULE__{items: items}) do
Enum.reduce(items, Decimal.new(0), &Decimal.add(&2, &1.subtotal))
end
def can_cancel?(%__MODULE__{status: status}), do: status in [:pending, :confirmed]
end
Use cases โ application-specific orchestration. Each public function in a context module is a use case. It coordinates entities, calls infrastructure through behaviours, returns {:ok, _} | {:error, _}:
defmodule MyApp.Orders do
@moduledoc "Order lifecycle โ placement, cancellation, fulfillment."
alias MyApp.Orders.Order
def cancel_order(order_id) do
with {:ok, order} <- fetch_order(order_id),
true <- Order.can_cancel?(order),
{:ok, order} <- mark_cancelled(order),
:ok <- notify_cancellation(order) do
{:ok, order}
else
false -> {:error, :not_cancellable}
error -> error
end
end
end
For most Elixir applications, keep entities as internal modules (@moduledoc false) and use cases as public context functions. Separate them explicitly only when entity rules are complex enough to warrant independent testing and reuse across multiple use cases.
6.2 When to create a new context
Create a new context when:
- Different business domain (Catalog vs. ShoppingCart vs. Accounts)
- Different teams will own the code
- Data has a distinct lifecycle (orders vs. products)
- Entities have different consistency requirements
- When in doubt โ prefer separate contexts. Merging is easier than splitting later.
DON'T split when:
- Entities share the same aggregate root (Order and OrderItem โ same context)
- Operations always change together in a transaction
- Splitting would require constant cross-context calls
- The contexts would be thin wrappers around a shared set of operations
Smell tests that say "split this":
- The context file is over ~800 lines of public functions
- Two clusters of functions never reference each other's data
- Different teams keep stepping on each other's changes
- One set of functions changes for reason A, another for reason B (single-responsibility violation)
6.3 Aggregates โ the consistency boundary
An aggregate is a cluster of entities that must be consistent as a unit. The aggregate root is the entity you load, validate, and save as a whole. In Ecto this maps to cast_assoc / cast_embed:
defmodule MyApp.Orders.Order do
use Ecto.Schema
schema "orders" do
field :status, Ecto.Enum, values: [:pending, :confirmed, :shipped]
has_many :items, MyApp.Orders.OrderItem, on_replace: :delete
timestamps()
end
def changeset(order, attrs) do
order
|> cast(attrs, [:status])
|> cast_assoc(:items) # Items validated + saved WITH the order
|> validate_at_least_one_item()
end
end
# Context operates on the aggregate root, never individual items
defmodule MyApp.Orders do
def add_item(order, item_attrs) do
items = order.items ++ [item_attrs]
update_order(order, %{items: items}) # Whole aggregate saved together
end
end
Aggregate rules:
- Never load or save parts of an aggregate independently โ always go through the root
- Each aggregate is a transaction boundary โ one
Repo.insert/update per aggregate
- Different aggregates communicate through the context's public API, not direct associations
- If two entities must be consistent, they belong in the same aggregate โ same context
6.4 Boundary structure template
# With Ecto (Phoenix, database-backed apps)
defmodule MyApp.Catalog do
@moduledoc "Product catalog management."
import Ecto.Query, warn: false
alias MyApp.Repo
alias MyApp.Catalog.{Product, Category}
# Queries
def list_products, do: Repo.all(Product)
def get_product!(id), do: Repo.get!(Product, id)
# Commands
def create_product(attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
end
# Without Ecto (Nerves, CLI, pure OTP services)
defmodule MyFirmware.Sensors do
@moduledoc "Sensor reading and calibration."
alias MyFirmware.Sensors.{Reader, Calibration}
defdelegate read(sensor_id), to: Reader
defdelegate calibrate(sensor_id, reference), to: Calibration
def read_calibrated(sensor_id) do
with {:ok, raw} <- Reader.read(sensor_id),
{:ok, cal} <- Calibration.get(sensor_id) do
{:ok, Calibration.apply(raw, cal)}
end
end
end
Internal modules are private to the boundary:
defmodule MyApp.Catalog.Product do
@moduledoc false # Internal โ not part of public API
use Ecto.Schema
# ...
end
Context organization inside a context:
lib/my_app/catalog/
โโโ product.ex # Schema (internal)
โโโ category.ex # Schema (internal)
โโโ product_queries.ex # Complex query builders (internal)
โโโ import_worker.ex # Background processing (internal)
โโโ price_calculator.ex # Pure business logic (internal)
All internal modules are private. Only MyApp.Catalog is the public API.
6.5 Context relationships (context mapping)
Contexts don't exist in isolation โ they relate to each other in specific ways.
| Relationship | Meaning | Elixir implementation |
|---|
| Shared kernel | Two contexts share a data structure | Shared module in a common namespace (e.g., MyApp.Shared.Money) |
| Customer-supplier | One context serves another | Supplier exposes public API, customer calls it |
| Conformist | You adapt to an external model | Anti-corruption layer translates their types to yours |
| Separate ways | Contexts are independent | No direct communication, possibly PubSub |
Boundary atom-safety discipline: every external string identifier crossing into a context โ sort key from a query string, action name from a webhook, role name from a JWT claim, channel topic suffix โ stays a string OR converts via String.to_existing_atom/1 against a closed allowlist (Ecto.Enum is the canonical pattern). Never String.to_atom/1 on request data. See ยง1 rule 24.
6.6 Anti-corruption layer (ACL)
When integrating with external or legacy systems, translate their data model to yours at the boundary. Never let foreign data structures leak into your domain.
# BAD โ external API's data model leaks into domain
def process_payment(stripe_charge) do
if stripe_charge["status"] == "succeeded" do
update_order(stripe_charge["metadata"]["order_id"], stripe_charge["amount"])
end
end
# GOOD โ anti-corruption layer translates at the boundary
defmodule MyApp.PaymentGateway.Stripe do
@behaviour MyApp.PaymentGateway
@impl true
def charge(amount, token) do
case Stripe.Charge.create(%{amount: amount, source: token}) do
{:ok, charge} -> {:ok, to_domain_result(charge)}
{:error, err} -> {:error, to_domain_error(err)}
end
end
# Translation layer โ Stripe's model โ our domain model
defp to_domain_result(charge) do
%{transaction_id: charge.id, amount: charge.amount, captured_at: DateTime.utc_now()}
end
defp to_domain_error(%{code: "card_declined"}), do: :card_declined
defp to_domain_error(%{code: "expired_card"}), do: :card_expired
defp to_domain_error(_), do: :payment_failed
end
Rule: The behaviour adapter IS the anti-corruption layer. All translation between external and domain models happens in the adapter module. Domain code never sees external data structures.