Add @moduledoc to Secrets, LiveHelpers, AuthOverrides, and Membership domain. Enable Credo.Check.Readability.ModuleDoc in .credo.exs.
56 KiB
Code Guidelines and Best Practices
Purpose
This document serves as a foundational reference for our development team, ensuring consistency, maintainability, and quality in our codebase. It defines standards and best practices for building the Mila membership management application.
Project Context
We are building a membership management system (Mila) using the following technology stack:
Backend & Runtime:
- Elixir
~> 1.15(currently 1.18.3-otp-27) - Erlang/OTP 27.3.4
- Phoenix Framework
~> 1.8.0 - Ash Framework
~> 3.0 - AshPostgres
~> 2.0 - Ecto
~> 3.10 - Postgrex
>= 0.0.0 - AshAuthentication
~> 4.9 - AshAuthenticationPhoenix
~> 2.10 - bcrypt_elixir
~> 3.0
Frontend & UI:
- Phoenix LiveView
~> 1.1.0 - Phoenix HTML
~> 4.1 - Tailwind CSS 4.0.9
- DaisyUI (as Tailwind plugin)
- Heroicons v2.2.0
- JavaScript (ES2022)
- esbuild
~> 0.9
Database:
- PostgreSQL 17.6 (dev), 16 (prod)
Testing:
- ExUnit (built-in)
- Ecto.Adapters.SQL.Sandbox
Development Tools:
- asdf 0.16.5 (version management)
- Just 1.43.0 (task runner)
- Credo
~> 1.7(code analysis) - Sobelow
~> 0.14(security analysis) - mix_audit
~> 2.1(dependency audit)
Infrastructure:
- Docker & Docker Compose
- Bandit
~> 1.5(HTTP server)
Table of Contents
- Setup and Architectural Conventions
- Coding Standards and Style
- Tooling Guidelines
- Testing Standards
- Security Guidelines
- Performance Best Practices
- Documentation Standards
- Accessibility Guidelines
1. Setup and Architectural Conventions
1.1 Project Structure
Our project follows a domain-driven design approach using Phoenix contexts and Ash domains:
lib/
├── accounts/ # Accounts domain (AshAuthentication)
│ ├── accounts.ex # Domain definition
│ ├── user.ex # User resource
│ ├── token.ex # Token resource
│ ├── user_identity.exs # User identity helpers
│ └── user/ # User-related modules
│ ├── changes/ # Ash changes for user
│ └── preparations/ # Ash preparations for user
├── membership/ # Membership domain
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── property.ex # Custom property resource
│ ├── property_type.ex # Property type resource
│ └── email.ex # Email custom type
├── mv/ # Core application modules
│ ├── accounts/ # Domain-specific logic
│ │ └── user/
│ │ ├── senders/ # Email senders for user actions
│ │ └── validations/
│ ├── email_sync/ # Email synchronization logic
│ │ ├── changes/ # Sync changes
│ │ ├── helpers.ex # Sync helper functions
│ │ └── loader.ex # Data loaders
│ ├── membership/ # Domain-specific logic
│ │ └── member/
│ │ └── validations/
│ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer
│ ├── release.ex # Release tasks
│ ├── repo.ex # Database repository
│ └── secrets.ex # Secret management
├── mv_web/ # Web interface layer
│ ├── components/ # UI components
│ │ ├── core_components.ex
│ │ ├── table_components.ex
│ │ ├── layouts.ex
│ │ └── layouts/ # Layout templates
│ │ ├── navbar.ex
│ │ └── root.html.heex
│ ├── controllers/ # HTTP controllers
│ │ ├── auth_controller.ex
│ │ ├── page_controller.ex
│ │ ├── locale_controller.ex
│ │ ├── error_html.ex
│ │ ├── error_json.ex
│ │ └── page_html/
│ ├── live/ # LiveView modules
│ │ ├── components/ # LiveView-specific components
│ │ │ ├── search_bar_component.ex
│ │ │ └── sort_header_component.ex
│ │ ├── member_live/ # Member CRUD LiveViews
│ │ ├── property_live/ # Property CRUD LiveViews
│ │ ├── property_type_live/
│ │ └── user_live/ # User management LiveViews
│ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint
│ ├── gettext.ex # I18n configuration
│ ├── live_helpers.ex # LiveView helpers
│ ├── live_user_auth.ex # LiveView authentication
│ ├── router.ex # Application router
│ └── telemetry.ex # Telemetry configuration
├── mv_web.ex # Web module definition
└── mv.ex # Application module definition
test/
├── accounts/ # Accounts domain tests
│ ├── user_test.exs
│ ├── email_sync_edge_cases_test.exs
│ ├── email_uniqueness_test.exs
│ ├── user_email_sync_test.exs
│ ├── user_member_deletion_test.exs
│ └── user_member_relationship_test.exs
├── membership/ # Membership domain tests
│ ├── member_test.exs
│ └── member_email_sync_test.exs
├── mv_web/ # Web layer tests
│ ├── components/ # Component tests
│ │ ├── layouts/
│ │ │ └── navbar_test.exs
│ │ ├── search_bar_component_test.exs
│ │ └── sort_header_component_test.exs
│ ├── controllers/ # Controller tests
│ │ ├── auth_controller_test.exs
│ │ ├── error_html_test.exs
│ │ ├── error_json_test.exs
│ │ ├── oidc_integration_test.exs
│ │ └── page_controller_test.exs
│ ├── live/ # LiveView tests
│ │ └── profile_navigation_test.exs
│ ├── member_live/ # Member LiveView tests
│ │ └── index_test.exs
│ ├── user_live/ # User LiveView tests
│ │ ├── form_test.exs
│ │ └── index_test.exs
│ └── locale_test.exs
├── seeds_test.exs # Database seed tests
└── support/ # Test helpers
├── conn_case.ex # Controller test helpers
└── data_case.ex # Data layer test helpers
1.2 Module Organization
Module Naming:
- Modules: Use
PascalCasewith full namespace (e.g.,Mv.Accounts.User) - Domains: Top-level domains are
Mv.AccountsandMv.Membership - Resources: Resource modules should be singular nouns (e.g.,
Member, notMembers) - Context functions: Use
snake_caseand verb-first naming (e.g.,create_user,list_members)
Module Structure:
defmodule Mv.Membership.Member do
@moduledoc """
Represents a club member with their personal information and membership status.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
# 1. Ash DSL sections in order (see Spark formatter config)
admin do
# ...
end
postgres do
# ...
end
resource do
# ...
end
code_interface do
# ...
end
actions do
# ...
end
policies do
# ...
end
attributes do
# ...
end
relationships do
# ...
end
# 2. Public functions
# 3. Private functions
end
1.3 Domain-Driven Design
Use Ash Domains for Context Boundaries:
Each domain should:
- Have a clear boundary and responsibility
- Define a public API through code interfaces
- Encapsulate business logic within resources
- Handle cross-domain communication explicitly
Example domain definition:
defmodule Mv.Membership do
use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix]
admin do
show? true
end
resources do
resource Mv.Membership.Member do
define :create_member, action: :create_member
define :list_members, action: :read
define :update_member, action: :update_member
define :destroy_member, action: :destroy
end
end
end
1.4 Dependency Management
- Use
mix.exsfor all dependencies: Define versions explicitly - Keep dependencies up to date: Use Renovate for automated updates
- Version management: Use
asdfwith.tool-versionsfor consistent environments
1.5 Scalability Considerations
- Database indexing: Add indexes for frequently queried fields
- Pagination: Use Ash's keyset pagination for large datasets (default configured)
- Background jobs: Plan for Oban or similar for async processing
- Caching: Consider caching strategies for expensive operations
- Process design: Use OTP principles (GenServers, Supervisors) for stateful components
2. Coding Standards and Style
2.1 Code Formatting
Use mix format for all Elixir code:
mix format
Key formatting rules:
- Indentation: 2 spaces (no tabs)
- Line length: Maximum 120 characters (configured in
.credo.exs) - Trailing whitespace: Not allowed
- File endings: Always include trailing newline
Naming Conventions Summary:
- Elixir: Use
snake_casefor functions/variables,PascalCasefor modules - Phoenix: Controllers end with
Controller, LiveViews end withLive - Ash: Resources are singular nouns, actions are verb-first (
:create_member) - Files: Match module names in
snake_case(user_controller.exforUserController)
2.2 Function Design
Verb-First Function Names:
# Good
def create_user(attrs)
def list_members(query)
def send_email(recipient, content)
# Avoid
def user_create(attrs)
def members_list(query)
def email_send(recipient, content)
Use Pattern Matching in Function Heads:
# Good - multiple clauses with pattern matching
def handle_result({:ok, user}), do: {:ok, user}
def handle_result({:error, reason}), do: log_and_return_error(reason)
# Avoid - case/cond when pattern matching suffices
def handle_result(result) do
case result do
{:ok, user} -> {:ok, user}
{:error, reason} -> log_and_return_error(reason)
end
end
Keep Functions Small and Focused:
- Aim for functions under 20 lines
- Each function should have a single responsibility
- Extract complex logic into private helper functions
Use Guard Clauses for Early Returns:
def process_user(nil), do: {:error, :user_not_found}
def process_user(%{active: false}), do: {:error, :user_inactive}
def process_user(user), do: {:ok, perform_action(user)}
2.3 Error Handling
Use Tagged Tuples:
# Standard pattern
{:ok, result} | {:error, reason}
# Examples
def create_member(attrs) do
case Ash.create(Member, attrs) do
{:ok, member} -> {:ok, member}
{:error, error} -> {:error, error}
end
end
Use with for Complex Operations:
def register_user(params) do
with {:ok, validated} <- validate_params(params),
{:ok, user} <- create_user(validated),
{:ok, _email} <- send_welcome_email(user) do
{:ok, user}
else
{:error, reason} -> {:error, reason}
end
end
Let It Crash (with Supervision):
Don't defensively program against every possible error. Use supervisors to handle process failures:
# In your application.ex
children = [
Mv.Repo,
MvWeb.Endpoint,
{Phoenix.PubSub, name: Mv.PubSub}
]
Supervisor.start_link(children, strategy: :one_for_one)
2.4 Functional Programming Principles
Immutability:
# Good - return new data structures
def add_role(user, role) do
%{user | roles: [role | user.roles]}
end
# Avoid - mutation (not possible in Elixir anyway)
# This is just conceptual - Elixir prevents mutation
Pure Functions:
Write functions that:
- Return the same output for the same input
- Have no side effects
- Are easier to test and reason about
# Pure function
def calculate_total(items) do
Enum.reduce(items, 0, fn item, acc -> acc + item.price end)
end
# Impure function (side effects)
def create_and_log_user(attrs) do
Logger.info("Creating user: #{inspect(attrs)}") # Side effect
Ash.create!(User, attrs) # Side effect
end
Pipe Operator:
Use the pipe operator |> for transformation chains:
# Good
def process_members(query) do
query
|> filter_active()
|> sort_by_name()
|> limit_results(10)
end
# Avoid
def process_members(query) do
limit_results(sort_by_name(filter_active(query)), 10)
end
2.5 Elixir-Specific Patterns
Avoid Using Else with Unless:
# Good
unless user.admin? do
{:error, :unauthorized}
end
# Avoid - confusing
unless user.admin? do
{:error, :unauthorized}
else
perform_admin_action()
end
Use Enum over List Comprehensions for Clarity:
# Preferred for readability
users
|> Enum.filter(&(&1.active))
|> Enum.map(&(&1.name))
# List comprehension (use when more concise)
for user <- users, user.active, do: user.name
String Concatenation:
# Good - interpolation
"Hello, #{user.name}!"
# Avoid - concatenation with <>
"Hello, " <> user.name <> "!"
3. Tooling Guidelines
3.1 Elixir & Erlang/OTP
Version Management with asdf:
Always use the versions specified in .tool-versions:
# Install correct versions
asdf install
# Verify versions
elixir --version # Should show 1.18.3
erl -version # Should show 27.3.4
OTP Application Design:
defmodule Mv.Application do
use Application
def start(_type, _args) do
children = [
# Start the database repository
Mv.Repo,
# Start the Telemetry supervisor
MvWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Mv.PubSub},
# Start the Endpoint
MvWeb.Endpoint
]
opts = [strategy: :one_for_one, name: Mv.Supervisor]
Supervisor.start_link(children, opts)
end
end
3.2 Phoenix Framework
Context-Based Organization:
- Use contexts to define API boundaries
- Keep controllers thin - delegate to contexts or Ash actions
- Avoid direct Repo/Ecto calls in controllers
# Good - thin controller
defmodule MvWeb.MemberController do
use MvWeb, :controller
def create(conn, %{"member" => member_params}) do
case Mv.Membership.create_member(member_params) do
{:ok, member} ->
conn
|> put_flash(:info, "Member created successfully.")
|> redirect(to: ~p"/members/#{member}")
{:error, error} ->
conn
|> put_flash(:error, "Failed to create member.")
|> render(:new, error: error)
end
end
end
Phoenix LiveView Best Practices:
defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
# Use mount for initial setup
def mount(_params, _session, socket) do
{:ok, assign(socket, members: [], loading: true)}
end
# Use handle_params for URL parameter handling
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
# Use handle_event for user interactions
def handle_event("delete", %{"id" => id}, socket) do
# Handle deletion
{:noreply, socket}
end
# Use handle_info for asynchronous messages
def handle_info({:member_updated, member}, socket) do
{:noreply, update_member_in_list(socket, member)}
end
end
Component Design:
# Function components for stateless UI elements
def button(assigns) do
~H"""
<button class="btn btn-primary" {@rest}>
<%= render_slot(@inner_block) %>
</button>
"""
end
# Use attrs and slots for documentation
attr :id, :string, required: true
attr :title, :string, default: nil
slot :inner_block, required: true
def card(assigns) do
~H"""
<div id={@id} class="card">
<h2 :if={@title}><%= @title %></h2>
<%= render_slot(@inner_block) %>
</div>
"""
end
3.3 Ash Framework
Resource Definition Best Practices:
defmodule Mv.Membership.Member do
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
# Follow section order from Spark formatter config
postgres do
table "members"
repo Mv.Repo
end
attributes do
uuid_primary_key :id
attribute :first_name, :string do
allow_nil? false
public? true
end
attribute :email, :string do
allow_nil? false
public? true
end
timestamps()
end
actions do
# Define specific actions instead of using defaults.accept_all
create :create_member do
accept [:first_name, :last_name, :email]
change fn changeset, _context ->
# Custom validation or transformation
changeset
end
end
read :read do
primary? true
end
update :update_member do
accept [:first_name, :last_name, :email]
end
destroy :destroy
end
code_interface do
define :create_member
define :list_members, action: :read
define :update_member
define :destroy_member, action: :destroy
end
identities do
identity :unique_email, [:email]
end
end
Ash Policies:
policies do
# Admin can do everything
policy action_type([:read, :create, :update, :destroy]) do
authorize_if actor_attribute_equals(:role, :admin)
end
# Users can only read and update their own data
policy action_type([:read, :update]) do
authorize_if relates_to_actor_via(:user)
end
end
Ash Validations:
validations do
validate present(:email), on: [:create, :update]
validate match(:email, ~r/@/), message: "must be a valid email"
validate string_length(:first_name, min: 2, max: 100)
end
3.4 AshPostgres & Ecto
Migrations with Ash:
# Generate migration for all changes
mix ash.codegen --name add_members_table
# Apply migrations
mix ash.setup
Repository Configuration:
defmodule Mv.Repo do
use AshPostgres.Repo,
otp_app: :mv
# Install PostgreSQL extensions
def installed_extensions do
["citext", "uuid-ossp"]
end
end
Avoid N+1 Queries:
# Good - preload relationships
members =
Member
|> Ash.Query.load(:properties)
|> Mv.Membership.list_members!()
# Avoid - causes N+1 queries
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
# This triggers a query for each member
Ash.load!(member, :properties)
end)
3.5 Authentication (AshAuthentication)
Resource with Authentication:
defmodule Mv.Accounts.User do
use Ash.Resource,
domain: Mv.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication]
authentication do
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
end
oauth2 :rauthy do
client_id fn _, _ ->
Application.fetch_env!(:mv, :rauthy)[:client_id]
end
# ... other config
end
end
end
end
3.6 Frontend: Tailwind CSS & DaisyUI
Utility-First Approach:
<!-- Good - use utility classes -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Member Name</h2>
<p class="text-sm text-gray-600">Email: member@example.com</p>
<div class="card-actions justify-end">
<button class="btn btn-primary">Edit</button>
</div>
</div>
</div>
<!-- Avoid - custom CSS for standard patterns -->
<div class="custom-member-card">
<h2 class="custom-title">Member Name</h2>
<!-- ... -->
</div>
Responsive Design:
<!-- Use Tailwind's responsive prefixes -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<%= for member <- @members do %>
<.member_card member={member} />
<% end %>
</div>
DaisyUI Components:
<!-- Leverage DaisyUI component classes -->
<div class="navbar bg-base-100">
<div class="navbar-start">
<a class="btn btn-ghost text-xl">Mila</a>
</div>
<div class="navbar-end">
<.link navigate={~p"/members"} class="btn btn-primary">
Members
</.link>
</div>
</div>
Custom Tailwind Configuration:
Update assets/tailwind.config.js for custom needs:
module.exports = {
content: [
"../lib/mv_web.ex",
"../lib/mv_web/**/*.*ex"
],
theme: {
extend: {
colors: {
brand: "#FD4F00",
}
},
},
plugins: [
require("@tailwindcss/forms"),
// DaisyUI loaded from vendor
]
}
3.7 JavaScript & esbuild
Minimal JavaScript Philosophy:
Phoenix LiveView handles most interactivity. Use JavaScript only when necessary:
// assets/js/app.js
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
// Show progress bar on live navigation
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
liveSocket.connect()
Custom Hooks (when needed):
let Hooks = {}
Hooks.DatePicker = {
mounted() {
// Initialize date picker
this.el.addEventListener("change", (e) => {
this.pushEvent("date_selected", {date: e.target.value})
})
}
}
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
3.8 Code Quality: Credo
Run Credo Regularly:
# Check code quality
mix credo
# Strict mode for CI
mix credo --strict
Key Credo Checks Enabled:
- Consistency checks (spacing, line endings, parameter patterns)
- Design checks (FIXME/TODO tags, alias usage)
- Readability checks (max line length: 120, module/function names, module documentation)
- Refactoring opportunities (cyclomatic complexity, nesting)
- Warnings (unused operations, unsafe operations)
Documentation Enforcement:
- ✅
Credo.Check.Readability.ModuleDoc- ENABLED (as of November 2025) - All modules require
@moduledocdocumentation - Current coverage: 51 @moduledoc declarations across 47 modules (100% core modules)
- CI pipeline enforces documentation standards
Address Credo Issues:
# Before
def complex_function(user, data, opts) do
if user.admin? do
if data.valid? do
if opts[:force] do
# deeply nested logic
end
end
end
end
# After - flatten with guard clauses
def complex_function(user, _data, _opts) when not user.admin?,
do: {:error, :unauthorized}
def complex_function(_user, data, _opts) when not data.valid?,
do: {:error, :invalid_data}
def complex_function(_user, data, opts) do
if opts[:force] do
process_data(data)
else
validate_and_process(data)
end
end
3.9 Security: Sobelow
Run Security Analysis:
# Security audit
mix sobelow --config
# With verbose output
mix sobelow --config --verbose
Security Best Practices:
- Never commit secrets to version control
- Use environment variables for sensitive configuration
- Validate and sanitize all user inputs
- Use parameterized queries (Ecto handles this)
- Keep dependencies updated
3.10 Dependency Auditing & Updates
Regular Security Audits:
# Audit dependencies for security vulnerabilities
mix deps.audit
# Audit hex packages
mix hex.audit
# Security scan with Sobelow
mix sobelow --config
Update Dependencies:
# Update all dependencies
mix deps.update --all
# Update specific dependency
mix deps.update phoenix
# Check for outdated packages
mix hex.outdated
3.11 Email: Swoosh
Mailer Configuration:
defmodule Mv.Mailer do
use Swoosh.Mailer, otp_app: :mv
end
Sending Emails:
defmodule Mv.Accounts.WelcomeEmail do
use Phoenix.Swoosh, template_root: "lib/mv_web/templates"
import Swoosh.Email
def send(user) do
new()
|> to({user.name, user.email})
|> from({"Mila", "noreply@mila.example.com"})
|> subject("Welcome to Mila!")
|> render_body("welcome.html", %{user: user})
|> Mv.Mailer.deliver()
end
end
3.12 Internationalization: Gettext
Define Translations:
# In LiveView or controller
gettext("Welcome to Mila")
# With interpolation
gettext("Hello, %{name}!", name: user.name)
# Domain-specific translations
dgettext("auth", "Sign in with email")
Extract and Merge:
# Extract new translatable strings
mix gettext.extract
# Merge into existing translations
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
3.13 Task Runner: Just
Common Commands:
# Start development environment
just run
# Run tests
just test
# Run linter
just lint
# Run security audit
just audit
# Reset database
just reset-database
# Format code
just format
# Regenerate migrations
just regen-migrations migration_name
Define Custom Tasks:
Edit Justfile for project-specific tasks:
# Example custom task
setup-dev: install-dependencies start-database migrate-database
mix phx.gen.secret
@echo "Development environment ready!"
4. Testing Standards
4.1 Test Setup and Organization
Test Directory Structure:
Mirror the lib/ directory structure in test/:
test/
├── accounts/ # Tests for Accounts domain
│ ├── user_test.exs
│ ├── email_sync_test.exs
│ └── ...
├── membership/ # Tests for Membership domain
│ ├── member_test.exs
│ └── ...
├── mv_web/ # Tests for Web layer
│ ├── controllers/
│ ├── live/
│ └── components/
└── support/ # Test helpers
├── conn_case.ex # Controller test setup
└── data_case.ex # Database test setup
Test File Naming:
- Use
_test.exssuffix for all test files - Match the module name:
user.ex→user_test.exs - Use descriptive names for integration tests:
user_member_relationship_test.exs
4.2 ExUnit Basics
Test Module Structure:
defmodule Mv.Membership.MemberTest do
use Mv.DataCase, async: true # async: true for parallel execution
alias Mv.Membership.Member
describe "create_member/1" do
test "creates a member with valid attributes" do
attrs = %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
assert {:ok, %Member{} = member} = Mv.Membership.create_member(attrs)
assert member.first_name == "John"
assert member.email == "john@example.com"
end
test "returns error with invalid attributes" do
attrs = %{first_name: nil}
assert {:error, _error} = Mv.Membership.create_member(attrs)
end
end
describe "list_members/0" do
setup do
# Setup code for this describe block
{:ok, member: create_test_member()}
end
test "returns all members", %{member: member} do
members = Mv.Membership.list_members()
assert length(members) == 1
assert List.first(members).id == member.id
end
end
end
4.3 Test Types
4.3.1 Unit Tests
Test individual functions and modules in isolation:
defmodule Mv.Membership.EmailTest do
use ExUnit.Case, async: true
alias Mv.Membership.Email
describe "valid?/1" do
test "returns true for valid email" do
assert Email.valid?("user@example.com")
end
test "returns false for invalid email" do
refute Email.valid?("invalid-email")
refute Email.valid?("missing-at-sign.com")
end
end
end
4.3.2 Integration Tests
Test interactions between multiple modules or systems:
defmodule Mv.Accounts.UserMemberRelationshipTest do
use Mv.DataCase, async: true
alias Mv.Accounts.User
alias Mv.Membership.Member
describe "user-member relationship" do
test "creating a user automatically creates a member" do
attrs = %{
email: "test@example.com",
password: "SecurePassword123"
}
assert {:ok, user} = Mv.Accounts.create_user(attrs)
assert {:ok, member} = Mv.Membership.get_member_by_user_id(user.id)
assert member.email == user.email
end
test "deleting a user cascades to member" do
{:ok, user} = create_user()
{:ok, member} = Mv.Membership.get_member_by_user_id(user.id)
assert :ok = Mv.Accounts.destroy_user(user)
assert {:error, :not_found} = Mv.Membership.get_member(member.id)
end
end
end
4.3.3 Controller Tests
Test HTTP endpoints:
defmodule MvWeb.PageControllerTest do
use MvWeb.ConnCase, async: true
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Welcome to Mila"
end
end
4.3.4 LiveView Tests
Test LiveView interactions:
defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup do
member = create_test_member()
%{member: member}
end
test "displays list of members", %{conn: conn, member: member} do
{:ok, view, html} = live(conn, ~p"/members")
assert html =~ "Members"
assert html =~ member.first_name
end
test "deletes member", %{conn: conn, member: member} do
{:ok, view, _html} = live(conn, ~p"/members")
assert view
|> element("#member-#{member.id} a", "Delete")
|> render_click()
refute has_element?(view, "#member-#{member.id}")
end
end
4.3.5 Component Tests
Test function components:
defmodule MvWeb.Components.SearchBarComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import MvWeb.Components.SearchBarComponent
test "renders search input" do
assigns = %{search_query: "", id: "search"}
html =
render_component(&search_bar/1, assigns)
assert html =~ "input"
assert html =~ ~s(type="search")
end
end
4.4 Test Helpers and Fixtures
Create Test Helpers:
# test/support/fixtures.ex
defmodule Mv.Fixtures do
def member_fixture(attrs \\ %{}) do
default_attrs = %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer()}@example.com"
}
{:ok, member} =
default_attrs
|> Map.merge(attrs)
|> Mv.Membership.create_member()
member
end
end
Use Setup Blocks:
describe "with authenticated user" do
setup %{conn: conn} do
user = create_user()
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "can access protected page", %{conn: conn} do
conn = get(conn, ~p"/profile")
assert html_response(conn, 200) =~ "Profile"
end
end
4.5 Database Testing with Sandbox
Use Ecto Sandbox for Isolation:
# test/test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)
# test/support/data_case.ex
defmodule Mv.DataCase do
use ExUnit.CaseTemplate
using do
quote do
import Ecto
import Ecto.Changeset
import Ecto.Query
import Mv.DataCase
alias Mv.Repo
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
end
4.6 Test Coverage
Run Tests with Coverage:
# Run tests
mix test
# Run with coverage
mix test --cover
# Run specific test file
mix test test/membership/member_test.exs
# Run specific test line
mix test test/membership/member_test.exs:42
Coverage Goals:
- Aim for >80% overall coverage
- 100% coverage for critical business logic
- Focus on meaningful tests, not just coverage numbers
4.7 Testing Best Practices
Descriptive Test Names:
# Good - describes what is being tested
test "creates a member with valid email address"
test "returns error when email is already taken"
test "sends welcome email after successful registration"
# Avoid - vague or generic
test "member creation"
test "error case"
test "test 1"
Arrange-Act-Assert Pattern:
test "updates member email" do
# Arrange - set up test data
member = member_fixture()
new_email = "new@example.com"
# Act - perform the action
{:ok, updated_member} = Mv.Membership.update_member(member, %{email: new_email})
# Assert - verify results
assert updated_member.email == new_email
end
Test One Thing Per Test:
# Good - focused test
test "validates email format" do
attrs = %{email: "invalid-email"}
assert {:error, _} = Mv.Membership.create_member(attrs)
end
test "requires email to be present" do
attrs = %{email: nil}
assert {:error, _} = Mv.Membership.create_member(attrs)
end
# Avoid - testing multiple things
test "validates email" do
# Tests both format and presence
assert {:error, _} = Mv.Membership.create_member(%{email: nil})
assert {:error, _} = Mv.Membership.create_member(%{email: "invalid"})
end
Use describe Blocks for Organization:
describe "create_member/1" do
test "success case" do
# ...
end
test "error case" do
# ...
end
end
describe "update_member/2" do
test "success case" do
# ...
end
end
Avoid Testing Implementation Details:
# Good - test behavior
test "member can be created with valid attributes" do
attrs = valid_member_attrs()
assert {:ok, %Member{}} = Mv.Membership.create_member(attrs)
end
# Avoid - testing internal implementation
test "create_member calls Ash.create with correct params" do
# This is too coupled to implementation
end
Keep Tests Fast:
- Use
async: truewhen possible - Avoid unnecessary database interactions
- Mock external services
- Use fixtures efficiently
5. Security Guidelines
5.1 Authentication & Authorization
Use AshAuthentication:
# Authentication is configured at the resource level
authentication do
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
end
oauth2 :rauthy do
# OIDC configuration
end
end
end
Implement Authorization Policies:
policies do
# Default deny
policy action_type(:*) do
authorize_if always()
end
# Specific permissions
policy action_type([:read, :update]) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if actor_attribute_equals(:role, :admin)
end
end
5.2 Password Security
Use bcrypt for Password Hashing:
# Configured in AshAuthentication resource
password :password do
identity_field :email
hashed_password_field :hashed_password
hash_provider AshAuthentication.BcryptProvider
confirmation_required? true
end
Password Requirements:
- Minimum 12 characters
- Mix of uppercase, lowercase, numbers (enforced by validation)
- Use
bcrypt_elixirfor hashing (never store plain text passwords)
5.3 Input Validation & Sanitization
Validate All User Input:
attributes do
attribute :email, :string do
allow_nil? false
public? true
end
end
validations do
validate present(:email)
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
end
SQL Injection Prevention:
Ecto and Ash handle parameterized queries automatically:
# Safe - parameterized query
Ash.Query.filter(Member, email == ^user_email)
# Avoid raw SQL when possible
Ecto.Adapters.SQL.query(Mv.Repo, "SELECT * FROM members WHERE email = $1", [user_email])
5.4 CSRF Protection
Phoenix Handles CSRF Automatically:
<!-- CSRF token automatically included in forms -->
<.form for={@form} phx-submit="save">
<!-- form fields -->
</.form>
Configure in Endpoint:
# lib/mv_web/endpoint.ex
plug Plug.Session,
store: :cookie,
key: "_mv_key",
signing_salt: "secret"
plug :protect_from_forgery
5.5 Secrets Management
Never Commit Secrets:
# .gitignore should include:
.env
.env.*
!.env.example
Use Environment Variables:
# config/runtime.exs
config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
base_url: System.get_env("OIDC_BASE_URL")
Generate Secure Secrets:
# Generate secret key base
mix phx.gen.secret
# Generate token signing secret
mix phx.gen.secret
5.6 Security Headers
Configure Security Headers:
# lib/mv_web/endpoint.ex
plug Plug.Static,
at: "/",
from: :mv,
gzip: false,
only: MvWeb.static_paths(),
headers: %{
"x-content-type-options" => "nosniff",
"x-frame-options" => "SAMEORIGIN",
"x-xss-protection" => "1; mode=block"
}
5.7 Dependency Security
- Use Renovate for automated dependency updates
- Review changelogs before updating dependencies
- Test thoroughly after updates
- Run regular audits (see section 3.10 for audit commands)
5.8 Logging & Monitoring
Sanitize Logs:
# Don't log sensitive information
Logger.info("User login attempt", user_id: user.id)
# Avoid
Logger.info("User login attempt", user: inspect(user)) # May contain password
Configure Logger:
# config/config.exs
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id, :user_id]
6. Performance Best Practices
6.1 Database Performance
Indexing:
postgres do
table "members"
repo Mv.Repo
# Add indexes for frequently queried fields
index [:email], unique: true
index [:last_name]
index [:created_at]
end
Avoid N+1 Queries:
# Good - preload relationships
members =
Member
|> Ash.Query.load([:properties, :user])
|> Mv.Membership.list_members!()
# Avoid - causes N+1
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
properties = Ash.load!(member, :properties) # N queries!
end)
Pagination:
# Use keyset pagination (configured as default in Ash)
Ash.Query.page(Member, offset: 0, limit: 50)
Batch Operations:
# Use bulk operations for multiple records
Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create)
6.2 LiveView Performance
Optimize Assigns:
# Good - only assign what's needed
def mount(_params, _session, socket) do
{:ok, assign(socket, members_count: get_count())}
end
# Avoid - assigning large collections unnecessarily
def mount(_params, _session, socket) do
{:ok, assign(socket, all_members: list_all_members())} # Heavy!
end
Use Temporary Assigns:
# For data that's only needed for rendering
def handle_event("load_report", _, socket) do
report_data = generate_large_report()
{:noreply, assign(socket, report: report_data) |> assign(:report, temporary_assigns: [:report])}
end
Stream Collections:
# For large collections
def mount(_params, _session, socket) do
{:ok, stream(socket, :members, list_members())}
end
def render(assigns) do
~H"""
<div id="members" phx-update="stream">
<div :for={{id, member} <- @streams.members} id={id}>
<%= member.name %>
</div>
</div>
"""
end
6.3 Caching Strategies
Function-Level Caching:
defmodule Mv.Cache do
use GenServer
def get_or_compute(key, compute_fn) do
case get(key) do
nil ->
value = compute_fn.()
put(key, value)
value
value ->
value
end
end
end
ETS for In-Memory Cache:
# In application.ex
:ets.new(:mv_cache, [:named_table, :public, read_concurrency: true])
# Usage
:ets.insert(:mv_cache, {"key", "value"})
:ets.lookup(:mv_cache, "key")
6.4 Async Processing
Background Jobs (Future):
# When Oban is added
defmodule Mv.Workers.EmailWorker do
use Oban.Worker
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
user = Mv.Accounts.get_user!(user_id)
Mv.Mailer.send_welcome_email(user)
:ok
end
end
# Enqueue job
%{user_id: user.id}
|> Mv.Workers.EmailWorker.new()
|> Oban.insert()
Task Async for Concurrent Operations:
def gather_dashboard_data do
tasks = [
Task.async(fn -> get_member_count() end),
Task.async(fn -> get_recent_registrations() end),
Task.async(fn -> get_payment_status() end)
]
[member_count, recent, payments] = Task.await_many(tasks)
%{
member_count: member_count,
recent: recent,
payments: payments
}
end
6.5 Profiling & Monitoring
Use :observer:
# In iex session
:observer.start()
Use Telemetry:
# Attach telemetry handlers
:telemetry.attach(
"query-duration",
[:mv, :repo, :query],
fn event, measurements, metadata, _config ->
Logger.info("Query took #{measurements.total_time}ms")
end,
nil
)
7. Documentation Standards
7.1 Module Documentation
Use @moduledoc:
defmodule Mv.Membership.Member do
@moduledoc """
Represents a club member with their personal information and membership status.
Members can have custom properties defined by the club administrators.
Each member is optionally linked to a user account for self-service access.
## Examples
iex> Mv.Membership.create_member(%{first_name: "John", last_name: "Doe", email: "john@example.com"})
{:ok, %Mv.Membership.Member{}}
"""
end
Module Documentation Should Include:
- Purpose of the module
- Key responsibilities
- Usage examples
- Related modules
7.2 Function Documentation
Use @doc:
@doc """
Creates a new member with the given attributes.
## Parameters
- `attrs` - A map of member attributes including:
- `:first_name` (required) - The member's first name
- `:last_name` (required) - The member's last name
- `:email` (required) - The member's email address
## Returns
- `{:ok, member}` - Successfully created member
- `{:error, error}` - Validation or creation error
## Examples
iex> Mv.Membership.create_member(%{
...> first_name: "Jane",
...> last_name: "Smith",
...> email: "jane@example.com"
...> })
{:ok, %Mv.Membership.Member{first_name: "Jane"}}
iex> Mv.Membership.create_member(%{first_name: nil})
{:error, %Ash.Error.Invalid{}}
"""
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
def create_member(attrs) do
# Implementation
end
7.3 Type Specifications
Use @spec for Function Signatures:
@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()}
def create_member(attrs)
@spec list_members(keyword()) :: [Member.t()]
def list_members(opts \\ [])
@spec get_member!(String.t()) :: Member.t() | no_return()
def get_member!(id)
7.4 Code Comments
When to Comment:
# Good - explain WHY, not WHAT
def calculate_dues(member) do
# Annual dues are prorated based on join date to be fair to mid-year joiners
months_active = calculate_months_active(member)
@annual_dues * (months_active / 12)
end
# Avoid - stating the obvious
def calculate_dues(member) do
# Calculate the dues
months_active = calculate_months_active(member) # Get months active
@annual_dues * (months_active / 12) # Multiply annual dues by fraction
end
Complex Logic:
def sync_member_email(member, user) do
# Email synchronization priority:
# 1. User email is the source of truth (authenticated account)
# 2. Member email is updated to match user email
# 3. Preserve member email history for audit purposes
if member.email != user.email do
# Archive old email before updating
archive_member_email(member, member.email)
update_member(member, %{email: user.email})
end
end
7.5 README and Project Documentation
Keep README.md Updated:
- Installation instructions
- Development setup
- Running tests
- Deployment guide
- Contributing guidelines
Additional Documentation:
docs/directory for detailed guides- Architecture decision records (ADRs)
- API documentation (generated with ExDoc)
Generate Documentation:
# Generate HTML documentation
mix docs
# View documentation
open doc/index.html
7.6 Changelog
Maintain CHANGELOG.md:
# Changelog
## [Unreleased]
### Added
- Member custom properties feature
- Email synchronization between user and member
### Changed
- Updated Phoenix to 1.8.0
### Fixed
- Email uniqueness validation bug
## [0.1.0] - 2025-01-15
### Added
- Initial release
- Basic member management
- OIDC authentication
8. Git Workflow
8.1 Branching Strategy
Main Branches:
main- Production-ready code
Feature Branches:
# Create feature branch
git checkout -b feature/member-custom-properties
# Work on feature
git add .
git commit -m "Add custom properties to members"
# Push to remote
git push origin feature/member-custom-properties
8.2 Commit Messages
Format:
<type>: <subject>
<body>
<footer>
Types:
feat:New featurefix:Bug fixdocs:Documentation changesstyle:Code style changes (formatting)refactor:Code refactoringtest:Adding or updating testschore:Maintenance tasks
Examples:
feat: add email synchronization for members
Implement automatic email sync between user accounts and member records.
When a user updates their email, the associated member record is updated.
Closes #123
fix: resolve N+1 query in member list
Preload properties relationship when loading members to avoid N+1 queries.
Performance improvement: reduced query count from 100+ to 2.
8.3 Code Reviews
Before Creating PR:
- Run
mix format - Run
mix credo - Run
mix test - Run
mix sobelow --config - Update documentation if needed
PR Description Should Include:
- What changed
- Why it changed
- How to test it
- Screenshots (for UI changes)
- Related issues
9. Deployment
9.1 Environment Configuration
Production Checklist:
- Set
SECRET_KEY_BASE(usemix phx.gen.secret) - Set
TOKEN_SIGNING_SECRET - Configure
DATABASE_URL - Set
PHX_HOST - Configure OIDC credentials
- Set up SMTP for email
- Enable SSL/TLS
- Configure monitoring
9.2 Database Migrations
# In production Docker container
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
9.3 Building Docker Image
# Build production image
docker build -t mitgliederverwaltung .
# Or use Just
just build-docker-container
9.4 Health Checks
Implement Health Check Endpoint:
# lib/mv_web/controllers/health_controller.ex
defmodule MvWeb.HealthController do
use MvWeb, :controller
def index(conn, _params) do
# Check database connectivity
case Ecto.Adapters.SQL.query(Mv.Repo, "SELECT 1", []) do
{:ok, _} -> json(conn, %{status: "healthy"})
{:error, _} -> conn |> put_status(503) |> json(%{status: "unhealthy"})
end
end
end
10. Additional Resources
10.1 Official Documentation
- Elixir: https://elixir-lang.org/docs.html
- Phoenix: https://hexdocs.pm/phoenix/
- Ash Framework: https://hexdocs.pm/ash/
- LiveView: https://hexdocs.pm/phoenix_live_view/
- Ecto: https://hexdocs.pm/ecto/
- Tailwind CSS: https://tailwindcss.com/docs
- DaisyUI: https://daisyui.com/
10.2 Books & Guides
- Programming Elixir by Dave Thomas
- Phoenix in Action by Geoffrey Lessel
- Testing Elixir by Andrea Leopardi & Jeffrey Matthias
- Designing Elixir Systems with OTP by James Edward Gray II
10.3 Community
- Elixir Forum: https://elixirforum.com/
- Ash Framework Discord
- Phoenix Framework Slack
8. Accessibility Guidelines
Building accessible applications ensures that all users, including those with disabilities, can use our application effectively. The following guidelines follow WCAG 2.1 Level AA standards.
8.1 Semantic HTML
Use Semantic Elements:
<!-- Good - semantic HTML -->
<nav class="navbar">
<a href="/">Home</a>
</nav>
<main>
<article>
<h1>Member Details</h1>
<section>
<h2>Contact Information</h2>
<p>Email: <%= @member.email %></p>
</section>
</article>
</main>
<!-- Avoid - non-semantic divs -->
<div class="navigation">
<div class="link">Home</div>
</div>
8.2 ARIA Labels and Roles
Use ARIA Attributes When Necessary:
<!-- Icon-only buttons need labels -->
<button aria-label={gettext("Delete member")} phx-click="delete">
<.icon name="hero-trash" />
</button>
<!-- Loading states -->
<div role="status" aria-live="polite" aria-busy={@loading}>
<%= if @loading do %>
<span aria-label={gettext("Loading...")}>
<.icon name="hero-loading" class="animate-spin" />
</span>
<% end %>
</div>
<!-- Navigation landmarks -->
<nav aria-label={gettext("Main navigation")}>
<!-- navigation items -->
</nav>
8.3 Keyboard Navigation
All Interactive Elements Must Be Keyboard Accessible:
<!-- Good - keyboard accessible -->
<.link navigate={~p"/members"} class="btn">
Members
</.link>
<!-- Good - custom keyboard handler -->
<div
tabindex="0"
role="button"
phx-click="toggle"
phx-keydown="toggle"
phx-key="Enter"
aria-pressed={@expanded}>
Toggle
</div>
<!-- Avoid - div without keyboard support -->
<div phx-click="action">Click me</div>
Tab Order:
- Ensure logical tab order matches visual order
- Use
tabindex="0"for custom interactive elements - Use
tabindex="-1"to programmatically focus (not in tab order) - Never use positive
tabindexvalues
8.4 Color and Contrast
Ensure Sufficient Contrast:
# Tailwind classes with sufficient contrast (4.5:1 minimum)
# Good
<p class="text-gray-900 bg-white">High contrast text</p>
<p class="text-white bg-gray-900">Inverted high contrast</p>
# Avoid - insufficient contrast
<p class="text-gray-400 bg-gray-300">Low contrast text</p>
Don't Rely Solely on Color:
<!-- Good - color + icon + text -->
<div class="alert alert-error">
<.icon name="hero-exclamation-circle" />
<span><%= gettext("Error: Email is required") %></span>
</div>
<!-- Avoid - color only -->
<div class="text-red-500">
<%= gettext("Email is required") %>
</div>
8.5 Form Accessibility
Label All Form Fields:
<!-- Good - explicit labels -->
<.input
field={@form[:email]}
type="email"
label={gettext("Email Address")}
required
/>
<!-- Good - with helper text -->
<.input
field={@form[:password]}
type="password"
label={gettext("Password")}
help={gettext("Must be at least 12 characters")}
required
/>
Error Messages:
<!-- Accessible error messages -->
<.input
field={@form[:email]}
type="email"
label={gettext("Email")}
errors={@errors[:email]}
aria-describedby={@errors[:email] && "email-error"}
/>
<span :if={@errors[:email]} id="email-error" role="alert">
<%= @errors[:email] %>
</span>
Required Fields:
<!-- Mark required fields -->
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required
aria-required="true"
/>
8.6 Focus Management
Manage Focus in LiveView:
def handle_event("save", params, socket) do
case save_member(params) do
{:ok, member} ->
socket
|> put_flash(:info, gettext("Member saved successfully"))
|> push_navigate(to: ~p"/members/#{member}")
# Focus will move to the new page
{:error, changeset} ->
socket
|> assign(:form, to_form(changeset))
# Keep focus context
end
end
Skip Links:
<!-- Add skip link for keyboard users -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-white">
<%= gettext("Skip to main content") %>
</a>
<main id="main-content">
<!-- main content -->
</main>
8.7 Images and Media
Alt Text for Images:
<!-- Good - descriptive alt text -->
<img src="/images/member-photo.jpg" alt={gettext("Photo of %{name}", name: @member.name)} />
<!-- Decorative images -->
<img src="/images/decoration.svg" alt="" role="presentation" />
Icons:
<!-- Icons with meaning need labels -->
<.icon name="hero-check-circle" aria-label={gettext("Success")} />
<!-- Decorative icons -->
<.icon name="hero-sparkles" aria-hidden="true" />
8.8 Tables
Accessible Data Tables:
<table>
<caption><%= gettext("List of members") %></caption>
<thead>
<tr>
<th scope="col"><%= gettext("Name") %></th>
<th scope="col"><%= gettext("Email") %></th>
<th scope="col"><%= gettext("Actions") %></th>
</tr>
</thead>
<tbody>
<tr :for={member <- @members}>
<td><%= member.name %></td>
<td><%= member.email %></td>
<td>
<.link navigate={~p"/members/#{member}"} aria-label={gettext("View %{name}", name: member.name)}>
<%= gettext("View") %>
</.link>
</td>
</tr>
</tbody>
</table>
8.9 Live Regions
Announce Dynamic Content:
<!-- Search results announcement -->
<div role="status" aria-live="polite" aria-atomic="true">
<%= if @searched do %>
<span class="sr-only">
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
</span>
<% end %>
</div>
<!-- Status messages -->
<div role="alert" aria-live="assertive">
<%= if @error do %>
<%= @error %>
<% end %>
</div>
8.10 Testing Accessibility
Tools and Practices:
# Browser DevTools
# - Chrome: Lighthouse Accessibility Audit
# - Firefox: Accessibility Inspector
# Automated testing (future)
# - pa11y
# - axe-core
Manual Testing:
- Keyboard Navigation: Navigate entire application using only keyboard
- Screen Reader: Test with NVDA (Windows) or VoiceOver (Mac)
- Zoom: Test at 200% zoom level
- Color Blindness: Use browser extensions to simulate
Checklist:
- All images have alt text (or alt="" for decorative)
- All form inputs have labels
- All interactive elements are keyboard accessible
- Color contrast meets WCAG AA (4.5:1 for normal text)
- Focus indicators are visible
- Headings follow logical hierarchy (h1, h2, h3...)
- Error messages are announced to screen readers
- Skip links are available
- Tables have proper structure (th, scope, caption)
- ARIA labels used for icon-only buttons
8.11 DaisyUI Accessibility
DaisyUI components are designed with accessibility in mind, but ensure:
<!-- Modal accessibility -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
<div class="modal-box">
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
<p><%= gettext("Are you sure?") %></p>
<div class="modal-action">
<button class="btn" onclick="document.getElementById('my-modal').close()">
<%= gettext("Cancel") %>
</button>
<button class="btn btn-error" phx-click="confirm-delete">
<%= gettext("Delete") %>
</button>
</div>
</div>
</dialog>
Conclusion
These guidelines are a living document and should evolve with our project and team. When in doubt, prioritize:
- Clarity over cleverness - Write code that's easy to understand
- Consistency over perfection - Follow established patterns
- Testing over hoping - Write tests for confidence
- Documentation over memory - Don't rely on tribal knowledge
- Communication over assumption - Ask questions, discuss trade-offs
- Accessibility for all - Build inclusive applications
Happy coding! 🚀