May 4, 2026 · Gökhan Oğuz

The Third Anything

I sat down on Sunday morning to add a third language to Sesla. By lunchtime I'd written a full plan. By dinner I'd thrown the plan away and spent the rest of the day refactoring code that, on the surface, was working perfectly fine. No new feature shipped. The app on real devices behaves exactly the same as it did at breakfast. I'm calling that a good day.

1162

Tests Passing

4

Commits

0

User-Visible Changes

The plan that didn't survive contact with reality

I won't say which language I picked first. It doesn't matter, because the plan was wrong before the language even came up. What matters is what happened when I gave the plan to a few LLMs for review and asked them to be mean.

They were mean.

The headline criticism wasn't about my pick or my linguistics. It was structural. Sesla, the reviewer pointed out, isn't a multilingual app. It's a two-language app. The codebase has spent its entire life knowing about exactly two values — Turkish and English — and that assumption is wired into about a dozen places nobody had ever needed to look at: switch statements, ternaries, ARB files, voice-settings keys, parser fallbacks, schema columns. Adding a third value to the language enum would compile fine and then silently route the new language to Turkish in half the app and English in the other half. The tests would still be green. Users would just see weird behavior.

There's a name for this in software: the rule of three. The first time you do something, just do it. The second time, you notice the duplication and grit your teeth. The third time, you refactor. I'd been treating the second language (English) as if it were the same shape as the first (Turkish) — because it was working — and now I was about to add a third without doing the refactor that the second one had been quietly demanding.

What "two-language system" actually means in code

The most embarrassing example: every place in the app that needed to do something language-specific looked like this.

if (lang == AppLanguage.turkish) {
  doTurkishThing();
} else {
  doEnglishThing();
}

Read it slowly. Notice that "else" branch. The AppLanguage enum has two values today, so "else" means English. The day a third value lands, "else" means English or the new language, silently. The compiler shrugs. The analyzer shrugs. Your tests, if you only wrote them for the existing two values, shrug. The bug only shows up when a parent in São Paulo opens the app and the sentence comes out half in Portuguese, half in Turkish, with no error.

Dart 3 has a fix for this. If you write the same logic as a switch expression that exhaustively covers every enum value, the compiler refuses to build the day you add a new value until you've handled it everywhere.

final result = switch (lang) {
  AppLanguage.turkish => doTurkishThing(),
  AppLanguage.english => doEnglishThing(),
};

Same behavior today. Loud failure tomorrow. Sunday afternoon was finding every if (lang == ...) in the codebase and converting it. Seven sites. None of them changed user-facing behavior. All of them now refuse to compile if I add an enum value without updating them.

The "no engine" engine

The bigger refactor was the morphology engines. Sesla has two of them, custom-built. The Turkish one is a multi-pass beast — vowel harmony, consonant softening, dative case, possessive chains, the whole story. The English one is smaller but still does real work around tense, plurals, and 3rd-person agreement. Both were standalone classes with different shapes and different call sites. Neither implemented a common interface, because there had only ever been two of them.

I extracted a tiny abstraction:

abstract class SentenceEngine {
  String build(List<String> texts);
}

Three implementations live behind it now: the Turkish engine wraps the existing builder, the English engine wraps the existing morphology, and a third one — PassthroughSentenceEngine — does almost nothing. It joins the words with spaces, drops empty strings, and returns. That's the entire body.

The reason it exists: not every language needs morphology to be intelligible. Some languages are analytic, meaning they use word order and standalone particles instead of glued-on suffixes. For a language like that, "I want eat rice" is approximately correct grammar. You don't need a verb conjugator. You need a string joiner. Passthrough lets a future language ship as soon as its symbol labels are translated, without waiting on me to write a morphology engine for it. I can always add a real engine later when it's earned.

The cheapest engine is the one you don't write. The trick is designing the seam where it would have gone, so the day you do need one, the wiring is already there.

A schema migration that nobody notices

The third refactor was the schema. Sesla stores symbol labels and board names as separate columns per language: text_tr, text_en, name_tr, name_en. Adding a third language under that pattern means another schema bump every time, and the column-per-language style doesn't really scale. But ripping it out now to switch readers would be invasive and risky.

The middle path: add three new normalized translation tables alongside the existing columns, and use SQLite triggers to auto-mirror writes. Repository code is completely untouched. Every time the app writes a symbol's text into the legacy column, an AFTER INSERT trigger fires and copies the value into symbol_translations(symbol_id, lang, value). Same for updates. Same for boards and categories. The migration also backfills every existing row on first launch after upgrade, so a user who's been running the app for months wakes up to a fully populated translations table they don't know exists.

Reads still come from the legacy columns. Nothing changes about behavior, performance, or the user experience. The translations tables sit there, fully synchronized, waiting. The day I'm ready to flip readers to use them — when language #3 actually lands — it's one commit and zero data work.

The thing I'm most paranoid about with database migrations is breaking existing users on upgrade. So the test for this isn't a pretend in-memory test. It writes a real v4-shaped sqlite file to disk, closes the database, reopens it through the v5 codebase, and asserts that the upgrade ran cleanly: tables created, rows backfilled, triggers installed, fresh inserts auto-mirrored. Reviewing that test is what kept me honest.

Not shipping is also a deliverable

There's a temptation, when you've done a day of work, to want something visible to come out the other end. A new tile. A new screen. A devlog post with a screenshot. This day didn't produce any of those. It produced four commits with messages like refactor(lang): exhaustive switch on AppLanguage and a test count that went from 1141 to 1162 — a number nobody but me will ever look at.

But the next time I sit down to add a language to Sesla, the work is going to be small. Most of it is going to be content — labels, cultural symbols, voice picks, hand-curated boards. The plumbing is already paid for. The compiler will tell me everywhere I missed. The translations table will already have rows. The passthrough engine will already be there if I decide morphology can wait.

That's worth not shipping a thing.

The third anything rule

I think this is general. Any time you're about to add a third instance of something — third payment method, third notification channel, third theme, third language — stop and check what the code thinks. There's a good chance it thinks there are only ever going to be two, because nothing has forced it to think otherwise yet. The third one is the one that exposes that assumption. It's cheaper to fix the assumption first and then add the new instance than to add the new instance and chase down all the silent fallthroughs from production bug reports.

Which language am I actually adding next, and when? That's a different post. I want some real user data first — I just shipped the Plus tier and the app is in store review, and decisions made without conversion data are decisions made by vibes. So the next few weeks are probably going to be quiet on the engineering side and loud on the watching-numbers side.

Then I'll know, and then the third language will land in roughly the time it takes to write the labels.