A Lighter Clean Architecture for Elixir
A Lighter Clean Architecture for Elixir
After years of building Elixir applications, I've developed a lighter approach to Clean Architecture that embraces the strengths of the BEAM ecosystem while maintaining the core principles of separation of concerns and testability.
This architecture evolved from building Quant, a financial data API library, where we needed a structure that was both maintainable and pragmatic.
The Problem with Traditional Clean Architecture
Uncle Bob's Clean Architecture is powerful, but it can be heavyweight for Elixir applications:
- Over-abstraction: Separate entities, DTOs, and database models create unnecessary translation layers
- Framework agnostic to a fault: Fighting against Ecto instead of embracing it
- Boilerplate heavy: Too many intermediate structures for simple operations
- OOP-centric: Designed for object-oriented languages, not functional ones
In Elixir, we have better primitives. We can achieve the same goals with less ceremony.
The Core Principle: Leverage Ecto
The key insight: Ecto schemas are already excellent domain models.
They provide:
- Type specifications
- Data validation through changesets
- Clear boundaries
- Composability
- Easy testing
Instead of fighting Ecto, we embrace it as our domain layer.
The Architecture Layers
1. Domain Layer: Ecto Schemas + Changesets
Ecto schemas define our domain models, and changesets encode business rules.
# lib/quant/domain/market_data.ex
defmodule Quant.Domain.MarketData do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
symbol: String.t(),
timestamp: DateTime.t(),
open: Decimal.t(),
high: Decimal.t(),
low: Decimal.t(),
close: Decimal.t(),
volume: integer(),
provider: atom(),
currency: String.t()
}
embedded_schema do
field :symbol, :string
field :timestamp, :utc_datetime
field :open, :decimal
field :high, :decimal
field :low, :decimal
field :close, :decimal
field :volume, :integer
field :provider, Ecto.Enum, values: [:yahoo_finance, :binance, :alpha_vantage]
field :currency, :string, default: "USD"
end
@doc """
Business rule: Validates market data integrity
"""
def changeset(market_data \\ %__MODULE__{}, attrs) do
market_data
|> cast(attrs, [:symbol, :timestamp, :open, :high, :low, :close, :volume, :provider, :currency])
|> validate_required([:symbol, :timestamp, :open, :high, :low, :close, :volume, :provider])
|> validate_symbol_format()
|> validate_price_relationships()
|> validate_volume_positive()
end
# Business rule: Symbol must be uppercase alphanumeric
defp validate_symbol_format(changeset) do
validate_change(changeset, :symbol, fn :symbol, symbol ->
if String.match?(symbol, ~r/^[A-Z0-9]+$/) do
[]
else
[symbol: "must be uppercase alphanumeric"]
end
end)
end
# Business rule: High >= Low, Close between High and Low
defp validate_price_relationships(changeset) do
high = get_field(changeset, :high)
low = get_field(changeset, :low)
close = get_field(changeset, :close)
cond do
is_nil(high) or is_nil(low) or is_nil(close) ->
changeset
Decimal.compare(high, low) == :lt ->
add_error(changeset, :high, "must be greater than or equal to low")
Decimal.compare(close, low) == :lt or Decimal.compare(close, high) == :gt ->
add_error(changeset, :close, "must be between low and high")
true ->
changeset
end
end
# Business rule: Volume must be positive
defp validate_volume_positive(changeset) do
validate_number(changeset, :volume, greater_than: 0)
end
end
Why this works:
- Schema defines the structure (domain model)
- Changeset encodes invariants (business rules)
- No separate entity/DTO layer needed
- Type specs for compile-time safety
- Easy to test in isolation
2. Use Cases: Application Logic
Use cases orchestrate domain objects and adapters to fulfill business requirements.
# lib/quant/use_cases/fetch_historical_data.ex
defmodule Quant.UseCases.FetchHistoricalData do
alias Quant.Domain.MarketData
alias Quant.Adapters.DataProvider
@type params :: %{
symbol: String.t(),
provider: atom(),
interval: String.t(),
period: String.t()
}
@type result :: {:ok, [MarketData.t()]} | {:error, term()}
@doc """
Fetches historical market data from a provider and validates it.
This use case:
1. Validates input parameters
2. Calls the appropriate adapter
3. Transforms raw data into domain models
4. Validates domain constraints
5. Returns normalized data
"""
@spec execute(params()) :: result()
def execute(params) do
with {:ok, validated_params} <- validate_params(params),
{:ok, raw_data} <- fetch_from_provider(validated_params),
{:ok, market_data} <- normalize_data(raw_data, validated_params) do
{:ok, market_data}
end
end
defp validate_params(params) do
required_keys = [:symbol, :provider, :interval, :period]
if Enum.all?(required_keys, &Map.has_key?(params, &1)) do
{:ok, params}
else
{:error, :missing_required_parameters}
end
end
defp fetch_from_provider(%{provider: provider} = params) do
adapter = DataProvider.get_adapter(provider)
adapter.fetch_history(params)
end
defp normalize_data(raw_data, %{provider: provider}) do
market_data =
Enum.reduce_while(raw_data, {:ok, []}, fn raw_record, {:ok, acc} ->
attrs = %{
symbol: raw_record["symbol"],
timestamp: parse_timestamp(raw_record["timestamp"]),
open: Decimal.new(raw_record["open"]),
high: Decimal.new(raw_record["high"]),
low: Decimal.new(raw_record["low"]),
close: Decimal.new(raw_record["close"]),
volume: raw_record["volume"],
provider: provider,
currency: raw_record["currency"] || "USD"
}
case MarketData.changeset(%MarketData{}, attrs) do
%{valid?: true} = changeset ->
{:cont, {:ok, [Ecto.Changeset.apply_changes(changeset) | acc]}}
changeset ->
{:halt, {:error, {:invalid_data, changeset.errors}}}
end
end)
case market_data do
{:ok, data} -> {:ok, Enum.reverse(data)}
error -> error
end
end
defp parse_timestamp(timestamp) when is_binary(timestamp) do
case DateTime.from_iso8601(timestamp) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
end
end
Why this works:
- Use case is pure application logic
- No business rules here (those are in changesets)
- Orchestrates domain + adapters
- Easy to test with mocks
- Clear input/output contracts
3. Adapters: External Services
Adapters implement the interface for external services, translating between their API and our domain.
# lib/quant/adapters/data_provider.ex
defmodule Quant.Adapters.DataProvider do
@moduledoc """
Behavior for data provider adapters.
"""
@type fetch_params :: %{
symbol: String.t(),
interval: String.t(),
period: String.t()
}
@type raw_data :: [map()]
@callback fetch_history(fetch_params()) :: {:ok, raw_data()} | {:error, term()}
@doc """
Returns the appropriate adapter module for a provider.
"""
def get_adapter(:yahoo_finance), do: Quant.Adapters.YahooFinance
def get_adapter(:binance), do: Quant.Adapters.Binance
def get_adapter(:alpha_vantage), do: Quant.Adapters.AlphaVantage
def get_adapter(provider), do: raise "Unknown provider: #{provider}"
end
# lib/quant/adapters/yahoo_finance.ex
defmodule Quant.Adapters.YahooFinance do
@behaviour Quant.Adapters.DataProvider
@base_url "https://query1.finance.yahoo.com/v8/finance/chart/"
@impl true
def fetch_history(%{symbol: symbol, interval: interval, period: period}) do
url = build_url(symbol, interval, period)
case HTTPoison.get(url) do
{:ok, %{status_code: 200, body: body}} ->
parse_response(body, symbol)
{:ok, %{status_code: 404}} ->
{:error, :symbol_not_found}
{:ok, %{status_code: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp build_url(symbol, interval, period) do
query_params = URI.encode_query(%{
"interval" => normalize_interval(interval),
"range" => normalize_period(period)
})
"#{@base_url}#{symbol}?#{query_params}"
end
defp normalize_interval("1d"), do: "1d"
defp normalize_interval("1h"), do: "1h"
defp normalize_interval("1m"), do: "1m"
defp normalize_interval(interval), do: interval
defp normalize_period("1y"), do: "1y"
defp normalize_period("1mo"), do: "1mo"
defp normalize_period("1d"), do: "1d"
defp normalize_period(period), do: period
defp parse_response(body, symbol) do
with {:ok, json} <- Jason.decode(body),
{:ok, data} <- extract_chart_data(json) do
{:ok, transform_to_standard_format(data, symbol)}
else
_ -> {:error, :invalid_response}
end
end
defp extract_chart_data(%{"chart" => %{"result" => [result | _]}}) do
{:ok, result}
end
defp extract_chart_data(_), do: {:error, :invalid_response}
defp transform_to_standard_format(data, symbol) do
timestamps = data["timestamp"]
quotes = data["indicators"]["quote"] |> List.first()
timestamps
|> Enum.with_index()
|> Enum.map(fn {timestamp, idx} ->
%{
"symbol" => symbol,
"timestamp" => DateTime.from_unix!(timestamp) |> DateTime.to_iso8601(),
"open" => Enum.at(quotes["open"], idx),
"high" => Enum.at(quotes["high"], idx),
"low" => Enum.at(quotes["low"], idx),
"close" => Enum.at(quotes["close"], idx),
"volume" => Enum.at(quotes["volume"], idx),
"currency" => data["meta"]["currency"]
}
end)
end
end
Why this works:
- Adapters handle all external complexity
- Transform external data to domain format
- No domain logic here
- Easy to swap implementations
- Can mock for testing
The Layers in Practice
Here's how they interact:
┌─────────────────────────────────────┐
│ External System │
│ (Yahoo Finance, Binance, etc.) │
└─────────────────┬───────────────────┘
│
│ HTTP/API
│
┌─────────────────▼───────────────────┐
│ Adapters Layer │
│ - YahooFinance │
│ - Binance │
│ - AlphaVantage │
│ │
│ Translates external → domain │
└─────────────────┬───────────────────┘
│
│ Raw data maps
│
┌─────────────────▼───────────────────┐
│ Use Cases Layer │
│ - FetchHistoricalData │
│ - CalculateIndicators │
│ - BacktestStrategy │
│ │
│ Orchestrates domain + adapters │
└─────────────────┬───────────────────┘
│
│ Domain structs
│
┌─────────────────▼───────────────────┐
│ Domain Layer │
│ - MarketData (schema) │
│ - Strategy (schema) │
│ - Signal (schema) │
│ │
│ Changesets encode business rules │
└─────────────────────────────────────┘
Why This is "Lighter"
Compared to traditional Clean Architecture:
1. No Separate Entity Layer
Traditional:
Database Model → Entity (domain) → DTO → Response
Our approach:
Ecto Schema (domain) → Response
2. Changesets ARE Business Rules
Traditional:
- Separate validation classes
- Service layer with business logic
- Multiple layers of validation
Our approach:
- Changeset functions encode invariants
- Business rules live with domain models
- Single source of truth
3. Embedded Schemas for Non-Persisted Data
We use embedded_schema for domain models that don't map to database tables:
defmodule Quant.Domain.Signal do
use Ecto.Schema
# Not stored in DB, pure domain model
embedded_schema do
field :timestamp, :utc_datetime
field :type, Ecto.Enum, values: [:buy, :sell, :hold]
field :confidence, :float
field :reason, :string
end
end
This gives us Ecto's benefits without database coupling.
Testing Benefits
This architecture is highly testable:
Domain Tests
defmodule Quant.Domain.MarketDataTest do
use ExUnit.Case
alias Quant.Domain.MarketData
describe "changeset/2" do
test "validates high >= low" do
attrs = %{
symbol: "AAPL",
timestamp: DateTime.utc_now(),
open: Decimal.new("150"),
high: Decimal.new("145"), # Invalid: less than low
low: Decimal.new("148"),
close: Decimal.new("149"),
volume: 1000,
provider: :yahoo_finance
}
changeset = MarketData.changeset(%MarketData{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to low" in errors_on(changeset).high
end
end
end
Use Case Tests
defmodule Quant.UseCases.FetchHistoricalDataTest do
use ExUnit.Case
import Mox
alias Quant.UseCases.FetchHistoricalData
setup :verify_on_exit!
test "returns validated market data" do
# Mock the adapter
Quant.Adapters.MockProvider
|> expect(:fetch_history, fn _params ->
{:ok, [
%{
"symbol" => "AAPL",
"timestamp" => "2026-01-28T00:00:00Z",
"open" => "150.00",
"high" => "155.00",
"low" => "149.00",
"close" => "154.00",
"volume" => 1000000,
"currency" => "USD"
}
]}
end)
params = %{
symbol: "AAPL",
provider: :mock,
interval: "1d",
period: "1mo"
}
assert {:ok, [market_data]} = FetchHistoricalData.execute(params)
assert market_data.symbol == "AAPL"
assert market_data.volume > 0
end
end
Adapter Tests
defmodule Quant.Adapters.YahooFinanceTest do
use ExUnit.Case
import Mox
alias Quant.Adapters.YahooFinance
test "transforms Yahoo Finance response to standard format" do
# Test with real API format
# Or use recorded HTTP responses (VCR pattern)
end
end
When to Use This Architecture
This architecture works well when:
- You're using Ecto: Leverages Ecto's strengths instead of fighting them
- Domain is data-centric: Financial data, sensor readings, user data
- Multiple external sources: Need adapters for different APIs
- Team knows Elixir: Not forcing OOP patterns on functional code
- Maintainability matters: Clear boundaries without over-engineering
When NOT to Use This
Consider alternatives if:
- No Ecto in your project: Without Ecto, you lose the main benefits
- Extremely complex domain: Might need full DDD with aggregates, value objects
- Microservices with shared domain: Traditional clean architecture might be clearer
- Team prefers OOP: Don't force functional patterns on OOP developers
Real-World Benefits
In the Quant library, this architecture delivered:
- Easy provider switching: Change one line to use different data source
- Robust validation: Changesets catch bad data before computation
- Clear testing: Each layer tests independently
- Fast onboarding: Developers understand it quickly
- Low maintenance: Less code means fewer bugs
Conclusion
Clean Architecture doesn't have to be heavy. By embracing Elixir and Ecto instead of fighting them, we get:
- ✅ Separation of concerns
- ✅ Testability
- ✅ Maintainability
- ✅ Less boilerplate
- ✅ Idiomatic Elixir
The key insight: Ecto schemas + changesets are a fantastic domain layer. Use them.
Start with this lighter approach. If your domain grows more complex, you can always add more structure. But for most Elixir applications, this strikes the perfect balance between pragmatism and principle.
This architecture is battle-tested in Quant, where it handles multiple financial data providers, complex calculations, and strict validation requirements—all while staying maintainable and testable.