Erlang OTP Behaviors
Overview
OTP (Open Telecom Platform) behaviors are reusable templates for the recurring shapes of concurrent processes. A behavior splits responsibilities: the OTP library handles the generic infrastructure (the receive loop, message matching, timeouts, system messages, supervision integration), and you fill in the callback functions with your business logic. This separation is what makes the behaviors worth using — the hard, easy-to-get-wrong parts are written once, in OTP, and tested by millions of deployments.
The four behaviors covered here:
gen_server runs a request/response loop over private state.
gen_statem runs a state machine where the current state selects how events are handled, with first-class timeouts and state-entry callbacks.
supervisor starts, monitors, and restarts a fixed or dynamic set of children according to a declared strategy.
gen_event dispatches events to a set of pluggable handlers in one manager process (supported, but see pitfalls).
Two cross-cutting habits run through all examples, and both exist for the same reason — fail fast, at the boundary, with a clear cause:
- Explicit
-spec/-type declarations so Dialyzer can prove the code's types line up before it ever runs. There is no any() where a precise type is known.
- Guards on public API functions so a bad argument crashes in the caller's process at the call site, rather than being shipped as a message that corrupts server state or fails confusingly deep inside a callback.
When to Use
Reach for an OTP behavior whenever a problem maps onto one of the shapes OTP already solves, because the behavior gives you supervision, debugging (sys/dbg), code upgrade, and consistent error reporting for free — work you would otherwise reimplement (usually inconsistently) on raw spawn.
gen_server — when a process owns mutable state that other processes read or modify through an API. The reason it fits is serialization: a gen_server handles one message at a time, so you never need locks or mutexes to protect the state, and the request/response (call) and fire-and-forget (cast) split is built in.
gen_statem — when behavior depends on an explicit mode and the legal transitions between modes matter (protocols, connection lifecycles, device drivers, session expiry). Encoding the mode as a state means illegal transitions become unrepresentable, and you get per-state timeouts and state-entry hooks without extra bookkeeping.
supervisor — whenever a process's crash should be contained and recovered rather than propagated. A supervisor turns "let it crash" into a reliability strategy: it restarts a child to a known-good initial state instead of leaving the system in a half-broken one.
- Supervision trees — to structure a whole application so a failure is isolated to the smallest subtree that can recover on its own. A flat pile of processes tends to over-restart on any single fault; a tree restarts only the affected branch.
gen_event — for simple, in-process notification fan-out to a set of trusted handlers. It is fully supported (not deprecated), but every handler runs inside the manager process, so prefer gen_statem or a dedicated pub/sub library when handlers are heavy, slow, or must fail independently.
Prerequisites
- A modern Erlang/OTP toolchain (OTP 24+ for
auto_shutdown; verify callbacks against OTP 27 stdlib). format_status/2 is deprecated on gen_server / gen_statem / gen_event — use format_status/1.
erlc compiler and dialyzer available on PATH.
- On Windows (PowerShell), use forward slashes or escaped backslashes in paths;
erlc and dialyzer accept both.
kernel/include/logger.hrl available (ships with OTP).
Procedure
Best Practices and the Reasoning Behind Them
- Put owned state behind a
gen_server so access is serialized and you inherit supervision and introspection. Hand-rolled receive loops re-solve these badly.
- Provide every callback the behavior expects, including catch-alls. The compiler warns about missing/unexported callbacks; a catch-all clause turns "unexpected message" from a crash into a logged no-op.
- Model state with a typed record (or a typed map). A
-record with field types lets Dialyzer catch a wrong field write at build time; maps are more flexible when the shape evolves. Choose per how much you value rigidity vs. flexibility.
- Use
cast for fire-and-forget, call for results. cast returns immediately and never blocks the caller; reserve call (which blocks and can time out) for when you actually need a reply or back-pressure.
- Implement
terminate/2 only when there's something to clean up (close sockets, flush buffers), and remember it runs reliably only when the process traps exits or is shut down by its supervisor.
- Type timeouts with the built-in
timeout() (non_neg_integer() | infinity) so intent is explicit and Dialyzer-checked — there is no special gen:timeout() type; timeout() is the standard one.
- Prefer
gen_statem's handle_event_function mode for branchy machines. Keeping all transitions in one handle_event/4 makes the full transition table reviewable in one place; use state_functions mode when each state's logic is large enough to deserve its own function.
- Expose a typed
child_spec/1 helper from worker modules. This is a convention, not an OTP callback: it keeps a worker's start args and shutdown policy next to the worker itself, so supervisors stay declarative.
- Match the
restart type to the child's role (permanent / transient / temporary) rather than defaulting everything to permanent.
- Use
sys:get_state/1 and tracing to inspect live processes when debugging — these work for any OTP behavior because the behavior handles system messages for you.
- Lean on
auto_shutdown for "the tree only makes sense while X lives." Marking a child significant => true and setting the supervisor's auto_shutdown => any_significant (or all_significant) lets the subtree wind itself down when that child exits for good — cleaner than a manual shutdown signal.
- Treat distribution as plaintext until you configure TLS. Enable TLS distribution and guard the cookie before sending sensitive data between nodes on shared networks.
Example 1: gen_server — Bounded Counter (counter_server)
This shows the standard gen_server skeleton hardened with types and validation. The counter is bounded: increments saturate at max, decrements floor at 0. Guards on the public API reject bad input in the caller's process; the callbacks defend again with catch-all clauses so the server cannot be crashed by an unexpected message.
-module(counter_server).
-behaviour(gen_server).
-include_lib("kernel/include/logger.hrl").
%% Public API
-export([start_link/0, start_link/1,
increment/0, increment/1,
decrement/0, decrement/1,
get_value/0, reset/0, stop/0]).
%% Convention (not an OTP callback): a typed child spec for supervisors.
-export([child_spec/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
%% A typed record lets Dialyzer reject a wrong-typed field write at build time.
%% `max' is an inclusive upper bound; `infinity' means unbounded.
-record(state, {
count = 0 :: non_neg_integer(),
max = infinity :: non_neg_integer() | infinity
}).
-type start_option() :: {max, non_neg_integer() | infinity}.
-type start_options() :: [start_option()].
-export_type([start_option/0, start_options/0]).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
start_link([]).
-spec start_link(start_options()) -> {ok, pid()} | ignore | {error, term()}.
start_link(Options) when is_list(Options) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Options, []).
%% Keeping start args + shutdown policy next to the worker keeps supervisors
%% declarative. `modules' helps the release handler during code upgrades.
-spec child_spec(start_options()) -> supervisor:child_spec().
child_spec(Options) when is_list(Options) ->
#{id => ?MODULE,
start => {?MODULE, start_link, [Options]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [?MODULE]}.
-spec increment() -> ok.
increment() ->
increment(1).
%% The guard makes a non-positive step crash the *caller* at the call site,
%% rather than silently shipping a bad message to the server.
-spec increment(pos_integer()) -> ok.
increment(N) when is_integer(N), N > 0 ->
gen_server:cast(?SERVER, {increment, N}).
-spec decrement() -> ok.
decrement() ->
decrement(1).
-spec decrement(pos_integer()) -> ok.
decrement(N) when is_integer(N), N > 0 ->
gen_server:cast(?SERVER, {decrement, N}).
-spec get_value() -> non_neg_integer().
get_value() ->
gen_server:call(?SERVER, get_value).
-spec reset() -> ok.
reset() ->
gen_server:call(?SERVER, reset).
-spec stop() -> ok.
stop() ->
gen_server:stop(?SERVER).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
-spec init(start_options()) -> {ok, #state{}} | {stop, term()}.
init(Options) ->
%% Trap exits so terminate/2 runs on supervisor shutdown and so a linked
%% process dying arrives as a message we can decide about, not a kill.
process_flag(trap_exit, true),
case validate_max(proplists:get_value(max, Options, infinity)) of
{ok, Max} ->
{ok, #state{count = 0, max = Max}};
{error, Reason} ->
{stop, {invalid_max, Reason}}
end.
-spec handle_call(term(), gen_server:from(), #state{}) ->
{reply, term(), #state{}}.
handle_call(get_value, _From, #state{count = Count} = State) ->
{reply, Count, State};
handle_call(reset, _From, State) ->
{reply, ok, State#state{count = 0}};
handle_call(Request, From, State) ->
%% A catch-all keeps one malformed call from crashing the server and
%% blocking every other client. Reply with an error so the caller fails
%% fast instead of waiting out the 5s call timeout.
?LOG_WARNING("~p: unexpected call ~p from ~p", [?MODULE, Request, From]),
{reply, {error, {unknown_request, Request}}, State}.
-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast({increment, N}, #state{count = Count, max = Max} = State)
when is_integer(N), N > 0 ->
{noreply, State#state{count = clamp_high(Count + N, Max)}};
handle_cast({decrement, N}, #state{count = Count} = State)
when is_integer(N), N > 0 ->
{noreply, State#state{count = max(0, Count - N)}};
handle_cast(Msg, State) ->
?LOG_WARNING("~p: unexpected cast ~p", [?MODULE, Msg]),
{noreply, State}.
-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
handle_info({'EXIT', Pid, Reason}, State) ->
%% Because we trap exits, a linked process dying arrives here as a message
%% rather than killing us. Log and decide per-link whether to act.
?LOG_INFO("~p: linked process ~p exited: ~p", [?MODULE, Pid, Reason]),
{noreply, State};
handle_info(Info, State) ->
?LOG_INFO("~p: ignoring info message ~p", [?MODULE, Info]),
{noreply, State}.
-spec terminate(term(), #state{}) -> ok.
terminate(Reason, #state{count = Count}) ->
?LOG_INFO("~p terminating (count=~p, reason=~p)", [?MODULE, Count, Reason]),
ok.
-spec code_change(term(), #state{}, term()) -> {ok, #state{}}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%%===================================================================
%%% Internal helpers
%%%===================================================================
-spec validate_max(term()) ->
{ok, non_neg_integer() | infinity} | {error, term()}.
validate_max(infinity) ->
{ok, infinity};
validate_max(Max) when is_integer(Max), Max >= 0 ->
{ok, Max};
validate_max(Other) ->
{error, {not_a_valid_max, Other}}.
-spec clamp_high(integer(), non_neg_integer() | infinity) -> non_neg_integer().
clamp_high(Value, infinity) ->
Value;
clamp_high(Value, Max) when Value > Max ->
Max;
clamp_high(Value, _Max) ->
Value.
Example 2: gen_statem with state_enter — Door Lock (door_fsm)
A door has three states — locked, unlocked, open — and only some transitions are legal (you cannot open a locked door). Encoding states as a state machine makes the illegal transitions explicit: any action invalid for the current state is rejected with {error, {invalid_in_state, State}} instead of silently doing the wrong thing. handle_event_function mode keeps the whole transition table in one function; state_enter callbacks log each transition once. The unlock code is configurable and validated at start_link.
-module(door_fsm).
-behaviour(gen_statem).
-include_lib("kernel/include/logger.hrl").
%% Public API
-export([start_link/0, start_link/1,
open/0, close/0, lock/0, unlock/1, status/0, stop/0]).
%% gen_statem callbacks
-export([init/1, callback_mode/0, terminate/3, code_change/4]).
%% State callback (handle_event_function mode)
-export([handle_event/4]).
-define(SERVER, ?MODULE).
-define(DEFAULT_CODE, <<"1234">>).
-type door_state() :: locked | unlocked | open.
-type data() :: #{code := binary(), attempts := non_neg_integer()}.
-export_type([door_state/0]).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
start_link(?DEFAULT_CODE).
%% Require a non-trivial code; a too-short code crashes the caller, not the FSM.
-spec start_link(binary()) -> {ok, pid()} | ignore | {error, term()}.
start_link(Code) when is_binary(Code), byte_size(Code) >= 4 ->
gen_statem:start_link({local, ?SERVER}, ?MODULE, #{code => Code}, []).
-spec open() -> ok | {error, term()}.
open() ->
gen_statem:call(?SERVER, open).
-spec close() -> ok | {error, term()}.
close() ->
gen_statem:call(?SERVER, close).
-spec lock() -> ok | {error, term()}.
lock() ->
gen_statem:call(?SERVER, lock).
-spec unlock(binary()) -> ok | {error, term()}.
unlock(Code) when is_binary(Code) ->
gen_statem:call(?SERVER, {unlock, Code}).
-spec status() -> door_state().
status() ->
gen_statem:call(?SERVER, status).
-spec stop() -> ok.
stop() ->
gen_statem:stop(?SERVER).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
-spec init(#{code := binary()}) -> {ok, door_state(), data()}.
init(#{code := Code}) ->
process_flag(trap_exit, true),
{ok, locked, #{code => Code, attempts => 0}}.
-spec callback_mode() -> [handle_event_function | state_enter].
callback_mode() ->
[handle_event_function, state_enter].
-spec handle_event(gen_statem:event_type(), term(), door_state(), data()) ->
keep_state_and_data
| {keep_state_and_data, [gen_statem:reply_action()]}
| {keep_state, data(), [gen_statem:reply_action()]}
| {next_state, door_state(), data(), [gen_statem:reply_action()]}.
%% state_enter: log every transition once.
handle_event(enter, OldState, NewState, _Data) ->
?LOG_INFO("door transition: ~p -> ~p", [OldState, NewState]),
keep_state_and_data;
%% locked: only unlock (with correct code) is legal.
handle_event({call, From}, {unlock, Code}, locked, #{code := Code} = Data) ->
{next_state, unlocked, Data#{attempts => 0}, [{reply, From, ok}]};
handle_event({call, From}, {unlock, WrongCode}, locked, #{attempts := A} = Data) ->
{keep_state, Data#{attempts => A + 1}, [{reply, From, {error, wrong_code}}]};
handle_event({call, From}, open, locked, _Data) ->
{keep_state_and_data, [{reply, From, {error, {invalid_in_state, locked}}}]};
handle_event({call, From}, lock, locked, _Data) ->
{keep_state_and_data, [{reply, From, ok}]};
%% unlocked: can open or lock.
handle_event({call, From}, open, unlocked, Data) ->
{next_state, open, Data, [{reply, From, ok}]};
handle_event({call, From}, lock, unlocked, Data) ->
{next_state, locked, Data, [{reply, From, ok}]};
%% open: can only close.
handle_event({call, From}, close, open, Data) ->
{next_state, unlocked, Data, [{reply, From, ok}]};
%% status query works in any state.
handle_event({call, From}, status, State, Data) ->
{keep_state_and_data, [{reply, From, State}]};
%% catch-all: reject unknown events gracefully.
handle_event(EventType, EventContent, State, Data) ->
?LOG_WARNING("door_fsm: unexpected ~p:~p in state ~p", [EventType, EventContent, State]),
{keep_state, Data}.
-spec terminate(term(), door_state(), data()) -> ok.
terminate(Reason, State, #{attempts := A}) ->
?LOG_INFO("door_fsm terminating (state=~p, attempts=~p, reason=~p)", [State, A, Reason]),
ok.
-spec code_change(term(), door_state(), data(), term()) -> {ok, door_state(), data()}.
code_change(_OldVsn, State, Data, _Extra) ->
{ok, State, Data}.
Example 3: gen_statem with state_functions — Session Server (session_server)
A per-user session with a sliding idle timeout. state_functions mode gives each state its own function. The state_timeout action starts the idle timer on entry; a touch cast resets it. When the timer fires, the session stops with reason normal — its intended end state.
-module(session_server).
-behaviour(gen_statem).
-include_lib("kernel/include/logger.hrl").
-export([start_link/2, touch/1, get_user/1, close/1,
init/1, callback_mode/0, active/3, terminate/3, code_change/4]).
-type user_id() :: binary().
-export_type([user_id/0]).
-type data() :: #{user_id := user_id(), timeout := timeout()}.
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link(user_id(), timeout()) -> {ok, pid()} | ignore | {error, term()}.
start_link(UserId, IdleTimeout) when is_binary(UserId), (IdleTimeout =:= infinity orelse is_integer(IdleTimeout) andalso IdleTimeout >= 0) ->
gen_statem:start_link(?MODULE, #{user_id => UserId, timeout => IdleTimeout}, []).
-spec touch(pid()) -> ok.
touch(Pid) when is_pid(Pid) ->
gen_statem:cast(Pid, touch).
-spec get_user(pid()) -> {ok, user_id()}.
get_user(Pid) when is_pid(Pid) ->
gen_statem:call(Pid, get_user).
-spec close(pid()) -> ok.
close(Pid) when is_pid(Pid) ->
gen_statem:stop(Pid).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
-spec init(data()) ->
{ok, active, data(), [{state_timeout, timeout(), expire}]}.
init(#{timeout := IdleTimeout} = Data) ->
process_flag(trap_exit, true),
{ok, active, Data, [{state_timeout, IdleTimeout, expire}]}.
-spec callback_mode() -> state_functions.
callback_mode() ->
state_functions.
-spec active(gen_statem:event_type(), term(), data()) ->
{keep_state, data()}
| {keep_state, data(), [gen_statem:action()]}
| {stop, normal, data()}.
%% The idle timer fired: no activity for a full window, so retire the session.
active(state_timeout, expire, #{user_id := UserId} = Data) ->
?LOG_INFO("session for ~p expired after idle timeout", [UserId]),
{stop, normal, Data};
%% Activity: restart the idle timer to extend the sliding window.
active(cast, touch, #{timeout := IdleTimeout} = Data) ->
{keep_state, Data, [{state_timeout, IdleTimeout, expire}]};
active({call, From}, get_user, #{user_id := UserId} = Data) ->
{keep_state, Data, [{reply, From, {ok, UserId}}]};
active(EventType, EventContent, Data) ->
?LOG_WARNING("session: unexpected ~p:~p", [EventType, EventContent]),
{keep_state, Data}.
-spec terminate(term(), active, data()) -> ok.
terminate(Reason, _State, #{user_id := UserId}) ->
?LOG_INFO("session for ~p terminating (reason ~p)", [UserId, Reason]),
ok.
-spec code_change(term(), active, data(), term()) -> {ok, active, data()}.
code_change(_OldVsn, State, Data, _Extra) ->
{ok, State, Data}.
Example 4: Dynamic Children — Session Supervisor (session_sup)
Sessions are created at runtime, one per login, so they need a simple_one_for_one supervisor: it holds a single child template and spawns instances on demand via start_child/2, appending the per-session arguments to the template's start args. The restart => temporary choice is deliberate and directly illustrates pitfall #7 — a session that expired or was closed must not be restarted, because dying is its intended end state. Restarting it would resurrect a session nobody asked for.
-module(session_sup).
-behaviour(supervisor).
-export([start_link/0, start_session/2, init/1]).
-define(SERVER, ?MODULE).
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
%% Spawns session_server:start_link(UserId, IdleTimeout) under the supervisor.
%% The UserId guard rejects bad input here; session_server validates the timeout.
-spec start_session(session_server:user_id(), timeout()) ->
{ok, pid()} | {ok, pid(), term()} | {error, term()}.
start_session(UserId, IdleTimeout) when is_binary(UserId) ->
supervisor:start_child(?SERVER, [UserId, IdleTimeout]).
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
SupFlags = #{strategy => simple_one_for_one,
intensity => 10,
period => 60},
Template = #{id => session_server,
start => {session_server, start_link, []},
restart => temporary, %% expired/closed sessions stay dead
shutdown => 5000,
type => worker,
modules => [session_server]},
{ok, {SupFlags, [Template]}}.
Example 5: Supervision Tree (app_supervisor)
The top-level supervisor wires the pieces together. It uses one_for_one so a crash in one child doesn't disturb the others — they're independent. Note the two reuse-and-correctness details: it pulls the counter's spec from counter_server:child_spec/1 (best practice #8), and the session_sup child uses shutdown => infinity, which is the required idiom for a supervisor child so it gets unbounded time to terminate its own children gracefully before the parent gives up.
-module(app_supervisor).
-behaviour(supervisor).
-export([start_link/0, init/1]).
-define(SERVER, ?MODULE).
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
%% intensity/period: tolerate up to 5 restarts within 60s before the
%% supervisor itself gives up — a circuit breaker against crash loops.
SupFlags = #{strategy => one_for_one,
intensity => 5,
period => 60},
Counter = counter_server:child_spec([{max, 1000}]),
Door = #{id => door_fsm,
start => {door_fsm, start_link, [<<"4821">>]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [door_fsm]},
%% A supervisor child needs `shutdown => infinity' so it can shut down its
%% own subtree before the parent considers it terminated.
Sessions = #{id => session_sup,
start => {session_sup, start_link, []},
restart => permanent,
shutdown => infinity,
type => supervisor,
modules => [session_sup]},
{ok, {SupFlags, [Counter, Door, Sessions]}}.
auto_shutdown (OTP 24+). This tree keeps every child independent, so it doesn't use it. When a subtree is only meaningful while one core child is alive, mark that child significant => true and set the supervisor's auto_shutdown => any_significant (or all_significant); the supervisor then terminates the whole subtree when that child exits normally and for good, instead of restarting it or leaving an orphaned tree.
Pitfalls
These are framed as trade-offs with reasons, not absolute rules — context decides whether something is a mistake.
- Doing slow/blocking work inside
handle_call. Because a gen_server processes one message at a time, a blocking call stalls every other client and can deadlock if the blocked call waits on a reply from the same server. Offload long work to a spawned task or reply immediately and continue asynchronously.
- No catch-all message clause. An unmatched
handle_call/handle_cast/handle_info clause raises function_clause and crashes the process. This is not caught by the compiler — you must add a final catch-all clause so a stray message degrades gracefully (log + ignore) instead of taking down the server and everything it was serving.
- Forgetting to reply in
handle_call. The caller blocks in gen_server:call/2 until the default 5000 ms timeout elapses, then exits with a timeout reason. Always return a {reply, Reply, NewState} tuple, or hand the From value to another process and return {noreply, NewState} so a gen_server:reply(From, Reply) can happen later.
- Choosing the wrong supervision strategy.
one_for_one restarts only the failed child; one_for_all restarts every sibling (use when children share fate); rest_for_one restarts the failed child and those started after it (use when later children depend on earlier ones). Picking the wrong one either restarts too much or leaves dependents pointing at a dead process.
- Trapping exits without a reason.
process_flag(trap_exit, true) is not universally required — it converts incoming exit signals into {'EXIT', Pid, Reason} messages and ensures terminate/2 runs on shutdown. Set it when you need cleanup on shutdown or want to react to linked processes dying; leaving it off is fine for a stateless worker that needs no teardown.
- Circular dependencies in a tree. If child A's start blocks on child B and B blocks on A, the supervisor's serial, ordered startup never completes. Order children so dependencies start first, or break the cycle with lazy connection/retry logic.
- Wrong
restart type for the role. permanent always restarts, transient restarts only on abnormal exit, temporary never restarts. temporary is correct for a session that should die when idle, and wrong for a database pool that must always exist — the value is right or wrong only relative to the child's purpose.
- Skipping
code_change. You only need a real code_change/3 (or /4) implementation if you perform hot release upgrades via release_handler. Systems that deploy with rolling restarts can leave the default pass-through — don't add migration logic you'll never exercise.
- Holding very large state in the process. Large per-process heaps lengthen garbage-collection pauses, and the full state is copied into crash reports and
sys:get_state/1 dumps. For large or read-mostly shared data, keep it in an ETS table or persistent_term and store only a handle in the process.
- No timeout in a long-lived state machine. Without a state or generic timeout, an idle session/connection lingers forever and leaks resources.
gen_statem state timeouts give you bounded lifetimes.
- Reaching for
gen_event reflexively. It is supported, but its shared-process model means one misbehaving handler degrades all of them. Use it for lightweight trusted fan-out; choose gen_statem or a pub/sub library when isolation matters.
- Assuming distribution is encrypted. Erlang distribution uses unencrypted TCP by default and authenticates only with a shared cookie. If nodes communicate across an untrusted network, enable TLS distribution (
-proto_dist inet_tls with -ssl_dist_optfile) and protect the cookie — don't assume the wire is private.
Verification
Compile all five modules with debug info so Dialyzer and the debugger can use them:
erlc +debug_info counter_server.erl door_fsm.erl session_server.erl session_sup.erl app_supervisor.erl
Expected: five .beam files produced, no warnings.
Run Dialyzer to confirm the -spec/-type declarations hold:
dialyzer --src counter_server.erl door_fsm.erl session_server.erl session_sup.erl app_supervisor.erl
Expected: no warnings or errors. If a PLT is missing, build one first: dialyzer --build_plt --apps erts kernel stdlib.
counter_server bounds check — start it, increment a few times, and confirm state via sys:get_state:
{ok, _} = counter_server:start_link([{max, 3}]),
counter_server:increment(5),
%% saturates at 3
{ok, 3} = {ok, counter_server:get_value()},
counter_server:decrement(10),
%% floors at 0
{ok, 0} = {ok, counter_server:get_value()},
sys:get_state(counter_server).
door_fsm illegal transitions — drive the transitions and confirm an illegal action returns an error:
{ok, _} = door_fsm:start_link(<<"4821">>),
{error, {invalid_in_state, locked}} = door_fsm:open(),
{error, wrong_code} = door_fsm:unlock(<<"9999">>),
ok = door_fsm:unlock(<<"4821">>),
ok = door_fsm:open(),
unlocked = door_fsm:status().
session_server idle timeout — start one with a short timeout, touch it to confirm the window slides, then leave it idle and confirm it exits with reason normal:
{ok, Pid} = session_server:start_link(<<"user1">>, 1000),
session_server:touch(Pid),
timer:sleep(500),
session_server:touch(Pid),
timer:sleep(1500),
%% process should be gone
false = erlang:is_process_alive(Pid).
Supervisor recovery — start app_supervisor, kill door_fsm, and confirm the supervisor restarts it under one_for_one while siblings keep their pids:
{ok, _} = app_supervisor:start_link(),
CounterPid = whereis(counter_server),
SessionsPid = whereis(session_sup),
exit(whereis(door_fsm), kill),
timer:sleep(100),
%% door_fsm restarted with a new pid
true = is_pid(whereis(door_fsm)),
%% siblings unaffected
CounterPid = whereis(counter_server),
SessionsPid = whereis(session_sup).
Distribution security — if these run on a distributed node over an untrusted network, confirm TLS distribution (-proto_dist inet_tls) and cookie protection are configured; otherwise record it as N/A for a single node.
Hot upgrade (only if you do live release upgrades) — exercise the sys:suspend/1 → sys:change_code/4 → sys:resume/1 path against a running gen_server/gen_statem.
Related Skills
- Erlang OTP Design Principles (System Documentation)
gen_server Reference Manual
gen_statem Reference Manual
supervisor Reference Manual
- Learn You Some Erlang — The Count, Rage Against The Finite-State Machines, Supervisors
- Erlang/OTP Secure Coding & TLS Distribution guidance
1---2name: erlang-otp-behaviors3description: Implements Erlang/OTP behaviors: gen_server, gen_statem, supervisor, and gen_event with typed callbacks and child_spec helpers. Use when writing OTP processes, supervision trees, or replacing raw spawn/receive. Not for Elixir GenServer/Phoenix, deprecated gen_fsm (use gen_statem), or BEAM NIFs. OTP 27: prefer format_status/1; format_status/2 is deprecated.4---5
6# Erlang OTP Behaviors
7
8## Overview
9
10OTP (Open Telecom Platform) behaviors are reusable templates for the recurring shapes of concurrent processes. A behavior splits responsibilities: the OTP library handles the generic infrastructure (the receive loop, message matching, timeouts, system messages, supervision integration), and you fill in the *callback* functions with your business logic. This separation is what makes the behaviors worth using — the hard, easy-to-get-wrong parts are written once, in OTP, and tested by millions of deployments.
11
12The four behaviors covered here:
13
14- **`gen_server`** runs a request/response loop over private state.
15- **`gen_statem`** runs a state machine where the current state selects how events are handled, with first-class timeouts and state-entry callbacks.
16- **`supervisor`** starts, monitors, and restarts a fixed or dynamic set of children according to a declared strategy.
17- **`gen_event`** dispatches events to a set of pluggable handlers in one manager process (supported, but see pitfalls).
18
19Two cross-cutting habits run through all examples, and both exist for the same reason — *fail fast, at the boundary, with a clear cause*:
20
211. **Explicit `-spec`/`-type` declarations** so Dialyzer can prove the code's types line up before it ever runs. There is no `any()` where a precise type is known.
222. **Guards on public API functions** so a bad argument crashes in the *caller's* process at the call site, rather than being shipped as a message that corrupts server state or fails confusingly deep inside a callback.
23
24## When to Use
25
26Reach for an OTP behavior whenever a problem maps onto one of the shapes OTP already solves, because the behavior gives you supervision, debugging (`sys`/`dbg`), code upgrade, and consistent error reporting for free — work you would otherwise reimplement (usually inconsistently) on raw `spawn`.
27
28- **`gen_server`** — when a process *owns* mutable state that other processes read or modify through an API. The reason it fits is serialization: a `gen_server` handles one message at a time, so you never need locks or mutexes to protect the state, and the request/response (`call`) and fire-and-forget (`cast`) split is built in.
29- **`gen_statem`** — when behavior depends on an explicit *mode* and the legal transitions between modes matter (protocols, connection lifecycles, device drivers, session expiry). Encoding the mode as a state means illegal transitions become unrepresentable, and you get per-state timeouts and state-entry hooks without extra bookkeeping.
30- **`supervisor`** — whenever a process's crash should be *contained and recovered* rather than propagated. A supervisor turns "let it crash" into a reliability strategy: it restarts a child to a known-good initial state instead of leaving the system in a half-broken one.
31- **Supervision trees** — to structure a whole application so a failure is isolated to the smallest subtree that can recover on its own. A flat pile of processes tends to over-restart on any single fault; a tree restarts only the affected branch.
32- **`gen_event`** — for simple, in-process notification fan-out to a set of trusted handlers. It is fully supported (not deprecated), but every handler runs *inside* the manager process, so prefer `gen_statem` or a dedicated pub/sub library when handlers are heavy, slow, or must fail independently.
33
34## Prerequisites
35
36- A modern Erlang/OTP toolchain (OTP 24+ for `auto_shutdown`; verify callbacks against [OTP 27 stdlib](https://www.erlang.org/docs/27/apps/stdlib/api-reference.html)). `format_status/2` is deprecated on `gen_server` / `gen_statem` / `gen_event` — use `format_status/1`.
37- `erlc` compiler and `dialyzer` available on `PATH`.
38- On Windows (PowerShell), use forward slashes or escaped backslashes in paths; `erlc` and `dialyzer` accept both.
39- `kernel/include/logger.hrl` available (ships with OTP).
40
41## Procedure
42
43### Best Practices and the Reasoning Behind Them
44
451. **Put owned state behind a `gen_server`** so access is serialized and you inherit supervision and introspection. Hand-rolled `receive` loops re-solve these badly.
462. **Provide every callback the behavior expects, including catch-alls.** The compiler warns about missing/unexported callbacks; a catch-all clause turns "unexpected message" from a crash into a logged no-op.
473. **Model state with a typed record (or a typed map).** A `-record` with field types lets Dialyzer catch a wrong field write at build time; maps are more flexible when the shape evolves. Choose per how much you value rigidity vs. flexibility.
484. **Use `cast` for fire-and-forget, `call` for results.** `cast` returns immediately and never blocks the caller; reserve `call` (which blocks and can time out) for when you actually need a reply or back-pressure.
495. **Implement `terminate/2` only when there's something to clean up** (close sockets, flush buffers), and remember it runs reliably only when the process traps exits or is shut down by its supervisor.
506. **Type timeouts with the built-in `timeout()`** (`non_neg_integer() | infinity`) so intent is explicit and Dialyzer-checked — there is no special `gen:timeout()` type; `timeout()` is the standard one.
517. **Prefer `gen_statem`'s `handle_event_function` mode for branchy machines.** Keeping all transitions in one `handle_event/4` makes the full transition table reviewable in one place; use `state_functions` mode when each state's logic is large enough to deserve its own function.
528. **Expose a typed `child_spec/1` helper from worker modules.** This is a convention, not an OTP callback: it keeps a worker's start args and shutdown policy next to the worker itself, so supervisors stay declarative.
539. **Match the `restart` type to the child's role** (`permanent` / `transient` / `temporary`) rather than defaulting everything to `permanent`.
5410. **Use `sys:get_state/1` and tracing to inspect live processes** when debugging — these work for any OTP behavior because the behavior handles system messages for you.
5511. **Lean on `auto_shutdown` for "the tree only makes sense while X lives."** Marking a child `significant => true` and setting the supervisor's `auto_shutdown => any_significant` (or `all_significant`) lets the subtree wind itself down when that child exits for good — cleaner than a manual shutdown signal.
5612. **Treat distribution as plaintext until you configure TLS.** Enable TLS distribution and guard the cookie before sending sensitive data between nodes on shared networks.
57
58### Example 1: gen_server — Bounded Counter (`counter_server`)
59
60This shows the standard `gen_server` skeleton hardened with types and validation. The counter is *bounded*: increments saturate at `max`, decrements floor at `0`. Guards on the public API reject bad input in the caller's process; the callbacks defend again with catch-all clauses so the server cannot be crashed by an unexpected message.
61
62```erlang
63-module(counter_server).
64-behaviour(gen_server).
65
66-include_lib("kernel/include/logger.hrl").
67
68%% Public API
69-export([start_link/0, start_link/1,
70 increment/0, increment/1,
71 decrement/0, decrement/1,
72 get_value/0, reset/0, stop/0]).
73
74%% Convention (not an OTP callback): a typed child spec for supervisors.
75-export([child_spec/1]).
76
77%% gen_server callbacks
78-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
79 terminate/2, code_change/3]).
80
81-define(SERVER, ?MODULE).
82
83%% A typed record lets Dialyzer reject a wrong-typed field write at build time.
84%% `max' is an inclusive upper bound; `infinity' means unbounded.
85-record(state, {
86 count = 0 :: non_neg_integer(),
87 max = infinity :: non_neg_integer() | infinity
88}).
89
90-type start_option() :: {max, non_neg_integer() | infinity}.
91-type start_options() :: [start_option()].
92-export_type([start_option/0, start_options/0]).
93
94%%%===================================================================
95%%% API
96%%%===================================================================
97
98-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
99start_link() ->
100 start_link([]).
101
102-spec start_link(start_options()) -> {ok, pid()} | ignore | {error, term()}.
103start_link(Options) when is_list(Options) ->
104 gen_server:start_link({local, ?SERVER}, ?MODULE, Options, []).
105
106%% Keeping start args + shutdown policy next to the worker keeps supervisors
107%% declarative. `modules' helps the release handler during code upgrades.
108-spec child_spec(start_options()) -> supervisor:child_spec().
109child_spec(Options) when is_list(Options) ->
110 #{id => ?MODULE,
111 start => {?MODULE, start_link, [Options]},
112 restart => permanent,
113 shutdown => 5000,
114 type => worker,
115 modules => [?MODULE]}.
116
117-spec increment() -> ok.
118increment() ->
119 increment(1).
120
121%% The guard makes a non-positive step crash the *caller* at the call site,
122%% rather than silently shipping a bad message to the server.
123-spec increment(pos_integer()) -> ok.
124increment(N) when is_integer(N), N > 0 ->
125 gen_server:cast(?SERVER, {increment, N}).
126
127-spec decrement() -> ok.
128decrement() ->
129 decrement(1).
130
131-spec decrement(pos_integer()) -> ok.
132decrement(N) when is_integer(N), N > 0 ->
133 gen_server:cast(?SERVER, {decrement, N}).
134
135-spec get_value() -> non_neg_integer().
136get_value() ->
137 gen_server:call(?SERVER, get_value).
138
139-spec reset() -> ok.
140reset() ->
141 gen_server:call(?SERVER, reset).
142
143-spec stop() -> ok.
144stop() ->
145 gen_server:stop(?SERVER).
146
147%%%===================================================================
148%%% gen_server callbacks
149%%%===================================================================
150
151-spec init(start_options()) -> {ok, #state{}} | {stop, term()}.
152init(Options) ->
153 %% Trap exits so terminate/2 runs on supervisor shutdown and so a linked
154 %% process dying arrives as a message we can decide about, not a kill.
155 process_flag(trap_exit, true),
156 case validate_max(proplists:get_value(max, Options, infinity)) of
157 {ok, Max} ->
158 {ok, #state{count = 0, max = Max}};
159 {error, Reason} ->
160 {stop, {invalid_max, Reason}}
161 end.
162
163-spec handle_call(term(), gen_server:from(), #state{}) ->
164 {reply, term(), #state{}}.
165handle_call(get_value, _From, #state{count = Count} = State) ->
166 {reply, Count, State};
167handle_call(reset, _From, State) ->
168 {reply, ok, State#state{count = 0}};
169handle_call(Request, From, State) ->
170 %% A catch-all keeps one malformed call from crashing the server and
171 %% blocking every other client. Reply with an error so the caller fails
172 %% fast instead of waiting out the 5s call timeout.
173 ?LOG_WARNING("~p: unexpected call ~p from ~p", [?MODULE, Request, From]),
174 {reply, {error, {unknown_request, Request}}, State}.
175
176-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
177handle_cast({increment, N}, #state{count = Count, max = Max} = State)
178 when is_integer(N), N > 0 ->
179 {noreply, State#state{count = clamp_high(Count + N, Max)}};
180handle_cast({decrement, N}, #state{count = Count} = State)
181 when is_integer(N), N > 0 ->
182 {noreply, State#state{count = max(0, Count - N)}};
183handle_cast(Msg, State) ->
184 ?LOG_WARNING("~p: unexpected cast ~p", [?MODULE, Msg]),
185 {noreply, State}.
186
187-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
188handle_info({'EXIT', Pid, Reason}, State) ->
189 %% Because we trap exits, a linked process dying arrives here as a message
190 %% rather than killing us. Log and decide per-link whether to act.
191 ?LOG_INFO("~p: linked process ~p exited: ~p", [?MODULE, Pid, Reason]),
192 {noreply, State};
193handle_info(Info, State) ->
194 ?LOG_INFO("~p: ignoring info message ~p", [?MODULE, Info]),
195 {noreply, State}.
196
197-spec terminate(term(), #state{}) -> ok.
198terminate(Reason, #state{count = Count}) ->
199 ?LOG_INFO("~p terminating (count=~p, reason=~p)", [?MODULE, Count, Reason]),
200 ok.
201
202-spec code_change(term(), #state{}, term()) -> {ok, #state{}}.
203code_change(_OldVsn, State, _Extra) ->
204 {ok, State}.
205
206%%%===================================================================
207%%% Internal helpers
208%%%===================================================================
209
210-spec validate_max(term()) ->
211 {ok, non_neg_integer() | infinity} | {error, term()}.
212validate_max(infinity) ->
213 {ok, infinity};
214validate_max(Max) when is_integer(Max), Max >= 0 ->
215 {ok, Max};
216validate_max(Other) ->
217 {error, {not_a_valid_max, Other}}.
218
219-spec clamp_high(integer(), non_neg_integer() | infinity) -> non_neg_integer().
220clamp_high(Value, infinity) ->
221 Value;
222clamp_high(Value, Max) when Value > Max ->
223 Max;
224clamp_high(Value, _Max) ->
225 Value.
226```
227
228### Example 2: gen_statem with `state_enter` — Door Lock (`door_fsm`)
229
230A door has three states — `locked`, `unlocked`, `open` — and only some transitions are legal (you cannot `open` a `locked` door). Encoding states as a state machine makes the illegal transitions explicit: any action invalid for the current state is rejected with `{error, {invalid_in_state, State}}` instead of silently doing the wrong thing. `handle_event_function` mode keeps the whole transition table in one function; `state_enter` callbacks log each transition once. The unlock code is configurable and validated at `start_link`.
231
232```erlang
233-module(door_fsm).
234-behaviour(gen_statem).
235
236-include_lib("kernel/include/logger.hrl").
237
238%% Public API
239-export([start_link/0, start_link/1,
240 open/0, close/0, lock/0, unlock/1, status/0, stop/0]).
241
242%% gen_statem callbacks
243-export([init/1, callback_mode/0, terminate/3, code_change/4]).
244
245%% State callback (handle_event_function mode)
246-export([handle_event/4]).
247
248-define(SERVER, ?MODULE).
249-define(DEFAULT_CODE, <<"1234">>).
250
251-type door_state() :: locked | unlocked | open.
252-type data() :: #{code := binary(), attempts := non_neg_integer()}.
253-export_type([door_state/0]).
254
255%%%===================================================================
256%%% API
257%%%===================================================================
258
259-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
260start_link() ->
261 start_link(?DEFAULT_CODE).
262
263%% Require a non-trivial code; a too-short code crashes the caller, not the FSM.
264-spec start_link(binary()) -> {ok, pid()} | ignore | {error, term()}.
265start_link(Code) when is_binary(Code), byte_size(Code) >= 4 ->
266 gen_statem:start_link({local, ?SERVER}, ?MODULE, #{code => Code}, []).
267
268-spec open() -> ok | {error, term()}.
269open() ->
270 gen_statem:call(?SERVER, open).
271
272-spec close() -> ok | {error, term()}.
273close() ->
274 gen_statem:call(?SERVER, close).
275
276-spec lock() -> ok | {error, term()}.
277lock() ->
278 gen_statem:call(?SERVER, lock).
279
280-spec unlock(binary()) -> ok | {error, term()}.
281unlock(Code) when is_binary(Code) ->
282 gen_statem:call(?SERVER, {unlock, Code}).
283
284-spec status() -> door_state().
285status() ->
286 gen_statem:call(?SERVER, status).
287
288-spec stop() -> ok.
289stop() ->
290 gen_statem:stop(?SERVER).
291
292%%%===================================================================
293%%% gen_statem callbacks
294%%%===================================================================
295
296-spec init(#{code := binary()}) -> {ok, door_state(), data()}.
297init(#{code := Code}) ->
298 process_flag(trap_exit, true),
299 {ok, locked, #{code => Code, attempts => 0}}.
300
301-spec callback_mode() -> [handle_event_function | state_enter].
302callback_mode() ->
303 [handle_event_function, state_enter].
304
305-spec handle_event(gen_statem:event_type(), term(), door_state(), data()) ->
306 keep_state_and_data
307 | {keep_state_and_data, [gen_statem:reply_action()]}
308 | {keep_state, data(), [gen_statem:reply_action()]}
309 | {next_state, door_state(), data(), [gen_statem:reply_action()]}.
310%% state_enter: log every transition once.
311handle_event(enter, OldState, NewState, _Data) ->
312 ?LOG_INFO("door transition: ~p -> ~p", [OldState, NewState]),
313 keep_state_and_data;
314%% locked: only unlock (with correct code) is legal.
315handle_event({call, From}, {unlock, Code}, locked, #{code := Code} = Data) ->
316 {next_state, unlocked, Data#{attempts => 0}, [{reply, From, ok}]};
317handle_event({call, From}, {unlock, WrongCode}, locked, #{attempts := A} = Data) ->
318 {keep_state, Data#{attempts => A + 1}, [{reply, From, {error, wrong_code}}]};
319handle_event({call, From}, open, locked, _Data) ->
320 {keep_state_and_data, [{reply, From, {error, {invalid_in_state, locked}}}]};
321handle_event({call, From}, lock, locked, _Data) ->
322 {keep_state_and_data, [{reply, From, ok}]};
323%% unlocked: can open or lock.
324handle_event({call, From}, open, unlocked, Data) ->
325 {next_state, open, Data, [{reply, From, ok}]};
326handle_event({call, From}, lock, unlocked, Data) ->
327 {next_state, locked, Data, [{reply, From, ok}]};
328%% open: can only close.
329handle_event({call, From}, close, open, Data) ->
330 {next_state, unlocked, Data, [{reply, From, ok}]};
331%% status query works in any state.
332handle_event({call, From}, status, State, Data) ->
333 {keep_state_and_data, [{reply, From, State}]};
334%% catch-all: reject unknown events gracefully.
335handle_event(EventType, EventContent, State, Data) ->
336 ?LOG_WARNING("door_fsm: unexpected ~p:~p in state ~p", [EventType, EventContent, State]),
337 {keep_state, Data}.
338
339-spec terminate(term(), door_state(), data()) -> ok.
340terminate(Reason, State, #{attempts := A}) ->
341 ?LOG_INFO("door_fsm terminating (state=~p, attempts=~p, reason=~p)", [State, A, Reason]),
342 ok.
343
344-spec code_change(term(), door_state(), data(), term()) -> {ok, door_state(), data()}.
345code_change(_OldVsn, State, Data, _Extra) ->
346 {ok, State, Data}.
347```
348
349### Example 3: gen_statem with `state_functions` — Session Server (`session_server`)
350
351A per-user session with a sliding idle timeout. `state_functions` mode gives each state its own function. The `state_timeout` action starts the idle timer on entry; a `touch` cast resets it. When the timer fires, the session stops with reason `normal` — its intended end state.
352
353```erlang
354-module(session_server).
355-behaviour(gen_statem).
356
357-include_lib("kernel/include/logger.hrl").
358
359-export([start_link/2, touch/1, get_user/1, close/1,
360 init/1, callback_mode/0, active/3, terminate/3, code_change/4]).
361
362-type user_id() :: binary().
363-export_type([user_id/0]).
364
365-type data() :: #{user_id := user_id(), timeout := timeout()}.
366
367%%%===================================================================
368%%% API
369%%%===================================================================
370
371-spec start_link(user_id(), timeout()) -> {ok, pid()} | ignore | {error, term()}.
372start_link(UserId, IdleTimeout) when is_binary(UserId), (IdleTimeout =:= infinity orelse is_integer(IdleTimeout) andalso IdleTimeout >= 0) ->
373 gen_statem:start_link(?MODULE, #{user_id => UserId, timeout => IdleTimeout}, []).
374
375-spec touch(pid()) -> ok.
376touch(Pid) when is_pid(Pid) ->
377 gen_statem:cast(Pid, touch).
378
379-spec get_user(pid()) -> {ok, user_id()}.
380get_user(Pid) when is_pid(Pid) ->
381 gen_statem:call(Pid, get_user).
382
383-spec close(pid()) -> ok.
384close(Pid) when is_pid(Pid) ->
385 gen_statem:stop(Pid).
386
387%%%===================================================================
388%%% gen_statem callbacks
389%%%===================================================================
390
391-spec init(data()) ->
392 {ok, active, data(), [{state_timeout, timeout(), expire}]}.
393init(#{timeout := IdleTimeout} = Data) ->
394 process_flag(trap_exit, true),
395 {ok, active, Data, [{state_timeout, IdleTimeout, expire}]}.
396
397-spec callback_mode() -> state_functions.
398callback_mode() ->
399 state_functions.
400
401-spec active(gen_statem:event_type(), term(), data()) ->
402 {keep_state, data()}
403 | {keep_state, data(), [gen_statem:action()]}
404 | {stop, normal, data()}.
405%% The idle timer fired: no activity for a full window, so retire the session.
406active(state_timeout, expire, #{user_id := UserId} = Data) ->
407 ?LOG_INFO("session for ~p expired after idle timeout", [UserId]),
408 {stop, normal, Data};
409%% Activity: restart the idle timer to extend the sliding window.
410active(cast, touch, #{timeout := IdleTimeout} = Data) ->
411 {keep_state, Data, [{state_timeout, IdleTimeout, expire}]};
412active({call, From}, get_user, #{user_id := UserId} = Data) ->
413 {keep_state, Data, [{reply, From, {ok, UserId}}]};
414active(EventType, EventContent, Data) ->
415 ?LOG_WARNING("session: unexpected ~p:~p", [EventType, EventContent]),
416 {keep_state, Data}.
417
418-spec terminate(term(), active, data()) -> ok.
419terminate(Reason, _State, #{user_id := UserId}) ->
420 ?LOG_INFO("session for ~p terminating (reason ~p)", [UserId, Reason]),
421 ok.
422
423-spec code_change(term(), active, data(), term()) -> {ok, active, data()}.
424code_change(_OldVsn, State, Data, _Extra) ->
425 {ok, State, Data}.
426```
427
428### Example 4: Dynamic Children — Session Supervisor (`session_sup`)
429
430Sessions are created at runtime, one per login, so they need a `simple_one_for_one` supervisor: it holds a single child *template* and spawns instances on demand via `start_child/2`, appending the per-session arguments to the template's start args. The `restart => temporary` choice is deliberate and directly illustrates pitfall #7 — a session that expired or was closed must **not** be restarted, because dying is its intended end state. Restarting it would resurrect a session nobody asked for.
431
432```erlang
433-module(session_sup).
434-behaviour(supervisor).
435
436-export([start_link/0, start_session/2, init/1]).
437
438-define(SERVER, ?MODULE).
439
440-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
441start_link() ->
442 supervisor:start_link({local, ?SERVER}, ?MODULE, []).
443
444%% Spawns session_server:start_link(UserId, IdleTimeout) under the supervisor.
445%% The UserId guard rejects bad input here; session_server validates the timeout.
446-spec start_session(session_server:user_id(), timeout()) ->
447 {ok, pid()} | {ok, pid(), term()} | {error, term()}.
448start_session(UserId, IdleTimeout) when is_binary(UserId) ->
449 supervisor:start_child(?SERVER, [UserId, IdleTimeout]).
450
451-spec init([]) ->
452 {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
453init([]) ->
454 SupFlags = #{strategy => simple_one_for_one,
455 intensity => 10,
456 period => 60},
457 Template = #{id => session_server,
458 start => {session_server, start_link, []},
459 restart => temporary, %% expired/closed sessions stay dead
460 shutdown => 5000,
461 type => worker,
462 modules => [session_server]},
463 {ok, {SupFlags, [Template]}}.
464```
465
466### Example 5: Supervision Tree (`app_supervisor`)
467
468The top-level supervisor wires the pieces together. It uses `one_for_one` so a crash in one child doesn't disturb the others — they're independent. Note the two reuse-and-correctness details: it pulls the counter's spec from `counter_server:child_spec/1` (best practice #8), and the `session_sup` child uses `shutdown => infinity`, which is the required idiom for a supervisor child so it gets unbounded time to terminate *its own* children gracefully before the parent gives up.
469
470```erlang
471-module(app_supervisor).
472-behaviour(supervisor).
473
474-export([start_link/0, init/1]).
475
476-define(SERVER, ?MODULE).
477
478-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
479start_link() ->
480 supervisor:start_link({local, ?SERVER}, ?MODULE, []).
481
482-spec init([]) ->
483 {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
484init([]) ->
485 %% intensity/period: tolerate up to 5 restarts within 60s before the
486 %% supervisor itself gives up — a circuit breaker against crash loops.
487 SupFlags = #{strategy => one_for_one,
488 intensity => 5,
489 period => 60},
490
491 Counter = counter_server:child_spec([{max, 1000}]),
492
493 Door = #{id => door_fsm,
494 start => {door_fsm, start_link, [<<"4821">>]},
495 restart => permanent,
496 shutdown => 5000,
497 type => worker,
498 modules => [door_fsm]},
499
500 %% A supervisor child needs `shutdown => infinity' so it can shut down its
501 %% own subtree before the parent considers it terminated.
502 Sessions = #{id => session_sup,
503 start => {session_sup, start_link, []},
504 restart => permanent,
505 shutdown => infinity,
506 type => supervisor,
507 modules => [session_sup]},
508
509 {ok, {SupFlags, [Counter, Door, Sessions]}}.
510```
511
512> **`auto_shutdown` (OTP 24+).** This tree keeps every child independent, so it doesn't use it. When a subtree is only meaningful while one core child is alive, mark that child `significant => true` and set the supervisor's `auto_shutdown => any_significant` (or `all_significant`); the supervisor then terminates the whole subtree when that child exits *normally and for good*, instead of restarting it or leaving an orphaned tree.
513
514## Pitfalls
515
516These are framed as trade-offs with reasons, not absolute rules — context decides whether something is a mistake.
517
5181. **Doing slow/blocking work inside `handle_call`.** Because a `gen_server` processes one message at a time, a blocking call stalls *every* other client and can deadlock if the blocked call waits on a reply from the same server. Offload long work to a spawned task or reply immediately and continue asynchronously.
5192. **No catch-all message clause.** An unmatched `handle_call`/`handle_cast`/`handle_info` clause raises `function_clause` and crashes the process. This is *not* caught by the compiler — you must add a final catch-all clause so a stray message degrades gracefully (log + ignore) instead of taking down the server and everything it was serving.
5203. **Forgetting to reply in `handle_call`.** The caller blocks in `gen_server:call/2` until the default 5000 ms timeout elapses, then *exits* with a `timeout` reason. Always return a `{reply, Reply, NewState}` tuple, or hand the `From` value to another process and return `{noreply, NewState}` so a `gen_server:reply(From, Reply)` can happen later.
5214. **Choosing the wrong supervision strategy.** `one_for_one` restarts only the failed child; `one_for_all` restarts every sibling (use when children share fate); `rest_for_one` restarts the failed child and those started after it (use when later children depend on earlier ones). Picking the wrong one either restarts too much or leaves dependents pointing at a dead process.
5225. **Trapping exits without a reason.** `process_flag(trap_exit, true)` is not universally required — it converts incoming exit signals into `{'EXIT', Pid, Reason}` messages and ensures `terminate/2` runs on shutdown. Set it *when* you need cleanup on shutdown or want to react to linked processes dying; leaving it off is fine for a stateless worker that needs no teardown.
5236. **Circular dependencies in a tree.** If child A's start blocks on child B and B blocks on A, the supervisor's serial, ordered startup never completes. Order children so dependencies start first, or break the cycle with lazy connection/retry logic.
5247. **Wrong `restart` type for the role.** `permanent` always restarts, `transient` restarts only on abnormal exit, `temporary` never restarts. `temporary` is *correct* for a session that should die when idle, and *wrong* for a database pool that must always exist — the value is right or wrong only relative to the child's purpose.
5258. **Skipping `code_change`.** You only need a real `code_change/3` (or `/4`) implementation if you perform *hot* release upgrades via `release_handler`. Systems that deploy with rolling restarts can leave the default pass-through — don't add migration logic you'll never exercise.
5269. **Holding very large state in the process.** Large per-process heaps lengthen garbage-collection pauses, and the full state is copied into crash reports and `sys:get_state/1` dumps. For large or read-mostly shared data, keep it in an ETS table or `persistent_term` and store only a handle in the process.
52710. **No timeout in a long-lived state machine.** Without a state or generic timeout, an idle session/connection lingers forever and leaks resources. `gen_statem` state timeouts give you bounded lifetimes.
52811. **Reaching for `gen_event` reflexively.** It is supported, but its shared-process model means one misbehaving handler degrades all of them. Use it for lightweight trusted fan-out; choose `gen_statem` or a pub/sub library when isolation matters.
52912. **Assuming distribution is encrypted.** Erlang distribution uses *unencrypted* TCP by default and authenticates only with a shared cookie. If nodes communicate across an untrusted network, enable TLS distribution (`-proto_dist inet_tls` with `-ssl_dist_optfile`) and protect the cookie — don't assume the wire is private.
530
531## Verification
532
5331. **Compile all five modules** with debug info so Dialyzer and the debugger can use them:
534
535 ```powershell
536 erlc +debug_info counter_server.erl door_fsm.erl session_server.erl session_sup.erl app_supervisor.erl
537 ```
538
539 Expected: five `.beam` files produced, no warnings.
540
5412. **Run Dialyzer** to confirm the `-spec`/`-type` declarations hold:
542
543 ```powershell
544 dialyzer --src counter_server.erl door_fsm.erl session_server.erl session_sup.erl app_supervisor.erl
545 ```
546
547 Expected: no warnings or errors. If a PLT is missing, build one first: `dialyzer --build_plt --apps erts kernel stdlib`.
548
5493. **`counter_server` bounds check** — start it, increment a few times, and confirm state via `sys:get_state`:
550
551 ```erlang
552 {ok, _} = counter_server:start_link([{max, 3}]),
553 counter_server:increment(5),
554 %% saturates at 3
555 {ok, 3} = {ok, counter_server:get_value()},
556 counter_server:decrement(10),
557 %% floors at 0
558 {ok, 0} = {ok, counter_server:get_value()},
559 sys:get_state(counter_server).
560 ```
561
5624. **`door_fsm` illegal transitions** — drive the transitions and confirm an illegal action returns an error:
563
564 ```erlang
565 {ok, _} = door_fsm:start_link(<<"4821">>),
566 {error, {invalid_in_state, locked}} = door_fsm:open(),
567 {error, wrong_code} = door_fsm:unlock(<<"9999">>),
568 ok = door_fsm:unlock(<<"4821">>),
569 ok = door_fsm:open(),
570 unlocked = door_fsm:status().
571 ```
572
5735. **`session_server` idle timeout** — start one with a short timeout, `touch` it to confirm the window slides, then leave it idle and confirm it exits with reason `normal`:
574
575 ```erlang
576 {ok, Pid} = session_server:start_link(<<"user1">>, 1000),
577 session_server:touch(Pid),
578 timer:sleep(500),
579 session_server:touch(Pid),
580 timer:sleep(1500),
581 %% process should be gone
582 false = erlang:is_process_alive(Pid).
583 ```
584
5856. **Supervisor recovery** — start `app_supervisor`, kill `door_fsm`, and confirm the supervisor restarts it under `one_for_one` while siblings keep their pids:
586
587 ```erlang
588 {ok, _} = app_supervisor:start_link(),
589 CounterPid = whereis(counter_server),
590 SessionsPid = whereis(session_sup),
591 exit(whereis(door_fsm), kill),
592 timer:sleep(100),
593 %% door_fsm restarted with a new pid
594 true = is_pid(whereis(door_fsm)),
595 %% siblings unaffected
596 CounterPid = whereis(counter_server),
597 SessionsPid = whereis(session_sup).
598 ```
599
6007. **Distribution security** — if these run on a distributed node over an untrusted network, confirm TLS distribution (`-proto_dist inet_tls`) and cookie protection are configured; otherwise record it as N/A for a single node.
601
6028. **Hot upgrade** (only if you do live release upgrades) — exercise the `sys:suspend/1` → `sys:change_code/4` → `sys:resume/1` path against a running `gen_server`/`gen_statem`.
603
604## Related Skills
605
606- Erlang OTP Design Principles (System Documentation)
607- `gen_server` Reference Manual
608- `gen_statem` Reference Manual
609- `supervisor` Reference Manual
610- Learn You Some Erlang — The Count, Rage Against The Finite-State Machines, Supervisors
611- Erlang/OTP Secure Coding & TLS Distribution guidance