← Back to Ds ex

CODE QUALITY

Documentation for CODE_QUALITY from the Ds ex repository.

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

  1. General Principles
  2. Module Structure and Attributes
  3. Structs and @enforce_keys
  4. Type Specifications (@type, @type t, @spec)
  5. Documentation (@moduledoc, @doc)
  6. Naming Conventions
  7. Code Formatting
  8. Testing Best Practices
  9. Performance Guidelines
  10. Error Handling Patterns
  11. Common Anti-Patterns to Avoid
  12. OTP and Concurrency Guidelines
  13. Best Practices for Related Features
  14. Example Implementation
  15. 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 the Quantum 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.
  • Enforce Required Fields with @enforce_keys:
    • List fields in @enforce_keys that must always have non-nil values.
    • Ensure @enforce_keys matches the fields in defstruct unless optional fields are explicitly allowed.
    • Example: @enforce_keys [:name, :reference] ensures these fields are provided and non-nil.
  • 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]).

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(), ...}.
  • 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.
  • 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.
  • Private Types:
    • Use @typep for types only used within the module.
  • Placement:
    • Place type definitions near the top of the module, after @moduledoc and before function definitions.
    • Group related types together for clarity.

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.
  • 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.
  • Reference Types:
    • Mention relevant types (e.g., @type t) in @moduledoc or @doc for clarity.
  • Examples:
    • Include code examples in @doc using Markdown code blocks (elixir).
    • Show both success and error cases where applicable.

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/).
  • 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).
  • 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
    
  • Setup Callbacks:

    • Use setup for per-test initialization
    • Use setup_all for module-wide setup (requires async: false)
    setup do
      {:ok, pid} = MyModule.start_link([])
      %{pid: pid}
    end
    
    test "validates behavior", %{pid: pid} do
      assert MyModule.get_state(pid) == %{}
    end
    
  • 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])
    

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
    

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 for Quantum.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.
  • Dialyzer: Static analysis for type checking.
    • Run: mix dialyzer
    • Ensure all public functions and structs have @spec and @type.
  • Credo: Linting for code style and best practices.
    • Run: mix credo
    • Configure: Customize .credo.exs for project-specific checks.
  • ExDoc: Generate documentation from @moduledoc and @doc.
    • Run: mix docs
    • Ensure all public APIs are documented.

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.