Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
77682ec
feat(trogonstack-elixir): add trogonstack-elixir plugin with GenServe…
yordis Oct 5, 2026
b24b94a
fix(trogonstack-elixir): set initial version to 0.0.1
yordis Oct 5, 2026
4791aa4
feat(trogonstack-elixir): teach where a GenServer belongs and the que…
yordis Oct 5, 2026
b0b3fe3
refactor(trogonstack-elixir): load the GenServer review checklist onl…
yordis Oct 5, 2026
b1a825e
fix(trogonstack-elixir): cover dependent siblings and restart loops d…
yordis Oct 5, 2026
01136a8
fix(trogonstack-elixir): make the GenServer instance explicit in clie…
yordis Oct 5, 2026
abe4dfc
feat(trogonstack-elixir): recommend names for the GenServer, its pure…
yordis Oct 5, 2026
6a53f5d
fix(trogonstack-elixir): keep Request and Response structs at the Gen…
yordis Oct 5, 2026
d396eaf
fix(trogonstack-elixir): keep GenServer replies to two-element result…
yordis Oct 5, 2026
92822da
fix(trogonstack-elixir): name GenServer modules after their domain to…
yordis Oct 5, 2026
bf5ef06
feat(trogonstack-elixir): show how to remove GenServer queues and han…
yordis Oct 5, 2026
b71a595
fix(trogonstack-elixir): enforce a hard queue bound and compile the c…
yordis Oct 5, 2026
375a943
feat(trogonstack-elixir): keep GenServer contracts extensible so new …
yordis Oct 5, 2026
371431e
feat(trogonstack-elixir): warn against GenServers that gatekeep datab…
yordis Oct 5, 2026
356a5c9
docs(trogonstack-elixir): lead with principles so readers can judge c…
yordis Oct 5, 2026
85f18b5
fix(trogonstack-elixir): keep examples from demonstrating the anti-pa…
yordis Oct 5, 2026
89af4d0
docs(trogonstack-elixir): add a complete GenServer and a pooled-drive…
yordis Oct 5, 2026
c746b0b
fix(trogonstack-elixir): keep shed-load and chunking examples correct…
yordis Oct 5, 2026
0aeea9f
docs(trogonstack-elixir): tie resource cleanup to process exit so cra…
yordis Oct 5, 2026
afe6003
fix(trogonstack-elixir): stop chunked imports from starting a second …
yordis Oct 5, 2026
4847ba2
fix(trogonstack-elixir): hold admission slots until the server consum…
yordis Oct 5, 2026
e8d5cfe
docs(trogonstack-elixir): compose the locked debit as an Ecto.Multi s…
yordis Oct 9, 2026
8348cef
docs(trogonstack-elixir): recommend error structs without forbidding …
yordis Oct 9, 2026
ef2dd5f
docs(trogonstack-elixir): match on the transaction result directly in…
yordis Oct 9, 2026
66d53f3
fix(trogonstack-elixir): stop restarting in-memory entities whose res…
yordis Oct 10, 2026
3f34984
docs(trogonstack-elixir): back the worked example's tested claim with…
yordis Oct 10, 2026
a084072
fix(trogonstack-elixir): stop claiming a raised exception skips termi…
yordis Oct 10, 2026
b93127e
fix(trogonstack-elixir): keep review rules from flagging the worked e…
yordis Oct 10, 2026
9fcda6c
docs(trogonstack-elixir): correct the upsert label, explain why late …
yordis Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
"description": "Skills for writing and reviewing Product Requirements Documents (PRDs) and Feature Requirements Documents (FRDs)",
"source": "./plugins/trogonstack-product-requirements",
"category": "development"
},
{
"name": "trogonstack-elixir",
"description": "Elixir skills for designing and reviewing OTP processes such as GenServers",
"source": "./plugins/trogonstack-elixir",
"category": "development"
}
]
}
3 changes: 3 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
},
"plugins/trogonstack-product-requirements": {
"component": "trogonstack-product-requirements"
},
"plugins/trogonstack-elixir": {
"component": "trogonstack-elixir"
}
},
"plugins": [
Expand Down
3 changes: 2 additions & 1 deletion .github/release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"plugins/trogonstack-otel": "0.1.2",
"plugins/trogonstack-eventmodeling": "0.1.2",
"plugins/trogonstack-eda": "0.1.2",
"plugins/trogonstack-product-requirements": "0.1.1"
"plugins/trogonstack-product-requirements": "0.1.1",
"plugins/trogonstack-elixir": "0.0.1"
}
9 changes: 9 additions & 0 deletions plugins/trogonstack-elixir/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "trogonstack-elixir",
"description": "Elixir skills for designing and reviewing OTP processes such as GenServers",
"version": "0.0.1",
"author": {
"name": "TrogonStack",
"url": "https://github.com/TrogonStack"
}
}
7 changes: 7 additions & 0 deletions plugins/trogonstack-elixir/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# trogonstack-elixir

