SharedLedger Journey Part 2: Domain-Driven Development with Ash

posted by donghyun

last updated

12 min read

ํƒœ๊ทธ

๐Ÿ“ The Four Domain Contexts

SharedLedger is organized around four main domain contexts:

1. Identity - Who Are You?

Purpose: User management, authentication, sessions

Key Resources:

  • User - Email, hashed password, name, default currency

Actions:

  • Sign up / Sign in
  • Update profile
  • Read user info

Authorization:

  • Users can only access their own data
  • Admin role for special operations

2. Accounts - What Do You Share?

Purpose: Shared account management between couples/groups

Key Resources:

  • Account - Name, join code, created_by
  • Membership - User โ†” Account relationship with roles

Actions:

  • Create account
  • Join account (via join code)
  • Leave account
  • List userโ€™s accounts

Authorization:

  • Only members can access account data
  • Creators have special permissions

3. Ledger - Whereโ€™s Your Money?

Purpose: Transaction tracking and categorization

Key Resources:

  • Transaction - Amount, currency, category, date, note
  • Category - Expense/Income classification
  • Summary - Monthly/yearly aggregations

Actions:

  • Create transaction
  • Update/delete transaction
  • List transactions (filtered)
  • Calculate summaries

Authorization:

  • Account members can CRUD transactions
  • Returns NotFound (not Forbidden) for unauthorized

4. Currency - How Much Is It Worth?

Purpose: Multi-currency support and conversion

Key Resources:

  • ExchangeRate - EUR/KRW rates (daily updates)

Actions:

  • Convert amount between currencies
  • Get latest rates
  • Fetch historical rates

Mock vs. Real:

  • Development: Mock rates (static)
  • Production: OpenExchangeRates API

๐ŸŽฏ Ash Resource Definitions

Example: Transaction Resource

defmodule SharedLedger.Ledger.Transaction do
  use Ash.Resource,
    domain: SharedLedger.Ledger,
    data_layer: AshPostgres.DataLayer

  attributes do
    uuid_primary_key :id

    attribute :amount, :decimal, allow_nil?: false
    attribute :currency, :atom, allow_nil?: false
    attribute :category, :string, allow_nil?: false
    attribute :date, :date, allow_nil?: false
    attribute :note, :string

    create_timestamp :inserted_at
    update_timestamp :updated_at
  end

  relationships do
    belongs_to :account, SharedLedger.Accounts.Account
    belongs_to :created_by, SharedLedger.Identity.User
  end

  # ... actions, policies, calculations
end

๐Ÿค” Challenges with AI and Ash

Claude initially struggled with Ash syntax because itโ€™s a relatively new framework with less training data. Common errors:

  • Using Ash.Changeset with to_form/1 (Protocol not implemented error)
  • Confusing Ash actions vs Ecto queries
  • Missing actor: parameter in all operations
  • Creating duplicate User models in multiple domains

After providing ADR patterns and explicit โŒ/โœ… examples in CLAUDE.md, accuracy improved significantly. The key was showing both wrong and correct patterns side-by-side.

๐Ÿ”— Resource Relationships

The Membership Pattern

One of the trickiest parts: User โ†” Account many-to-many relationship

# Account has many users through memberships
has_many :memberships, SharedLedger.Accounts.Membership
has_many :members, SharedLedger.Identity.User do
  through :memberships
end

# User has many accounts through memberships
has_many :memberships, SharedLedger.Accounts.Membership
has_many :accounts, SharedLedger.Accounts.Account do
  through :memberships
end

๐Ÿ’ก Lesson: Ash Relationships

Many-to-many through Membership was tricky. Claude initially created:

  • Direct has_many :users without join table
  • Missing through: option
  • Forgetting manage_relationship for atomic operations

The fix: Use manage_relationship action instead of manual membership creation.

๐Ÿ”’ Authorization with Ash Policies

RBAC Implementation

Key Rule: Returns NotFound instead of Forbidden

