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(notForbidden) 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.Changesetwithto_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 :userswithout join table -
Missing
through:option -
Forgetting
manage_relationshipfor 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
-
actorconcept requires passing user context everywhere -
NotFoundvsForbiddenstrategy 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=truewith 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.codegenreduced boilerplate by ~60% - IEx-first workflow: Caught 90% of issues before UI work
โ What Claude Got Wrong
Recurring mistakes:
-
Direct
Ash.Queryin LiveViews (violated architecture) -
Nested
if/caseinstead ofwithstatements (complexity 14 โ 7 after fix) -
Missing
actor:in test assertions -
condinstead of pattern matching (code-smells) - Duplicate domain wrappers that just delegated to Ash
๐ก How to Guide Claude Better
What worked:
- CLAUDE.md with โ/โ examples - Side-by-side wrong vs correct
- ADRs as context - Claude learned from documented decisions
- Small iterations - One resource at a time, verify in IEx
- Explicit Ash version - โUsing Ash 3.x, not 2.x patternsโ
-
Credo enforcement -
mix credo --strictcaught 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)