# DRY refactor plan Status: Complete Codebase analysis identifying repeated patterns and consolidation opportunities. Ordered by impact. ## Codebase snapshot | Category | Files | Lines | |----------|------:|------:| | Elixir source (.ex) | 96 | 18,153 | | HEEx templates (.heex) | 15 | 2,384 | | Tests (.exs) | 45 | 6,735 | | JavaScript (.js) | 1 | 351 | | CSS (.css) | 5 | 1,218 | | Config + migrations | 17 | 653 | | **Total** | **179** | **29,494** | Code density: 81.1% (22,108 code / 4,355 blank / 809 comment). ### Concentration - `shop_components.ex` alone is 4,424 lines — 24% of all Elixir source - Web layer (9,576) slightly larger than contexts (7,950) - Top 3 files (`shop_components.ex`, `index.html.heex`, `preview_data.ex`) account for 6,650 lines — 36% of source ### What matters Every LOC is maintenance cost, attack surface, and cognitive load. The goal isn't minimising LOC for its own sake — a 50-line module that does one thing clearly beats a 20-line clever abstraction. But 224 lines of copy-pasted template wrapper is the kind of LOC worth eliminating. Contexts are well-sized (200-550 lines each, good boundaries). LiveViews average ~157 lines — thin wrappers over contexts, which is right. The problems are in the shared component layer and the repeated boilerplate. ### Worth questioning - `preview_data.ex` (1,049 lines) and `mockups/generator.ex` (522 lines) total 1,570 lines of hard-coded demo content. Should this be seed data or fixtures rather than compiled code? Could be moved behind `Mix.env()` guards or into a mix task to reduce the production runtime footprint by ~8%. - Test-to-source ratio is 0.37:1 (lines). Reasonable for a template-heavy app, but new features should aim to keep this steady or improve it. ## High priority ### 1. Extract `<.shop_layout>` wrapper component **Problem:** Every page template repeats ~28 lines of shell boilerplate: `#shop-container` div, `skip_link`, `announcement_bar`, `shop_header`, `shop_footer`, `cart_drawer`, `search_modal`, `mobile_bottom_nav`. That's ~224 lines of copy-paste across 8 templates. **Fix:** Create a `shop_layout/1` component in `shop_components.ex` that accepts an inner block and common attrs. Templates become just their unique `
` content. **Before:** ```heex
<.skip_link /> <%= if @theme_settings.announcement_bar do %> <.announcement_bar theme_settings={@theme_settings} /> <% end %> <.shop_header theme_settings={@theme_settings} logo_image={@logo_image} ... />
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} ... /> <.search_modal hint_text={~s(...)} /> <.mobile_bottom_nav active_page="home" mode={@mode} />
``` **After:** ```heex <.shop_layout {assigns}>
``` **Net saving:** ~195 lines removed (224 duplicated minus ~29 for the new component definition). **Files:** `shop_components.ex` (new component), all 8 page templates, `collection.ex` inline render. **Complexity:** Medium. The error template has slight variations (no `phx-hook`, different class). Handle with an optional attr or keep error as a special case. ### 2. Extract theme loading into on_mount hook **Problem:** 7 shop LiveViews independently load theme settings, check CSS cache, fetch logo/header, assign 5 common values. ~12-15 identical lines per file, ~75 lines total. **Fix:** Create a `ThemeHook` (like `CartHook`) using `on_mount` that assigns `theme_settings`, `generated_css`, `logo_image`, `header_image`, `mode`. Add it to the `:public_shop` live_session alongside `CartHook`. **Before (every LiveView):** ```elixir def mount(_params, _session, socket) do theme_settings = Settings.get_theme_settings() generated_css = case CSSCache.get() do {:ok, css} -> css :miss -> css = CSSGenerator.generate(theme_settings); CSSCache.put(css); css end socket = socket |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) |> assign(:logo_image, Media.get_logo()) |> assign(:header_image, Media.get_header()) |> assign(:mode, :shop) # ... page-specific assigns end ``` **After:** ```elixir # Router live_session :public_shop, on_mount: [{CartHook, :mount_cart}, {ThemeHook, :mount_theme}] do # ... end # LiveView - only page-specific logic remains def mount(_params, _session, socket) do socket = assign(socket, :page_title, "Home") {:ok, socket} end ``` **Net saving:** ~55 lines removed (75 duplicated minus ~20 for the new hook module). Also removes 7 sets of aliases (Settings, Media, CSSCache, CSSGenerator) from LiveViews. **Files:** New `theme_hook.ex`, `router.ex` (add to on_mount), all 7 shop LiveViews (remove boilerplate). **Complexity:** Low. Follows established `CartHook` pattern exactly. ### 3. Extract common preview assigns helper **Problem:** 10 `preview_page` clauses in `theme_live/index.ex` each pass the same 8 common attrs (theme_settings, logo_image, header_image, mode, cart_items, cart_count, cart_subtotal, cart_drawer_open). ~80 lines of duplication. **Fix:** Extract a private `common_preview_assigns/1` function. Each clause merges page-specific attrs on top. **Net saving:** ~65 lines removed (80 duplicated minus ~15 for the helper function). **Files:** `lib/simpleshop_theme_web/live/theme_live/index.ex` **Complexity:** Low. Pure refactor within one file. ## Medium priority ### 4. Split `shop_components.ex` into focused modules **Problem:** 4,400 lines, 55 public functions. Covers navigation, products, cart, contact, content rendering, forms. Hard to navigate, slow to compile. **Fix:** Split into ~5 modules: - `ShopLayoutComponents` — header, footer, mobile_bottom_nav, skip_link, announcement_bar, shop_layout - `ShopProductComponents` — product_card, product_grid, product_gallery, variant_selector, related_products - `ShopCartComponents` — cart_drawer, cart_item, cart_layout, order_summary - `ShopContentComponents` — rich_text, hero_section, image_text_section, responsive_image - `ShopFormComponents` — contact_form, newsletter_card, search_modal Keep `ShopComponents` as a facade that imports all sub-modules for backward compatibility. **Net saving:** ~0 lines (redistribution, not reduction). Value is in navigability, compile speed, and cognitive load — no single file over ~1,000 lines. **Files:** `shop_components.ex` split into 5 new files. **Complexity:** Medium. Mechanical but touches many imports. Need to update `page_templates.ex` and any direct references. ### 5. Unify cart hydration between CartHook and CheckoutController **Problem:** `CheckoutController` manually calls `Cart.hydrate()` and `Enum.reduce` for subtotal instead of using `Cart.calculate_subtotal/1`. Duplicates logic from `CartHook.update_cart_assigns/2`. **Fix:** Extract a `Cart.build_state/1` function that returns `%{items: hydrated, count: n, subtotal: formatted}`. Both CartHook and CheckoutController use it. **Net saving:** ~15 lines removed. Main value is correctness — single source of truth for cart state calculation. **Files:** `cart.ex`, `cart_hook.ex`, `checkout_controller.ex` **Complexity:** Low. ### 6. Consolidate encryption logic **Problem:** Both `Settings.put_secret/2` and `ProviderConnection` changeset independently call `Vault.encrypt()` with different error handling patterns. **Fix:** Add `Vault.encrypt!/1` that raises on failure (for changeset use) and keep `Vault.encrypt/1` for tuple returns. Or create an `Ecto.Type` for encrypted fields. **Net saving:** ~10 lines removed. Main value is consistent error handling across all encryption callsites. **Files:** `vault.ex`, `settings.ex`, `provider_connection.ex` **Complexity:** Low-medium. ## Low priority ### 7. Consolidate Settings repo lookups **Problem:** `get_setting`, `get_secret`, `has_secret?`, `secret_hint` each independently call `Repo.get_by(Setting, key: key)`. **Fix:** Extract `defp fetch_setting(key)` that returns `{:ok, setting} | :not_found`. **Files:** `settings.ex` **Complexity:** Trivial. ### 8. Scalable secrets loading **Problem:** `Secrets.load_stripe_config/0` hardcodes Stripe key loading. Adding more providers means duplicating the pattern. **Fix:** Registry of `{settings_key, app, env_key}` tuples. `load_all/0` iterates. **Files:** `secrets.ex` **Complexity:** Trivial but low value until more providers are added. ## What's already good - **CartHook** — textbook shared behaviour via `attach_hook`. Model for ThemeHook. - **Router** — clean pipeline composition, no duplication. - **CoreComponents vs ShopComponents** — proper separation, no overlap. - **Provider abstraction** — clean dispatch with Mox support. - **Context boundaries** — Products, Orders, Settings, Media are well-separated. - **Page LiveView renders** — now all use `{assigns}` spread (just refactored). ## Impact summary | # | Item | Lines saved | Risk | Primary value | |---|------|------------:|------|---------------| | 2 | ThemeHook | ~55 | Low | Eliminates mount duplication + alias clutter | | 1 | shop_layout | ~195 | Medium | Biggest raw line reduction | | 3 | Preview assigns | ~65 | Low | Single-file cleanup | | 5 | Cart state builder | ~15 | Low | Correctness (single source of truth) | | 4 | Split shop_components | ~0 | Medium | Navigability, compile speed | | 6 | Encryption | ~10 | Low | Consistent error handling | | 7 | Settings lookups | ~10 | Trivial | Minor cleanup | | 8 | Secrets loading | ~5 | Trivial | Future-proofing | | | **Total** | **~355** | | **~1.7% of total source** | Combined with the structural improvement of splitting `shop_components.ex` (4,424 lines into 5 modules of ~900 lines each), the largest file drops from 24% of all source to under 5%. ## Suggested order 1. ThemeHook (item 2) — lowest risk, biggest clarity win 2. shop_layout component (item 1) — biggest line reduction 3. Preview assigns helper (item 3) — quick win, one file 4. Cart state builder (item 5) — small but prevents bugs 5. Split shop_components (item 4) — larger effort, do when convenient 6. Everything else — opportunistic