policies do
  policy action_type(:read) do
    authorize_if actor_member_of_account()
  end

  policy action_type([:create, :update, :delete]) do
    authorize_if actor_member_of_account()
  end
end

๐Ÿค” Authorization Challenges

Key differences from Phoenix authorization:

  • Ash policies are declarative, not procedural
  • actor concept requires passing user context everywhere
  • NotFound vs Forbidden strategy prevents information leakage

Claude repeatedly forgot to add actor: parameter. We added a strict rule: โ€œTests without actor will fail with authorization errors.โ€ Also, AshAuthentication.Checks.AshAuthenticationInteraction bypass was confusing at first.

๐Ÿ—๏ธ Domain Layer Pattern

Critical Architecture Rule

โŒ WRONG: LiveView โ†’ Ash.Query/Ash.Changeset
โœ… CORRECT: LiveView โ†’ Domain Function โ†’ Ash Resource

Example:

# โŒ BAD: Direct Ash in LiveView
def mount(_params, _session, socket) do
  transactions = Transaction
    |> Ash.Query.filter(account_id == ^id)
    |> Ash.read!(actor: user)
  {:ok, assign(socket, :transactions, transactions)}
end

# โœ… GOOD: Domain function
def mount(_params, _session, socket) do
  case SharedLedger.Ledger.list_account_transactions(id, user) do
    {:ok, transactions} ->
      {:ok, assign(socket, :transactions, transactions)}
    {:error, _} ->
      {:ok, put_flash(socket, :error, "Failed to load")}
  end
end

๐Ÿ”ง Domain Function Examples

# SharedLedger.Ledger module
def create_transaction(params, actor) do
  Transaction
  |> Ash.Changeset.for_create(:create, params, actor: actor)
  |> Ash.create()
end

def list_account_transactions(account_id, actor) do
  Transaction
  |> Ash.Query.filter(account_id == ^account_id)
  |> Ash.read(actor: actor)
end

def calculate_account_summary(account_id, actor) do
  # ... calculation logic
end

๐Ÿ’ก Lesson: Domain Layer Discipline

Claude constantly violated the architecture rule:

โŒ LiveView โ†’ Ash.Query.filter(...) โ†’ Ash.read!()
โœ… LiveView โ†’ Domain.list_transactions() โ†’ (internal Ash call)

I had to refactor ~15 LiveView files to remove direct Ash usage. The discipline paid off: testing became trivial, and changing Ash internals didnโ€™t break LiveViews.

๐Ÿงช Testing in IEx First

The Golden Rule

ALWAYS test backend in IEx before touching LiveView!

iex -S mix

# Create a user
{:ok, user} = SharedLedger.Identity.register_user(%{
  email: "test@example.com",
  password: "password123",
  name: "Test User"
})

# Create an account
{:ok, account} = SharedLedger.Accounts.create_account(%{
  name: "Our Budget"
}, user)

# Create a transaction
{:ok, transaction} = SharedLedger.Ledger.create_transaction(%{
  account_id: account.id,
  amount: 100.50,
  currency: :EUR,
  category: "Groceries",
  date: ~D[2025-10-26]
}, user)

# Verify it worked
SharedLedger.Ledger.list_account_transactions(account.id, user)

๐ŸŽฏ Why This Matters

Testing in IEx first saved countless hours:

  • Caught authorization issues before UI work
  • Validated domain function signatures
  • Discovered actor context requirements early

Claude wanted to jump straight to LiveView implementation. I enforced: โ€œNo UI until IEx confirms backend works.โ€ This prevented debugging authorization errors through UI layers.

๐Ÿ’ฑ Currency Conversion Logic

The Challenge

Convert EUR โ†” KRW with real-time rates

Implementation:

defmodule SharedLedger.Currency do
  def convert(amount, from_currency, to_currency) do
    with {:ok, rate} <- get_exchange_rate(from_currency, to_currency) do
      converted = Decimal.mult(amount, rate)
      {:ok, converted}
    end
  end

  defp get_exchange_rate(from, to) do
    # Fetch from ExchangeRate resource
    # Or calculate inverse if needed
  end
