Build Log: Ohmyword — Why Elixir

I'm building a vocabulary app for learning Serbian. It's called Ohmyword. It's a CRUD app — nothing exotic. No real-time collaboration, no million-user scale problem. Just flashcards, search, and spaced repetition.

So why Elixir and Phoenix instead of the obvious choices — Rails, Django, Next.js?

Because the interesting question isn't "what's the fastest way to ship a CRUD app." It's "what happens when you pair an LLM with a language that has strong opinions about how code should be structured."

The Honest Starting Point: This App Will Have Bugs

I'm not a native Elixir developer. I'm building this with Claude as a development partner, and I went in knowing that mistakes are expected. That's part of the point.

When you're developing with an LLM, the failure modes matter as much as the happy path. In a dynamically typed language with mutable state, bugs hide. A wrong value silently propagates through three function calls before something blows up, and the stack trace points you somewhere unhelpful. When you're generating code with an LLM and reviewing it rather than writing every line yourself, silent bugs are the enemy.

Elixir fails loudly. And that changes the entire development experience with AI.

First, Understand What Serbian Actually Asks of You

If you've only learned languages like Spanish or French, Serbian grammar will rearrange your understanding of what a "word" even is.

English has essentially one form of a noun. "Dog" is "dog" whether it's the subject, the object, or the thing you're giving something to. You just change the words around it.

Serbian has 7 grammatical cases, and each one changes the ending of the noun. The word "pas" (dog) becomes "psa" in genitive, "psu" in dative, "psa" again in accusative (because it's animate — inanimate masculine nouns behave differently), "psom" in instrumental, and "psu" again in locative. Multiply by singular and plural, and a single noun has up to 14 forms.

Now layer on: 3 genders with different declension patterns. Animate vs. inanimate distinction for masculine nouns. Verb aspect pairs — "to read" isn't one verb, it's two: "čitati" (ongoing) and "pročitati" (completed). Adjectives that decline to match gender, case, and number. Two scripts — Latin and Cyrillic — used interchangeably.

A typical language learning app treats vocabulary as a flat list: word → translation. For Serbian, a single entry might need 14+ searchable forms, gender metadata, animacy flags, aspect pair relationships, and dual-script rendering. The data model isn't a list — it's a graph.

This is why I built a two-table architecture: vocabulary_words for the linguistic source of truth, and search_terms for every inflected form a user might type. My seed data for 36 base words generated 411 searchable forms. This isn't a key-value lookup problem — it's a pattern-matching problem at its core.

Pattern Matching as a Code Review Partner

Elixir's pattern matching isn't just a language feature. It's a contract system built into every function head.

def validate_word(%{part_of_speech: :noun, gender: nil}) do
  {:error, "Nouns require a gender"}
end

def validate_word(%{part_of_speech: :noun, gender: gender}) when gender in [:m, :f, :n] do
  {:ok, :valid}
end

def validate_word(%{part_of_speech: :verb, verb_aspect: nil}) do
  {:error, "Verbs require an aspect"}
end

Each clause is an explicit statement about what shape of data it expects. When Claude generates a function, I can read the pattern matches and immediately see what cases are handled, what's missing, and where the assumptions are.

In a language without pattern matching, validation logic is buried inside conditionals, spread across a method body. You have to mentally trace through every branch. With Elixir, the function signatures are the documentation.

No JavaScript: One Language, One Mental Model

Phoenix LiveView let me build the entire interactive UI — flashcards, script toggling, search — without writing JavaScript.

This isn't a convenience preference. Every time you context-switch between languages in a prompt — "here's my Elixir backend, and here's my React frontend, and here's the API contract between them" — you increase the surface area for the LLM to make mistakes. Mismatched types across the boundary. State that's duplicated and drifts.

With LiveView, the state lives on the server. The UI is a function of that state. One language, one process, one mental model.

def handle_event("toggle_script", _params, socket) do
  new_script = if socket.assigns.script == :latin, do: :cyrillic, else: :latin
  {:noreply, assign(socket, :script, new_script)}
end

That's the entire script toggle. Server-side state change, UI re-renders automatically. No fetch calls, no state management library, no hydration bugs.

Functional Programming and Verifiable Output

Functional programming produces code with a shape that's easier to verify. Functions take data in and return data out. No side effects hiding in instance variables. Each function is a self-contained transformation.

When Claude generates an Elixir function, I can evaluate it in isolation: does this function, given this input, produce the correct output? That's simpler than "does this method, given the current state of this object and everything that's been called before it, do the right thing?"

For Serbian declension logic, this matters:

def decline("pas", :gen, :sg), do: "psa"
def decline("pas", :dat, :sg), do: "psu"
def decline("pas", :acc, :sg), do: "psa"  # animate masculine

Each rule is explicit, testable, and independent. If Claude gets one declension wrong, the error is contained to a single function clause. I fix that clause and nothing else is affected.

The Pipe Operator and Readable AI Output

Small thing, big difference. Elixir's pipe operator makes data transformations read top-to-bottom:

def search(query) do
  query
  |> String.downcase()
  |> Transliteration.strip_diacritics()
  |> Transliteration.to_latin()
  |> SearchTerm.find_matches()
  |> Enum.map(&load_word/1)
end

Each step is visible. Is the order right? Is anything missing? Is there a step that shouldn't be there? It's a visual checklist. Compare this to nested function calls — load_words(find_matches(to_latin(strip_diacritics(downcase(query))))) — and the readability gap is obvious.

When you're reviewing AI-generated code, readability isn't aesthetic. It's how you catch mistakes.

Ecto: Explicit Database Contracts

Ecto reinforces the same philosophy. Changesets make data validation explicit and composable:

def changeset(word, attrs) do
  word
  |> cast(attrs, [:term, :translation, :part_of_speech, :gender, :verb_aspect])
  |> validate_required([:term, :translation, :part_of_speech])
  |> validate_gender_for_nouns()
  |> validate_aspect_for_verbs()
  |> validate_inclusion(:proficiency_level, 1..9)
end

Every validation is declared, not buried. When I ask Claude to add a new field or validation rule, the changeset pipeline makes it obvious where it should go and whether it conflicts with existing rules.

The Tradeoff I Accepted

Elixir has a smaller ecosystem than Ruby or Python. Fewer Stack Overflow answers. Fewer examples in LLM training data. There are moments where Claude is less fluent in Elixir idioms than it would be in Python, and I have to nudge it toward the right patterns.

That tradeoff is worth it because the language itself acts as a safety net. When Claude makes a mistake in Elixir, the pattern matching catches it. The compiler catches it. The explicit data flow makes it visible. This is the first in a series of posts about building Ohmyword and what small projects reveal about large ones. Next: Who owns data?. You can check it out here: Ohmyword

← Back to Blog