← Back to Blog

A Lighter Clean Architecture for Elixir

·10 min read·Guillaume Bailleul

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:

  1. Over-abstraction: Separate entities, DTOs, and database models create unnecessary translation layers
  2. Framework agnostic to a fault: Fighting against Ecto instead of embracing it
  3. Boilerplate heavy: Too many intermediate structures for simple operations
  4. 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:

  1. You're using Ecto: Leverages Ecto's strengths instead of fighting them
  2. Domain is data-centric: Financial data, sensor readings, user data
  3. Multiple external sources: Need adapters for different APIs
  4. Team knows Elixir: Not forcing OOP patterns on functional code
  5. Maintainability matters: Clear boundaries without over-engineering

When NOT to Use This

Consider alternatives if:

  1. No Ecto in your project: Without Ecto, you lose the main benefits
  2. Extremely complex domain: Might need full DDD with aggregates, value objects
  3. Microservices with shared domain: Traditional clean architecture might be clearer
  4. 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.