Below is a detailed Elixir Code Style Guide that incorporates the features you asked about (@type t
and @enforce_keys
) and other related Elixir constructs, such as structs, type specifications, module attributes, and documentation. This guide is designed to promote consistency, readability, and maintainability in Elixir codebases, with examples grounded in the context of the Quantum.NodeSelectorBroadcaster.StartOpts
module you provided. It also includes best practices for similar features and conventions commonly used in Elixir projects.
Elixir Code Style Guide
This style guide outlines conventions and best practices for writing clean, consistent, and maintainable Elixir code. It emphasizes Elixir’s idiomatic features, including structs, type specifications, module attributes, and documentation, while incorporating specific guidance for features like @type t
and @enforce_keys
. The goal is to ensure code is readable, well-documented, and robust for both development and production use.
Table of Contents
- General Principles
- Module Structure and Attributes
- Structs and
@enforce_keys
- Type Specifications (
@type
,@type t
,@spec
) - Documentation (
@moduledoc
,@doc
) - Naming Conventions
- Code Formatting
- Testing Best Practices
- Performance Guidelines
- Error Handling Patterns
- Common Anti-Patterns to Avoid
- OTP and Concurrency Guidelines
- Best Practices for Related Features
- Example Implementation
- Tools for Code Quality
1. General Principles
- Clarity over Cleverness: Write code that’s easy to understand and maintain, even if it means being more verbose.
- Consistency: Follow this guide within a project to ensure uniformity across the codebase.
- Leverage Elixir Features: Use Elixir’s built-in tools (e.g., structs, type specs, module attributes) to improve code reliability and documentation.
- Use Tooling: Rely on tools like
mix format
, Dialyzer, and Credo to enforce style and catch errors.
2. Module Structure and Attributes
Modules are the primary organizational unit in Elixir. Structure them clearly and use module attributes effectively.
Module Naming
- Use CamelCase for module names, reflecting their purpose (e.g.,
Quantum.NodeSelectorBroadcaster.StartOpts
). - Nest modules logically to reflect their hierarchy (e.g.,
Quantum.NodeSelectorBroadcaster
for a broadcaster within theQuantum
library).
Module Attributes
- Use
@moduledoc
to document the module’s purpose at the top of the file. - Use
@doc
for public functions and structs. - Reserve module attributes like
@enforce_keys
,@type
, and@spec
for their specific purposes (see below). - Avoid using module attributes for runtime data unless necessary; prefer constants or configuration.
Example:
defmodule Quantum.NodeSelectorBroadcaster.StartOpts do
@moduledoc """
Configuration struct for starting a Quantum NodeSelectorBroadcaster.
Defines required options for initializing the broadcaster process.
"""
# Module attributes for struct and type specs
@enforce_keys [:name, :execution_broadcaster_reference, :task_supervisor_reference]
defstruct @enforce_keys
@type t :: %__MODULE___{
name: GenServer.server(),
execution_broadcaster_reference: GenServer.server(),
task_supervisor_reference: GenServer.server()
}
end
3. Structs and @enforce_keys
Structs in Elixir are a powerful way to define structured data with enforced constraints. The @enforce_keys
attribute ensures required fields are provided during struct creation.
Guidelines
- Use Structs for Well-Defined Data:
- Define structs for data with a fixed structure, such as configuration options or domain models (e.g.,
StartOpts
for broadcaster configuration). - Use
defstruct
to define the fields, leveraging@enforce_keys
for required fields.
- Define structs for data with a fixed structure, such as configuration options or domain models (e.g.,
- Enforce Required Fields with
@enforce_keys
:- List fields in
@enforce_keys
that must always have non-nil values. - Ensure
@enforce_keys
matches the fields indefstruct
unless optional fields are explicitly allowed. - Example:
@enforce_keys [:name, :reference]
ensures these fields are provided and non-nil.
- List fields in
- Avoid Overusing Structs:
- Use structs for data with clear semantics; use maps for flexible or dynamic data.
- Default Values:
- Avoid defaults in
defstruct
unless truly optional, as@enforce_keys
requires non-nil values for listed fields. - If defaults are needed, define them explicitly in
defstruct
for non-enforced fields (e.g.,defstruct @enforce_keys ++ [timeout: 5000]
).
- Avoid defaults in
Example
defmodule Quantum.NodeSelectorBroadcaster.StartOpts do
@enforce_keys [:name, :execution_broadcaster_reference, :task_supervisor_reference]
defstruct @enforce_keys
# Valid creation
def example do
%__MODULE___{
name: :broadcaster,
execution_broadcaster_reference: {:global, :exec_broadcaster},
task_supervisor_reference: {:global, :task_sup}
}
end
end
Error Cases:
# Missing key
%Quantum.NodeSelectorBroadcaster.StartOpts{name: :broadcaster}
# => ** (KeyError) key :execution_broadcaster_reference not found
# Nil value for enforced key
%Quantum.NodeSelectorBroadcaster.StartOpts{
name: :broadcaster,
execution_broadcaster_reference: nil,
task_supervisor_reference: {:global, :task_sup}
}
# => ** (KeyError) key :execution_broadcaster_reference cannot be nil
4. Type Specifications (@type
, @type t
, @spec
)
Type specifications improve code reliability, enable static analysis with Dialyzer, and enhance documentation.
Guidelines
- Define a
@type t
for Structs:- Every module defining a struct should include a
@type t
specifying the struct’s structure. - Use
%__MODULE__{...}
to define the struct type, listing all fields and their types. - Example:
@type t :: %__MODULE___{name: GenServer.server(), ...}
.
- Every module defining a struct should include a
- Use Descriptive Types:
- Use built-in types (e.g.,
atom()
,pid()
,GenServer.server()
) or custom types for fields. - Define custom types with
@type
for complex or reusable types.
- Use built-in types (e.g.,
- Function Specifications with
@spec
:- Add
@spec
for all public functions to document their input and output types. - Reference
@type t
in function specs for structs.
- Add
- Private Types:
- Use
@typep
for types only used within the module.
- Use
- Placement:
- Place type definitions near the top of the module, after
@moduledoc
and before function definitions. - Group related types together for clarity.
- Place type definitions near the top of the module, after
Example
defmodule Quantum.NodeSelectorBroadcaster do
@moduledoc """
A GenServer for broadcasting node selection events in the Quantum scheduler.
"""
@type t :: %__MODULE__.StartOpts{
name: GenServer.server(),
execution_broadcaster_reference: GenServer.server(),
task_supervisor_reference: GenServer.server()
}
@typep internal_state :: %{
broadcaster: GenServer.server(),
tasks: map()
}
@spec start_link(t()) :: GenServer.on_start()
def start_link(%__MODULE__.StartOpts{} = opts) do
GenServer.start_link(__MODULE__, opts, name: opts.name)
end
end
5. Documentation (@moduledoc
, @doc
)
Good documentation is critical for maintainability and collaboration.
Guidelines
- Module Documentation (
@moduledoc
):- Every module should have a
@moduledoc
describing its purpose and usage. - Mark internal modules with
@moduledoc false
to suppress documentation generation (as in the original example). - Use clear, concise language and Markdown formatting for readability.
- Every module should have a
- Function and Struct Documentation (
@doc
):- Document all public functions and structs with
@doc
. - Include examples, expected inputs, and return values.
- Use
@doc false
for private functions if documentation is needed internally.
- Document all public functions and structs with
- Reference Types:
- Mention relevant types (e.g.,
@type t
) in@moduledoc
or@doc
for clarity.
- Mention relevant types (e.g.,
- Examples:
- Include code examples in
@doc
using Markdown code blocks (elixir
). - Show both success and error cases where applicable.
- Include code examples in
Example
defmodule Quantum.NodeSelectorBroadcaster.StartOpts do
@moduledoc """
A struct for configuring a `Quantum.NodeSelectorBroadcaster`.
Defines required fields for initializing the broadcaster process.
See `@type t` for the struct's type specification.
"""
@enforce_keys [:name, :execution_broadcaster_reference, :task_supervisor_reference]
defstruct @enforce_keys
@type t :: %__MODULE___{
name: GenServer.server(),
execution_broadcaster_reference: GenServer.server(),
task_supervisor_reference: GenServer.server()
}
@doc """
Creates a new `StartOpts` struct.
## Examples
iex> %Quantum.NodeSelectorBroadcaster.StartOpts{
...> name: :broadcaster,
...> execution_broadcaster_reference: {:global, :exec_broadcaster},
...> task_supervisor_reference: {:global, :task_sup}
...> }
%Quantum.NodeSelectorBroadcaster.StartOpts{...}
"""
def new(name, exec_ref, task_ref) do
%__MODULE__{
name: name,
execution_broadcaster_reference: exec_ref,
task_supervisor_reference: task_ref
}
end
end
6. Naming Conventions
- Modules: Use CamelCase (e.g.,
NodeSelectorBroadcaster
). - Functions and Variables: Use snake_case (e.g.,
start_link
,task_supervisor_reference
). - Struct Fields: Use snake_case for field names, matching function and variable conventions.
- Type Names: Use snake_case for custom types (e.g.,
@type my_type :: term()
). - Atoms: Use snake_case for atoms (e.g.,
:execution_broadcaster_reference
). - Descriptive Names: Choose names that clearly describe the purpose (e.g.,
task_supervisor_reference
indicates a reference to a task supervisor).
7. Code Formatting
- Use
mix format
:- Run
mix format
to enforce consistent code formatting across the project. - Configure
.formatter.exs
to include all source files (e.g.,lib/
,test/
).
- Run
- Line Length:
- Aim for lines under 98 characters, as recommended by Elixir’s formatter.
- Break long lines using Elixir’s pipeline operator (
|>
) or clear indentation.
- Indentation:
- Use 2 spaces for indentation (enforced by
mix format
).
- Use 2 spaces for indentation (enforced by
- Struct Definitions:
- Align struct fields vertically for readability:
@type t :: %__MODULE___{ name: GenServer.server(), execution_broadcaster_reference: GenServer.server(), task_supervisor_reference: GenServer.server() }
8. Testing Best Practices
ExUnit Patterns
Use ExUnit.Case for test modules:
- Include
use ExUnit.Case
in test modules - Use
async: true
for tests that don’t share state:
defmodule MyModuleTest do use ExUnit.Case, async: true # tests here... end
- Include
Setup Callbacks:
- Use
setup
for per-test initialization - Use
setup_all
for module-wide setup (requiresasync: false
)
setup do {:ok, pid} = MyModule.start_link([]) %{pid: pid} end test "validates behavior", %{pid: pid} do assert MyModule.get_state(pid) == %{} end
- Use
Test Tagging and Organization:
- Use
@tag
to categorize tests:
@tag :external @tag timeout: 10_000 test "calls external service" do # test implementation end
- Configure test exclusions in
test/test_helper.exs
:
ExUnit.start(exclude: [:external])
- Use
Doctests
Include doctests for public functions:
@doc """ Creates a new user struct. ## Examples iex> MyModule.new_user("Alice", 25) %MyModule.User{name: "Alice", age: 25} iex> MyModule.new_user("", 25) {:error, :invalid_name} """ def new_user(name, age) when is_binary(name) and name != "" do %User{name: name, age: age} end
Add
doctest ModuleName
to test files:defmodule MyModuleTest do use ExUnit.Case, async: true doctest MyModule end
Test Structure
Follow AAA pattern (Arrange, Act, Assert):
test "calculates total price correctly" do # Arrange items = [%Item{price: 10}, %Item{price: 20}] # Act total = Calculator.total_price(items) # Assert assert total == 30 end
Capture logs for cleaner output:
@moduletag :capture_log
9. Performance Guidelines
Avoid Large Structs
- Limit struct fields to under 32:
- Structs with 32+ fields use hash maps internally, impacting performance
- Break large structs into smaller, focused ones:
# Instead of one large struct defmodule LargeUser do defstruct [:name, :email, :address, :phone, ...] # 35+ fields end # Use composition defmodule User do defstruct [:name, :email, :profile, :settings] end defmodule UserProfile do defstruct [:address, :phone, :bio] end
Process vs Module Decisions
Use modules for stateless operations:
# Good: Simple calculator as a module defmodule Calculator do def add(a, b), do: a + b def multiply(a, b), do: a * b end
Use processes for stateful operations or concurrency:
# Good: Counter with state as GenServer defmodule Counter do use GenServer def increment(pid), do: GenServer.call(pid, :increment) # ... GenServer callbacks end
Optimize Expensive Operations
Cache expensive computations:
def expensive_operation(data) do # Cache the computed value to avoid repeated calls cache_key = :erlang.phash2(data) case :ets.lookup(:cache_table, cache_key) do [{^cache_key, result}] -> result [] -> result = do_expensive_work(data) :ets.insert(:cache_table, {cache_key, result}) result end end
Minimize data copying between processes:
# Good: Send only necessary data GenServer.cast(pid, {:process_user_id, user.id}) # Avoid: Sending large structs GenServer.cast(pid, {:process_user, large_user_struct})
Avoid redundant operations in hot paths:
# Good: Cache module name extraction def forward(program, inputs, opts) do program_name = program_name(program) # Cache this # Use program_name multiple times without recomputing end
10. Error Handling Patterns
Assertive Programming
- Use pattern matching for expected data structures:
# Good: Assertive - fails fast on unexpected data def process_user(%User{name: name, email: email}) do # Processing here end # Avoid: Non-assertive - silently handles wrong data def process_user(user) do name = user[:name] || "unknown" # Might hide bugs end
Tagged Tuples
- Use consistent
{:ok, result}
|{:error, reason}
patterns:def create_user(attrs) do case validate_attrs(attrs) do :ok -> case save_user(attrs) do {:ok, user} -> {:ok, user} {:error, reason} -> {:error, {:database_error, reason}} end {:error, reason} -> {:error, {:validation_error, reason}} end end
Using with
for Sequential Operations
- Use
with
for clean error handling in pipelines:def process_request(params) do with {:ok, parsed} <- parse_params(params), {:ok, validated} <- validate_data(parsed), {:ok, result} <- process_data(validated) do {:ok, result} else {:error, reason} -> {:error, reason} other -> {:error, {:unexpected_result, other}} end end
Error Context
- Provide meaningful error messages:
def divide(a, b) when b == 0 do {:error, {:division_by_zero, "Cannot divide #{a} by zero"}} end def divide(a, b), do: {:ok, a / b}
11. Common Anti-Patterns to Avoid
Macro Anti-Patterns
Avoid large code generation in macros:
# Bad: Validation logic in macro (compiled for every route) defmacro get(route, handler) do quote do if not is_binary(unquote(route)) do raise ArgumentError, "route must be binary" end # ... more validation code generated everywhere end end # Good: Extract validation to compile-time function defmacro get(route, handler) do validate_route_at_compile_time!(route) quote do @routes [{unquote(route), unquote(handler)} | @routes] end end
Prefer functions over macros when possible:
# Bad: Unnecessary macro defmacro add(a, b) do quote do: unquote(a) + unquote(b) end # Good: Simple function def add(a, b), do: a + b
Process Anti-Patterns
- Don’t use processes for simple stateless operations:
# Bad: Unnecessary GenServer for calculation defmodule CalculatorServer do use GenServer def add(a, b), do: GenServer.call(__MODULE__, {:add, a, b}) end # Good: Simple module function defmodule Calculator do def add(a, b), do: a + b end
Data Structure Anti-Patterns
Avoid non-assertive map access:
# Bad: Silent failures def get_name(user) do user[:name] || "unknown" # Hides missing data issues end # Good: Assertive access def get_name(%{name: name}), do: name
Avoid non-assertive boolean logic:
# Bad: Using && with non-booleans if user && user.active do # process end # Good: Explicit boolean checks if is_map(user) and user.active do # process end
Performance Anti-Patterns
- Avoid unnecessary atom creation:
# Bad: Creating atoms from user input def process_status(status_string) do String.to_atom(status_string) # Can exhaust atom table end # Good: Use existing atoms only def process_status(status_string) do String.to_existing_atom(status_string) end
12. OTP and Concurrency Guidelines
GenServer Best Practices
Keep GenServer state minimal and focused:
defmodule UserCache do use GenServer # Good: Focused state defstruct [:users, :last_updated, :config] def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @impl GenServer def init(opts) do state = %__MODULE__{ users: %{}, last_updated: DateTime.utc_now(), config: Keyword.get(opts, :config, %{}) } {:ok, state} end end
Use appropriate GenServer calls vs casts:
# Use call for operations requiring response def get_user(id), do: GenServer.call(__MODULE__, {:get_user, id}) # Use cast for fire-and-forget operations def update_stats(data), do: GenServer.cast(__MODULE__, {:update_stats, data})
Supervisor Patterns
- Design fault-tolerant supervision trees:
defmodule MyApp.Supervisor do use Supervisor def start_link(init_arg) do Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) end @impl Supervisor def init(_init_arg) do children = [ {MyApp.Cache, []}, {MyApp.WorkerSupervisor, []}, {MyApp.WebServer, [port: 4000]} ] Supervisor.init(children, strategy: :one_for_one) end end
Process Communication
Use appropriate process communication patterns:
# Good: Structured messages def handle_info({:user_updated, user_id, changes}, state) do # Handle user update {:noreply, update_user(state, user_id, changes)} end # Good: Timeout handling def handle_info(:timeout, state) do # Handle timeout {:noreply, state, 5000} # Reset timeout end
Handle process monitoring:
def handle_info({:DOWN, ref, :process, pid, reason}, state) do # Clean up references to dead process state = remove_process_ref(state, pid, ref) {:noreply, state} end
Task and Async Patterns
- Use Task for one-off async operations:
def fetch_user_data_async(user_id) do Task.async(fn -> # Fetch data from external service ExternalAPI.get_user(user_id) end) end def collect_results(tasks) do Task.await_many(tasks, 5000) end
13. Best Practices for Related Features
Module Attributes
- Use module attributes for compile-time configuration (e.g.,
@enforce_keys
,@type
). - Avoid storing runtime state in module attributes, as they are evaluated at compile time.
Structs
- Use structs for domain-specific data with fixed fields (e.g., configuration structs like
StartOpts
). - Combine with
@enforce_keys
for required fields and@type t
for type safety.
Behaviours
- Define behaviours for modules that share a common interface (e.g.,
GenServer
forQuantum.NodeSelectorBroadcaster
). Example:
defmodule Quantum.NodeSelectorBroadcaster do
@behaviour GenServer
# ...
end
Pattern Matching
- Use pattern matching in function clauses to validate struct inputs:
def start_link(%__MODULE__.StartOpts{} = opts), do: GenServer.start_link(__MODULE__, opts)
Error Handling
- Use
with
or pattern matching for robust error handling when working with structs. Example:
def validate_opts(%__MODULE__.StartOpts{} = opts) do
with :ok <- validate_name(opts.name),
:ok <- validate_reference(opts.execution_broadcaster_reference),
:ok <- validate_reference(opts.task_supervisor_reference) do
{:ok, opts}
else
error -> {:error, error}
end
end
14. Example Implementation
Here’s a complete example incorporating the above guidelines, expanding on the Quantum.NodeSelectorBroadcaster.StartOpts
module:
defmodule Quantum.NodeSelectorBroadcaster.StartOpts do
@moduledoc """
A struct for configuring a `Quantum.NodeSelectorBroadcaster` process.
This struct defines the required configuration for initializing a broadcaster
process in the Quantum job scheduler. All fields are enforced to ensure proper
configuration.
## Fields
- `name`: The name of the broadcaster process (`GenServer.server()`).
- `execution_broadcaster_reference`: Reference to the execution broadcaster.
- `task_supervisor_reference`: Reference to the task supervisor.
See `@type t` for the type specification.
"""
@enforce_keys [:name, :execution_broadcaster_reference, :task_supervisor_reference]
defstruct @enforce_keys
@type t :: %__MODULE___{
name: GenServer.server(),
execution_broadcaster_reference: GenServer.server(),
task_supervisor_reference: GenServer.server()
}
@doc """
Creates a new `StartOpts` struct with the given parameters.
## Parameters
- `name`: The name of the broadcaster process.
- `exec_ref`: The execution broadcaster reference.
- `task_ref`: The task supervisor reference.
## Returns
- A `%StartOpts{}` struct if all parameters are valid.
- Raises a `KeyError` if any enforced key is missing or `nil`.
## Examples
iex> Quantum.NodeSelectorBroadcaster.StartOpts.new(
...> :broadcaster,
...> {:global, :exec_broadcaster},
...> {:global, :task_sup}
...> )
%Quantum.NodeSelectorBroadcaster.StartOpts{
name: :broadcaster,
execution_broadcaster_reference: {:global, :exec_broadcaster},
task_supervisor_reference: {:global, :task_sup}
}
"""
@spec new(GenServer.server(), GenServer.server(), GenServer.server()) :: t()
def new(name, exec_ref, task_ref) do
%__MODULE__{
name: name,
execution_broadcaster_reference: exec_ref,
task_supervisor_reference: task_ref
}
end
@doc """
Validates a `StartOpts` struct.
## Parameters
- `opts`: The `%StartOpts{}` struct to validate.
## Returns
- `{:ok, opts}` if valid.
- `{:error, reason}` if invalid.
## Examples
iex> opts = Quantum.NodeSelectorBroadcaster.StartOpts.new(:broadcaster, {:global, :exec}, {:global, :task})
iex> Quantum.NodeSelectorBroadcaster.StartOpts.validate(opts)
{:ok, opts}
"""
@spec validate(t()) :: {:ok, t()} | {:error, term()}
def validate(%__MODULE__{} = opts) do
{:ok, opts}
end
end
15. Tools for Code Quality
mix format
: Enforce consistent code formatting.- Run:
mix format
- Configure: Update
.formatter.exs
for project-specific rules.
- Run:
- Dialyzer: Static analysis for type checking.
- Run:
mix dialyzer
- Ensure all public functions and structs have
@spec
and@type
.
- Run:
- Credo: Linting for code style and best practices.
- Run:
mix credo
- Configure: Customize
.credo.exs
for project-specific checks.
- Run:
- ExDoc: Generate documentation from
@moduledoc
and@doc
.- Run:
mix docs
- Ensure all public APIs are documented.
- Run:
Conclusion
This comprehensive style guide provides a complete framework for writing high-quality Elixir code that is consistent, readable, maintainable, and performant. By covering essential areas including:
- Core Language Features: Structs, type specifications, and documentation
- Testing Excellence: ExUnit patterns, doctests, and test organization
- Performance Optimization: Avoiding common bottlenecks and expensive operations
- Robust Error Handling: Assertive programming and proper error patterns
- Anti-Pattern Awareness: Common pitfalls to avoid in macros, processes, and data structures
- OTP Best Practices: GenServer, supervision, and concurrency patterns
This guide enables developers to leverage Elixir’s strengths while avoiding common mistakes. The combination of compile-time safety through type specifications, comprehensive testing practices, performance consciousness, and proper OTP usage creates codebases that are not only functional but truly production-ready.
The real-world examples and practical guidance ensure that teams can implement these practices immediately, resulting in Elixir applications that are scalable, maintainable, and robust. Following these guidelines, combined with Elixir’s excellent tooling ecosystem, will lead to codebases that exemplify the best of what the Elixir community has learned about building reliable, concurrent systems.