Elixir skills for designing and reviewing OTP processes such as GenServers.

```bash
claude plugin install trogonstack-elixir@trogonstack
```
69 changes: 69 additions & 0 deletions plugins/trogonstack-elixir/skills/elixir-design-genserver/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
name: elixir-design-genserver
description: "Design or review Elixir GenServer modules. Decides whether a process is justified at all, detects mailbox queuing and serialization bottlenecks, picks call vs cast, shapes the client API, and places the server under supervision. Use when writing a new GenServer, reviewing an existing one, deciding where an actor belongs, or diagnosing a process that became a bottleneck. Do not use for: (1) designing full supervision trees across an application, (2) distributed Erlang or clustering, (3) Phoenix LiveView or Channel processes, (4) GenStage, Broadway, or Flow pipelines."
allowed-tools:
- AskUserQuestion
- Read
- Write
- Shell
---

# Design an Elixir GenServer

Put a process only where the runtime needs one, and know what it costs: every GenServer is a queue with a single worker.

## Core Principle

A process is the unit of concurrency, of serialization, and of failure, all at once. Use one to model a runtime concern (state shared across callers, an owned resource, an independent lifecycle, fault isolation). Never use one to organize code; modules and functions do that.

Wrapping an operation that needs none of those concerns in a GenServer does not add structure. It adds a queue that turns concurrent callers into sequential ones.

## Principles

Apply them in order. A design that fails an earlier principle makes the later ones moot. Each one names the references that show it in practice; read them when the situation matches, not up front.

### 1. A Process Must Own Something

A process earns its place by owning what cannot live in the caller: shared state that must change consistently, a resource with its own lifecycle, time, or a failure boundary. If it owns nothing, the caller can do the work itself, concurrently.

Read [references/process-justification.md](references/process-justification.md) when the decision is not obvious, or when reviewing code that uses GenServers as a service layer.

Read [references/resource-lifecycle-ownership.md](references/resource-lifecycle-ownership.md) when a resource must be released when some process exits, or when cleanup depends on callers calling `release` or on `terminate/2`.

Read [references/anti-pattern-database-gatekeeper.md](references/anti-pattern-database-gatekeeper.md) when a GenServer wraps database writes, or is justified as fault tolerance or as a way to avoid race conditions.

### 2. Every Process Is a Queue

One process handles one message at a time, so it caps throughput at `1 / handling_time` regardless of core count, and every caller waits behind every other. Know the arrival rate and handling time on the hot path before adding one, and remove the queue rather than tuning around it.

Read [references/mailbox-queuing.md](references/mailbox-queuing.md) when the server sits on a request path, receives casts from many producers, does I/O inside callbacks, or already shows timeouts or a growing `message_queue_len`. It links to an avoid/prefer example for each way of removing the queue.

### 3. The Process Is a Shell Around Meaning

The domain lives in a pure, deterministic core named after what it means. The GenServer only translates messages into calls on that core and owns what the core must not know about: the clock, timers, monitors, and other processes. Logic that needs no process stays testable and reusable without one.

Read [references/callback-patterns.md](references/callback-patterns.md) when writing or reviewing the module body.

Read [references/handle-info.md](references/handle-info.md) when the server sends itself messages, uses timers, monitors other processes, or receives task results.

Read [references/naming.md](references/naming.md) when naming the public module, the pure core, the process state, or the messages the GenServer exchanges.

### 4. Contracts Outlive Implementations

The client API is used by code you do not control and cannot change in lockstep. Shape what goes in and what comes out so it can grow without breaking callers.

Read [references/request-response.md](references/request-response.md) when designing or changing what a client function takes or returns, especially a reply that holds a list.

### 5. Crash Only When a Restart Helps

A restart starts from a clean state, which fixes corrupt state and transient faults and nothing else. Match the restart strategy to the lifecycle, and handle failures that persist across restarts instead of crash-looping.

Read [references/supervision-and-testing.md](references/supervision-and-testing.md) when wiring the child spec, handling cleanup, or writing tests.

## Worked Example

Read [references/example-chat-room.md](references/example-chat-room.md) to see every principle applied in one complete, tested GenServer.

## Reviewing an Existing GenServer

Read [references/review-checklist.md](references/review-checklist.md) when reviewing or auditing GenServer code. Each item is a symptom; a failing item is a question about the principle behind it, answered in the linked reference.
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Anti-Pattern: Database Gatekeeper

A GenServer stands in front of the database and every callback wraps a `Repo` call. It is usually built for fault tolerance or to serialize writes, and it delivers neither. The database already provides both, for every writer, on every node.

## Avoid

