# 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
1. [Setup and Architectural Conventions](#1-setup-and-architectural-conventions)
2. [Coding Standards and Style](#2-coding-standards-and-style)
3. [Tooling Guidelines](#3-tooling-guidelines)
4. [Testing Standards](#4-testing-standards)
5. [Security Guidelines](#5-security-guidelines)
6. [Performance Best Practices](#6-performance-best-practices)
7. [Documentation Standards](#7-documentation-standards)
8. [Accessibility Guidelines](#8-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
│ ├── custom_field_value.ex # Custom field value resource
│ ├── custom_field.ex # CustomFieldValue 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
│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews
│ │ ├── custom_field_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 `PascalCase` with full namespace (e.g., `Mv.Accounts.User`)
- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership`
- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`)
- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`)
**Module Structure:**
```elixir
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:
```elixir
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.exs` for all dependencies:** Define versions explicitly
- **Keep dependencies up to date:** Use Renovate for automated updates
- **Version management:** Use `asdf` with `.tool-versions` for 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:**
```bash
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_case` for functions/variables, `PascalCase` for modules
- **Phoenix:** Controllers end with `Controller`, LiveViews end with `Live`
- **Ash:** Resources are singular nouns, actions are verb-first (`:create_member`)
- **Files:** Match module names in `snake_case` (`user_controller.ex` for `UserController`)
### 2.2 Function Design
**Verb-First Function Names:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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:
```elixir
# 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:**
```elixir
# 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
```elixir
# 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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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`:
```bash
# Install correct versions
asdf install
# Verify versions
elixir --version # Should show 1.18.3
erl -version # Should show 27.3.4
```
**OTP Application Design:**
```elixir
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
```elixir
# 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:**
```elixir
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:**
```elixir
# Function components for stateless UI elements
def button(assigns) do
~H"""
"""
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"""
<%= @title %>
<%= render_slot(@inner_block) %>
"""
end
```
### 3.3 Ash Framework
**Resource Definition Best Practices:**
```elixir
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:**
```elixir
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:**
```elixir
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:**
```bash
# Generate migration for all changes
mix ash.codegen --name add_members_table
# Apply migrations
mix ash.setup
```
**Repository Configuration:**
```elixir
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:**
```elixir
# Good - preload relationships
members =
Member
|> Ash.Query.load(:custom_field_values)
|> 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, :custom_field_values)
end)
```
### 3.5 Authentication (AshAuthentication)
**Resource with Authentication:**
```elixir
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:**
```heex
Member Name
Email: member@example.com
Member Name
```
**Responsive Design:**
```heex
<%= for member <- @members do %>
<.member_card member={member} />
<% end %>
<.link navigate={~p"/members"} class="btn btn-primary">
Members
```
**Custom Tailwind Configuration:**
Update `assets/tailwind.config.js` for custom needs:
```javascript
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:
```javascript
// 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):**
```javascript
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:**
```bash
# 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 `@moduledoc` documentation
- Current coverage: 51 @moduledoc declarations across 47 modules (100% core modules)
- CI pipeline enforces documentation standards
**Address Credo Issues:**
```elixir
# 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:**
```bash
# 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:**
```bash
# Audit dependencies for security vulnerabilities
mix deps.audit
# Audit hex packages
mix hex.audit
# Security scan with Sobelow
mix sobelow --config
```
**Update Dependencies:**
```bash
# 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:**
```elixir
defmodule Mv.Mailer do
use Swoosh.Mailer, otp_app: :mv
end
```
**Sending Emails:**
```elixir
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:**
```elixir
# 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:**
```bash
# 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:**
```bash
# 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:
```makefile
# 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.exs` suffix 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:**
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# test/test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual)
```
```elixir
# 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:**
```bash
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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: true` when possible
- Avoid unnecessary database interactions
- Mock external services
- Use fixtures efficiently
---
## 5. Security Guidelines
### 5.1 Authentication & Authorization
**Use AshAuthentication:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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_elixir` for hashing (never store plain text passwords)
### 5.3 Input Validation & Sanitization
**Validate All User Input:**
```elixir
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:
```elixir
# 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:**
```heex
<.form for={@form} phx-submit="save">
```
**Configure in Endpoint:**
```elixir
# 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:**
```bash
# .gitignore should include:
.env
.env.*
!.env.example
```
**Use Environment Variables:**
```elixir
# 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:**
```bash
# Generate secret key base
mix phx.gen.secret
# Generate token signing secret
mix phx.gen.secret
```
### 5.6 Security Headers
**Configure Security Headers:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# Good - preload relationships
members =
Member
|> Ash.Query.load([:custom_field_values, :user])
|> Mv.Membership.list_members!()
# Avoid - causes N+1
members = Mv.Membership.list_members!()
Enum.map(members, fn member ->
custom_field_values = Ash.load!(member, :custom_field_values) # N queries!
end)
```
**Pagination:**
```elixir
# Use keyset pagination (configured as default in Ash)
Ash.Query.page(Member, offset: 0, limit: 50)
```
**Batch Operations:**
```elixir
# Use bulk operations for multiple records
Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create)
```
### 6.2 LiveView Performance
**Optimize Assigns:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# For large collections
def mount(_params, _session, socket) do
{:ok, stream(socket, :members, list_members())}
end
def render(assigns) do
~H"""
<%= member.name %>
"""
end
```
### 6.3 Caching Strategies
**Function-Level Caching:**
```elixir
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:**
```elixir
# 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):**
```elixir
# 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:**
```elixir
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:**
```elixir
# In iex session
:observer.start()
```
**Use Telemetry:**
```elixir
# 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:**
```elixir
defmodule Mv.Membership.Member do
@moduledoc """
Represents a club member with their personal information and membership status.
Members can have custom_field_values 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:**
```elixir
@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:**
```elixir
@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:**
```elixir
# 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:**
```elixir
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:**
```bash
# Generate HTML documentation
mix docs
# View documentation
open doc/index.html
```
### 7.6 Changelog
**Maintain CHANGELOG.md:**
```markdown
# Changelog
## [Unreleased]
### Added
- Member custom_field_values 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:**
```bash
# Create feature branch
git checkout -b feature/member-custom-custom_field_values
# Work on feature
git add .
git commit -m "Add custom_field_values to members"
# Push to remote
git push origin feature/member-custom-custom_field_values
```
### 8.2 Commit Messages
**Format:**
```
: