- Extract fetch_setting/1 in Settings (4 callsites → 1 repo lookup) - Replace hardcoded load_stripe_config with registry-driven load_all - Adding new secrets is now a one-line @secret_registry entry - Mark DRY refactor plan as complete (all 8 items done) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 KiB
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.exalone 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) andmockups/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 behindMix.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_layoutShopProductComponents— product_card, product_grid, product_gallery, variant_selector, related_productsShopCartComponents— cart_drawer, cart_item, cart_layout, order_summaryShopContentComponents— rich_text, hero_section, image_text_section, responsive_imageShopFormComponents— 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
- ThemeHook (item 2) — lowest risk, biggest clarity win
- shop_layout component (item 1) — biggest line reduction
- Preview assigns helper (item 3) — quick win, one file
- Cart state builder (item 5) — small but prevents bugs
- Split shop_components (item 4) — larger effort, do when convenient
- Everything else — opportunistic