When “Don’t Repeat Yourself” Makes Code Worse – Learning Elixir And Re-learning Recursion

Re-learning functional programming with TDD and the Roman Numeral kata

Thanks to a conversation with Erik Schön, I was learning Elixir while waiting for my plane back from DevLin 2025. I decided to go through the Roman numeral kata to slowly get into Elixir.

When the algorithm was coming together I hit this interesting moment where I saw a lot of repetition, and asked Claude to help me think through how to remove it.

But, the “right” refactoring made the code actively harder to read the code.

The Recursive Discovery

First, I had to refresh my mind about how recursion works (Elixir is a functional language, and in those languages, recursion is a natural idiom). Coming from other languages, I kept looking for loops. And I did have an intermediate implementation using if/else clauses and loops. But over time, and with some refactoring, the functional idiom clicked.

In functional languages, the key is: You recurse.

So converting 8 to Roman numerals isn’t a loop – it’s:

  • 8 >= 5? Yes! Return “V” and recurse with 3
  • 3 >= 5? No. 3 >= 4? No. Just repeat “I” three times and recurse with 0
  • 0? Done. Return empty string.

Each function call handles one piece, then calls itself with what’s left. The base case (to_roman(0)) stops the recursion. This felt weird at first, but then my functional training started to come back – this is how you think in functional languages.

Here’s what we built:

def to_roman(0), do: ""
def to_roman(decimal) do
  cond do
    decimal >= 500 -> "D" <> to_roman(decimal - 500)
    decimal >= 400 -> "CD" <> to_roman(decimal - 400)
    decimal >= 100 -> "C" <> to_roman(decimal - 100)
    decimal >= 90 -> "XC" <> to_roman(decimal - 90)
    decimal >= 50 -> "L" <> to_roman(decimal - 50)
    decimal >= 40 -> "XL" <> to_roman(decimal - 40)
    decimal >= 10 -> "X" <> to_roman(decimal - 10)
    decimal >= 9 -> "IX" <> to_roman(decimal - 9)
    decimal >= 5 -> "V" <> to_roman(decimal - 5)
    decimal >= 4 -> "IV" <> to_roman(decimal - 4)
    true -> "I" <> to_roman(decimal - 1)
  end
end

The repetition is obvious. Every line follows the same pattern: decimal >= threshold -> "SYMBOL" <> to_roman(decimal - threshold).

Claude’s “Proper” Refactor

When I asked Claude for help, it suggested a few options that felt off, and one that felt “right” (it removed the repetition, but…

Here’s what it suggested:

def to_roman(0), do: ""
def to_roman(decimal) do
  {value, symbol} = get_value_and_symbol(decimal)
  symbol <> to_roman(decimal - value)
end
def get_value_and_symbol(decimal) do
  cond do
    decimal >= 1000 -> {1000, "M"}
    decimal >= 900 -> {900, "CM"}
    decimal >= 500 -> {500, "D"}
    decimal >= 400 -> {400, "CD"}
    decimal >= 100 -> {100, "C"}
    decimal >= 90 -> {90, "XC"}
    decimal >= 50 -> {50, "L"}
    decimal >= 40 -> {40, "XL"}
    decimal >= 10 -> {10, "X"}
    decimal >= 9 -> {9, "IX"}
    decimal >= 5 -> {5, "V"}
    decimal >= 4 -> {4, "IV"}
    true -> {1, "I"}
  end
end

“This eliminates the repetition,” Claude explained. “Single responsibility, separation of concerns.”

Technically better, right?

But I stared at it and realized… I found the first version more readable.

Why The “Worse” Code Won

The inline version tells me the entire algorithm at a glance. I can see:

  • The threshold checks in descending order
  • Exactly which symbol maps to which value
  • The recursive pattern calling itself
  • The whole conversion logic

All in one place.

When I read the data-driven refactoring, now I have to jump between functions. Decode the tuple unpacking. The main function is cleaner but… what did we actually gain?

We eliminated a repeating pattern, but each line in the original was declarative and self-explanatory. The repetition wasn’t accidental duplication – it was the algorithm itself.

Claude — true to form — agreed. “Readability over abstraction in this case,” it said.

When Patterns Are The Point

Sometimes code has a visible structure that should be obvious. Configuration. Routing tables. Conversion rules. The pattern IS the documentation.

Breaking that apart to eliminate repetition can actually hide the logic.

AND: when you’re learning a new paradigm like recursion, seeing the pattern repeated helps it sink in. Each line reinforces: “check condition, build symbol, recurse with remainder.” That’s not noise – that’s the lesson.

I’m not saying never abstract. If I’m adding 5000 -> “ↁ” tomorrow, maybe I want that mapping in a data structure. But for 11 well-understood Roman numeral rules?

The “repetitive” version wins.

DRY is a heuristic, not a law. Sometimes the repeated pattern is exactly what makes code clear – especially when that pattern is teaching you how to think functionally.

What do you think? Do you agree with me? Or would you implement the data-driven refactoring? Why would you?