```elixir
defmodule MyApp.Accounts.AccountsServer do
use GenServer

@impl GenServer
def init(_opts), do: {:ok, nil}

@impl GenServer
def handle_call(%DebitRequest{account_id: account_id, amount: amount}, _from, state) do
account = Repo.get!(Account, account_id)

reply =
account
|> Account.debit_changeset(amount)
|> Repo.update()

{:reply, reply, state}
end
end
```

## What It Seems to Give

| Belief | Reality |
|--------|---------|
| Supervision makes the write fault tolerant | A restart does not retry the write. The in-flight call is lost and the caller exits. The transaction is the failure boundary; retries belong to the caller, with an idempotency key. |
| One process linearizes writes, so there are no races | It serializes only callers that go through it, on one node. Other nodes, the overlap during a rolling deploy, migrations, jobs, other services, and `iex` all write around it. |
| It protects the database from load | The Repo pool already bounds connections and queues callers. The GenServer adds a pool of size one in front of it. |
| `cast` makes writes fast and async | The mailbox is not durable. A crash or deploy drops every queued write without a trace. |
| It caches rows in its state | Two sources of truth that drift the moment anyone else writes. |

## Prefer

Run the write in the caller's process and let the database linearize it:

```elixir
defmodule MyApp.Accounts do
import Ecto.Query
alias Ecto.Multi

def debit(account_id, amount) do
multi =
Multi.new()
|> Multi.run(:account, fn repo, _changes -> fetch_for_update(repo, account_id) end)
|> Multi.update(:debited, fn %{account: account} -> Account.debit_changeset(account, amount) end)

case Repo.transact(multi) do
{:ok, %{debited: account}} -> {:ok, account}
{:error, _step, error, _changes} -> {:error, error}
end
end

defp fetch_for_update(repo, account_id) do
query = Account |> where([a], a.id == ^account_id) |> lock("FOR UPDATE")

case repo.one(query) do
nil -> {:error, %AccountNotFoundError{account_id: account_id}}
%Account{} = account -> {:ok, account}
end
end
end
```

