# 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 %>
``` **DaisyUI Components:** ```heex ``` **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:** ``` :