end

Mock vs. Real Implementation

# config/dev.exs
config :shared_ledger,
  use_mock_exchange_rates: true

# config/prod.exs
config :shared_ledger,
  use_mock_exchange_rates: false,
  open_exchange_rates_app_id: System.get_env("OPEN_EXCHANGE_RATES_APP_ID")

๐Ÿ’ก Lesson: External API Integration

Mock vs Real separation was essential:

  • Development: USE_MOCK_EXCHANGE_RATES=true with static 1 EUR = 1650 KRW
  • Production: OpenExchangeRates API with caching

Claudeโ€™s initial implementation mixed both. We refactored to config-driven behavior selection. Testing with mocks made CI fast and reliable.

๐Ÿ“ Code Generation with Ash

The Power of mix ash.codegen

Never write migrations manually!

# Generate migration for new field
mix ash.codegen add_note_to_transactions

# Review the generated migration
# Then apply it
mix ecto.migrate

๐Ÿค” Codegen Experience

mix ash.codegen worked well for:

  • Adding new fields to resources
  • Creating relationships
  • Index generation

Manual fixes needed when:

  • SQLite didnโ€™t support ALTER COLUMN
  • Complex composite types (Money)
  • Renaming existing columns

Rule: Always review generated migrations before running.

๐ŸŽ“ Key Learnings - Domain Layer

โœ… What Worked Well

  • Domain context separation: Identity, Accounts, Ledger, Currency stayed clean
  • Declarative Ash definitions: Resources are self-documenting
  • Policy-based auth: Centralized, testable authorization
  • Code generation: ash.codegen reduced boilerplate by ~60%
  • IEx-first workflow: Caught 90% of issues before UI work

โŒ What Claude Got Wrong

Recurring mistakes:

  • Direct Ash.Query in LiveViews (violated architecture)
  • Nested if/case instead of with statements (complexity 14 โ†’ 7 after fix)
  • Missing actor: in test assertions
  • cond instead of pattern matching (code-smells)
  • Duplicate domain wrappers that just delegated to Ash

๐Ÿ’ก How to Guide Claude Better

What worked:

  1. CLAUDE.md with โŒ/โœ… examples - Side-by-side wrong vs correct
  2. ADRs as context - Claude learned from documented decisions
  3. Small iterations - One resource at a time, verify in IEx
  4. Explicit Ash version - โ€œUsing Ash 3.x, not 2.x patternsโ€
  5. Credo enforcement - mix credo --strict caught complexity issues

What didnโ€™t work:

  • Vague instructions like โ€œuse Ash patternsโ€
  • Large file generations without checkpoints

๐Ÿ“Š Progress Check

By end of Week 2-3:

โœ… All 4 domain contexts implemented
โœ… Ash resources defined
โœ… Authorization policies working
โœ… Domain functions tested in IEx
โœ… Currency conversion working
โŒ No UI yet (that's next!)
โš ๏ธ  Some resources needed refactoring later

๐ŸŽฌ Whatโ€™s Next?

In Part 3, weโ€™ll cover:

  • Building LiveView UI on top of solid backend
  • Component structure and patterns
  • Real-time updates
  • PWA features
  • Lessons learned about Claude and LiveView

๐Ÿ“ Reflection

Backend-first was the right choice. Building solid domain layer before UI meant fewer integration bugs.

Ash learning curve is steep but worth it. Declarative approach pays off at scale.

Claude accelerated boilerplate, slowed architecture. Good for CRUD, needed heavy guidance for patterns.

If starting over: More upfront ADRs, stricter CLAUDE.md rules from day 1, and smaller iteration cycles.


Previous: โ† Part 1: From Zero to Phoenix

Next: Part 3: Building the UI with LiveView โ†’ (Coming Soon)