simpleshop_theme/docs/plans/dry-refactor.md
jamey dd19281f4f docs: add codebase metrics and impact analysis to DRY refactor plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 11:42:58 +00:00

10 KiB

DRY refactor plan

Status: Planned

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 <main> content.

Before:

<div id="shop-container" phx-hook="CartPersist" class="shop-container min-h-screen pb-20 md:pb-0"
     style="background-color: var(--t-surface-base); ...">
  <.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} ... />

  <main id="main-content">
    <!-- unique content -->
  </main>

  <.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} />
</div>

After:

<.shop_layout {assigns}>
  <main id="main-content">
    <!-- unique content -->
  </main>
</.shop_layout>

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):

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:

# 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