The same fact, written in five places
I'm building an LMS where teachers write courses in one language and students read them in another. So content i18n. Standard problem.
What surprised me was how many places "this field gets translated" had to live.
1. supabase/migrations/...sql CHECK (entity_type IN (...))
2. backend/app/schemas/...py Literal["course", "module", ...]
3. backend/app/models/...py Mapped[Literal[...]]
4. backend/app/services/.../walker.py if entity_type == "course": ...
5. backend/app/api/v1/announcements call reconcile_entity(...)
Each of these is its own commit, its own PR, its own author. They drift independently. You add a new translatable entity in March, you forget one of the five, and a Russian student reads English in their dashboard until somebody reports it.
I shipped that bug twice in two months before I got tired of it.
What the bug looks like in practice
Concretely: I added an Announcement table that has translatable title and body. Things I had to update:
- The Postgres
CHECKconstraint to add'announcement'to the allowedentity_typevalues. Forgot this one. All inserts started failing withIntegrityError. Took two days to notice because the route swallowed the exception and just logged. - The Pydantic
EntityType = Literal[...]so the API contract types match. - The SQLAlchemy
Mapped[Literal[...]]on theContentTranslationrow. - The tree walker that, when a course is published, decides what to translate. Had to add a new branch for announcements.
- The per-entity hook on the announcement-create route. Every other write route already called
reconcile_entity()after commit. This one didn't, so edits silently never propagated.
Five places. Each one is a single line or a single function call. None of them is hard. The problem is that the knowledge of "this entity is translatable" is duplicated. Every duplicate is a chance to drift.
One registry, five readers
Collapse the duplication into a declarative registry. Every layer reads from it. Adding a new entity is one entry plus one migration, end of story.
# backend/app/services/translation/registry.py
@dataclass(frozen=True, slots=True)
class FieldSpec:
name: str # logical name in our API
column: str # column in DB
model_attr: str | None = None # ORM attr if it differs
@dataclass(frozen=True, slots=True)
class EntityRegistration:
entity_type: str
fields: tuple[FieldSpec, ...]
resolve_course: Callable[[Session, Any], Course | None]
build_context: Callable[[Any, Course], str]
_REGISTRATIONS: tuple[EntityRegistration, ...] = (
EntityRegistration(
entity_type="course",
fields=(FieldSpec("title", "title"), FieldSpec("description", "description")),
resolve_course=lambda _db, c: c,
build_context=lambda _co, c: f"Course title for «{c.title}»",
),
EntityRegistration(
entity_type="announcement",
fields=(FieldSpec("title", "title"), FieldSpec("body", "body")),
resolve_course=_resolve_course_via_attr("course_id"),
build_context=lambda ann, c: f"Announcement on course «{c.title}»",
),
# ... seven more entries
)
def all_entity_types() -> frozenset[str]:
return frozenset(r.entity_type for r in _REGISTRATIONS)
The same five layers now read from this:
Pydantic schema declares the Literal once, by hand, in the module that other layers import from:
# backend/app/schemas/protocol.py
EntityType = Literal[
"course", "module", "chapter", "chapter_block",
"quiz", "quiz_question", "quiz_option",
"assignment", "announcement", "course_event", "cohort",
]
That's the only manual list. A static test asserts it stays in lockstep with the registry:
def test_pydantic_literal_matches_registry():
declared = set(get_args(EntityType))
assert declared == all_entity_types()
SQLAlchemy model imports the same EntityType.
Walker:
def collect_translatable_fields(course, db):
for reg in _REGISTRATIONS:
for entity in reg.entities_for_course(db, course):
for field in reg.fields:
yield (reg.entity_type, entity, field)
Per-entity hook:
def reconcile_entity_if_course_published(db, entity_type, entity):
reg = next((r for r in _REGISTRATIONS if r.entity_type == entity_type), None)
if reg is None:
return
course = reg.resolve_course(db, entity)
if course and course.status == "published":
run_translation(db, reg, entity, course)
Migration: still by hand, but it's the only place that doesn't auto-update. So we test it.
def test_postgres_check_constraint_matches_registry():
declared = _declared_entity_types_from_migration()
actual = all_entity_types()
assert declared == actual, (
"Migration CHECK constraint and registry are out of sync. "
f"Missing in migration: {actual - declared}. "
f"Stale in migration: {declared - actual}."
)
Now adding a new translatable entity is one new EntityRegistration plus one migration that adds the value to the CHECK. Both visible side by side in the same PR. The other three layers fix themselves at import time.
The CI guard that makes drift impossible
Even with the registry, one vulnerability remains. A new write route that mutates a registered entity but never calls the reconcile hook. Routes are individual files. There's nothing structural to stop the omission.
The fix is a static test that introspects every FastAPI route at import time:
TRANSLATION_HOOK_NAMES = (
"reconcile_entity",
"reconcile_entity_if_course_published",
"run_course_translation_pipeline_if_published",
)
def test_writes_on_translatable_entity_call_a_hook():
"""Every POST/PUT/PATCH whose path or body schema mentions a
translatable entity must reference one of the canonical reconcile
hooks somewhere in its source file."""
failures: list[str] = []
for route in app.routes:
if not isinstance(route, APIRoute):
continue
if not (route.methods & {"POST", "PUT", "PATCH"}):
continue
if not _route_touches_translatable_entity(route):
continue
source = inspect.getsource(inspect.getmodule(route.endpoint))
if not any(hook in source for hook in TRANSLATION_HOOK_NAMES):
failures.append(f"{route.methods} {route.path} ({route.endpoint.__name__})")
assert not failures, (
"These write endpoints touch a translatable entity but never call "
"a reconcile hook:\n " + "\n ".join(failures)
)
Inverse rule for read endpoints:
def test_reads_returning_translatable_schemas_accept_language():
"""Every GET that returns a translatable response schema must
declare an Accept-Language parameter so the locale overlay applies."""
Both are plain pytest. No real database, no live HTTP, no test client. They use FastAPI's app.routes plus inspect.getsource. They fail the build in under one second.
The first time I ran them they immediately surfaced two real holes: the announcement-create route and the course-event-create route. Both had been merged for weeks. No user had hit them yet because both routes were teacher-only and had low traffic, but they would have failed the moment a teacher published an announcement on an EN-locale course with RU students enrolled.
I added a KNOWN_VIOLATIONS set to the test (cite the follow-up PR or issue, no silent skipping), fixed both routes the same hour, and emptied the set. The set has stayed empty since.
What the pattern actually buys
Adding a translatable entity is now a five-line PR. One registry entry, one migration line. CI catches anything you forgot.
Failures are loud, not silent. The CI guard names the offending route. There is no "huh, why doesn't translation work for this endpoint" debugging session anymore.
The registry doubles as documentation. Want to know what gets translated? Read one file. There is no git grep archaeology.
The pattern generalizes. Anywhere a single fact about your domain has to be repeated across multiple layers, the same shape applies. Audit logging. RLS. Soft delete. Encryption. Each one is a registry plus a static test that introspects routes or models and demands they reference the canonical hook.
That last bit is what made me write this post. The translation pipeline was the prompt, but the pattern is not about translation. It is about rejecting the idea that drift between layers is something you live with. You don't have to. Static introspection of your own code is dirt cheap and almost nobody does it.
Where it lives
The full implementation is in a small open-source LMS at github.com/ArVaViT/biblie-school. Registry is backend/app/services/translation/registry.py. CI guard is backend/tests/test_translation_coverage.py. MIT-licensed, contributors welcome if any of this resonates.
Thanks for reading.
Top comments (0)