The row lock serializes concurrent debits of the same account, from any process on any node, while debits of other accounts run in parallel. Each `Ecto.Multi` step runs in order inside one transaction; the first step that fails, a missing account or an invalid changeset, rolls the whole transaction back. `Repo.transact/2` (Ecto 3.13+) runs the multi and returns `{:error, step, error, changes}` on failure, which `debit/2` narrows to the [reply shapes](callback-patterns.md#reply-shapes).

Pick the database mechanism that matches the guarantee:

| Need | Use |
|------|-----|
| No duplicates | Unique index, and `Repo.insert(changeset, on_conflict: :nothing, conflict_target: :email)` for idempotent inserts, `on_conflict: {:replace, fields}` to update the existing row |
| No lost updates on a contended row | `lock("FOR UPDATE")` inside a transaction |
| No lost updates on a rarely contended row | `Ecto.Changeset.optimistic_lock/3`, retrying on `Ecto.StaleEntryError` |
| Ordering or sequence numbers | A database sequence or a version column |
| Mutual exclusion that is not a single row | `SELECT pg_advisory_xact_lock($1)` inside the transaction |
| Async writes that must survive a crash | A persisted job queue such as Oban |

## When a Process Belongs Near the Database

The process must own something the database does not:

- Time and a buffer: write-behind batching that collects rows and flushes them with `Repo.insert_all/3` on a timer or size threshold. It trades durability for throughput, so document the loss window and flush in `terminate/2` with `Process.flag(:trap_exit, true)`.
- A connection-shaped resource: a `Postgrex.Notifications` listener for `LISTEN`/`NOTIFY`.
- Authoritative in-memory state: a game room or auction that runs in memory and persists snapshots. While it runs, the process is the source of truth and the database is the backup, never both at once.

## Smells in Review

- `handle_call/3` bodies are `Repo` calls and the state is `nil` or unused
- The process is justified as "fault tolerance" or "to avoid race conditions"
- Writes go through `cast`
- The state holds rows that other code also writes
- The module has no unique indexes, locks, or transactions because the process "already serializes"

## Trade-offs

- Row locks and advisory locks hold a connection while waiting; keep transactions short and never call external services inside them.
- Optimistic locking pushes retries to the caller; it fits rows that rarely conflict.
- Throughput cost of the gatekeeper is covered in [queuing-remove-the-process.md](queuing-remove-the-process.md).
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Callback Patterns

The GenServer module is plumbing between callers and the pure core. The patterns below keep that plumbing thin, give callers one predictable way to handle results, and keep the process safe to start and restart.

## Module Layout

```elixir
defmodule MyApp.RateLimiter do
use GenServer

alias MyApp.RateLimiter.{Bucket, TakeRequest, TakeResponse}

def start_link(opts) do
{name, opts} = Keyword.pop!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end

def take(server, %TakeRequest{} = request) do
GenServer.call(server, request)
end

@impl GenServer
def init(opts) do
{:ok, Bucket.new(opts)}
end

@impl GenServer
def handle_call(%TakeRequest{key: key}, _from, %Bucket{} = bucket) do
case Bucket.take(bucket, key) do
{:ok, bucket} ->
{:reply, {:ok, %TakeResponse{remaining: Bucket.remaining(bucket, key)}}, bucket}

{:error, error} ->
{:reply, {:error, error}, bucket}
end
end
end
```

- Callers never call `GenServer.call/cast` directly. The client API owns the call; the messages are `<Operation>Request` and `<Operation>Response` structs. See [naming.md](naming.md).
- Callbacks delegate to a pure core (`Bucket.take/2`) that knows nothing about the GenServer or its messages.
- Mark every callback with `@impl GenServer`.
- Require `:name` in `start_link/1` and take the server as the first argument of every client function. Callers and tests always say which instance they talk to; a default argument hides that choice. A per-entity process derives its name from its key instead, and every client function takes that key first; see [example-chat-room.md](example-chat-room.md).
- Hold state in a struct so its shape is documented and enforced. The domain struct is the state until process-only data appears; see [naming.md](naming.md).

## Reply Shapes

Every reply, and every core function that can fail, returns one of:

| Shape | When |
|-------|------|
| `:ok` | Success with nothing to return |
| `{:ok, value}` | Success with a value, usually an `<Operation>Response` struct |
| `{:error, error}` | Failure. Any term works; an error struct (`defexception`) is recommended, see [naming.md](naming.md#errors) |

Never reply with a tuple of more than two elements such as `{:ok, value, extra}` or `{:error, reason, details}`. Put the extra data in the struct. Callers can then handle every operation with the same `case` or `with`, and adding a field never breaks a pattern match.

Core functions that cannot fail return their value directly, as `Bucket.remaining/2` returns an integer. When they return more than one value, they return a struct, never a loose tuple such as `{messages, more?}`.

Reply with a Response struct rather than a bare list or value, so the reply can grow; see [request-response.md](request-response.md).

This rule covers the replies you design. OTP callback return values such as `{:reply, reply, state}` or `{:ok, state, {:continue, term}}` keep the shapes OTP requires.

## call vs cast vs send

- `call`: the default. Back-pressure, errors reach the caller, the work is confirmed.
- `cast`: only when the caller does not care about the outcome and producers cannot outrun the server. See [mailbox-queuing.md](mailbox-queuing.md).
- `send/2` + `handle_info/2`: messages that are not part of the client API, such as timers, monitors, task results, and other libraries. See [handle-info.md](handle-info.md).

## Initialization

`init/1` blocks the caller of `start_link`; during boot that is the supervisor and every sibling after it.

- Return `{:ok, state, {:continue, :load}}` and do slow work in `handle_continue/2`.
- The supervisor starts the next sibling as soon as `init/1` returns, before `handle_continue/2` finishes. A sibling that calls the server during its own startup waits in the mailbox behind the load and can time out. When siblings need the loaded state at boot, keep loading in `init/1`, or expose an explicit readiness signal (a `ready?/0` call, a `Registry` entry, a `:persistent_term` flag) that they check.
- Crash from `handle_continue/2` only when a restart can fix the failure. A dependency that stays down (database, remote API) crashes the server repeatedly, exhausts the supervisor's restart intensity (3 restarts in 5 seconds by default), and takes the supervisor and its other children down with it. For those failures, stay up in a degraded state and retry with `Process.send_after/3` and backoff.
- Return `:ignore` when configuration disables the server.

## Timeouts and Long Work

- `GenServer.call/3` defaults to 5 seconds and exits the caller on expiry. A slow call inside a serialized process is a design question before it is a timeout question.
- Offload slow work to a task and reply later with `GenServer.reply/2`; see [queuing-offload-slow-work.md](queuing-offload-slow-work.md).
- For periodic work, timers, monitors, and the `handle_info/2` catch-all, see [handle-info.md](handle-info.md).

## Redacting State in Crash Logs

Crash reports and `:sys.get_status/1` print the full state. When it holds secrets (tokens, credentials, personal data), implement `format_status/1` (Elixir 1.17+; earlier versions use the deprecated `format_status/2`) to redact them:

```elixir
@impl GenServer
def format_status(%{state: %ClientServerState{} = state} = status) do
%{status | state: %ClientServerState{state | api_key: :redacted}}
end

def format_status(status), do: status
```

## Process Names

- Singleton: `name: __MODULE__`, only for genuinely one-per-node services. A singleton on a request path is a global queue.
- Many instances: `name: {:via, Registry, {MyApp.Registry, key}}` under a `DynamicSupervisor`.
- Never create atoms from user input to name processes. Atoms are not garbage collected.
Loading
Loading