Build journal
ARCHITECTUREArchitecture 5 min read

How a Feature Gets Built

Published Jul 6, 2026

Every feature follows one repeatable path down the layers and back up to the UI.

Praxis924 has a lot of surface area: lessons, exercises, Lucy the AI teacher, mock interviews, progress tracking, and a whole social layer of notes, bookmarks, likes, discussions, follows, and achievements. If every one of those had been built its own way, the codebase would be a museum of one-off decisions. Instead, every feature walks the same path down the layers and back up to the UI. Once you have built one, you can build any of them — and a reviewer can read any of them without relearning the map. Here is the recipe, using the engagement/social features as the worked example.

The problem

The naive approach is to reach for the shortest line between a request and the database. A "like a lesson" button becomes a route handler that opens a session, runs an INSERT, and returns JSON — all in one function. It ships fast. Then the second feature (bookmarks) copies it, the third (follows) copies that, and now business rules live in HTTP handlers, SQL is scattered across api/, and there is no single place to change how "liking" works. Testing means spinning up the web layer. Every new engineer learns a slightly different shape.

The fix is boring on purpose: one direction of flow, one job per layer, every time.

The design

A feature flows model → repository → service → schema + router → frontend service → page. Each layer has exactly one responsibility, and each only talks to its immediate neighbor.

python
1# 1. models/engagement.py — the table (SQLAlchemy)
2class Like(Base):
3    __tablename__ = "likes"
4    id = Column(Integer, primary_key=True)
5    user_id = Column(ForeignKey("users.id"), index=True)
6    lesson_id = Column(ForeignKey("content_items.id"), index=True)
7    __table_args__ = (UniqueConstraint("user_id", "lesson_id"),)
8
9# 2. repositories/engagement_repository.py — the only place that touches the DB
10def add_like(db, user_id, lesson_id) -> Like: ...
11def count_likes(db, lesson_id) -> int: ...
12
13# 3. services/engagement_service.py — the rules (idempotent, owns exceptions)
14def like_lesson(db, user, lesson_id):
15    lesson = content_repo.get(db, lesson_id)
16    if not lesson or lesson.status != "published":
17        raise ResourceNotFoundError("lesson")
18    return engagement_repo.add_like(db, user.id, lesson_id)
19
20# 4. schemas/engagement.py — Pydantic IO contract
21class LikeOut(BaseModel):
22    lesson_id: int
23    likes: int
24    liked_by_me: bool
25
26# 5. api/v1/engagement.py — thin router, no logic
27@router.post("/lessons/{lesson_id}/like", response_model=LikeOut)
28def like(lesson_id: int, user=Depends(current_user), db=Depends(get_db)):
29    return engagement_service.like_lesson(db, user, lesson_id)
Powered by AI

Then the frontend mirrors it — a typed service wraps the endpoint, and a page/component calls the service:

typescript
1// frontend/src/services/engagement-service.ts
2export async function likeLesson(lessonId: number) {
3  const { data } = await apiClient.post(`/lessons/${lessonId}/like`);
4  return data as LikeOut;
5}
Powered by AI

The page imports likeLesson, updates local Zustand state, done. The same five-plus-two steps produced notes, bookmarks, discussions, follows, and the activity feed. Because the shape is identical, a discussion thread is just a Like with more columns and a service that also notifies followers.

How it works

Two mechanics keep the recipe honest.

New tables auto-create. Base.metadata.create_all runs on startup, so a brand-new model becomes a real table the moment you import it in main.py. Forget that import and the table silently never exists — the single most common "why is this 500-ing" for a new feature.

Altering existing tables uses Alembic. Adding a pinned column to discussions is not a create — create_all will not touch a table that already exists. Generate a migration, review it, and let CI's migration-drift check fail the PR if a model changed without one.

Warning: create_all only creates missing tables; it never alters existing ones. If you edit a column on a live table without an Alembic migration, dev looks fine (fresh DB) and production breaks (old schema). Any change to an existing table is an Alembic migration, no exceptions.

For anything bigger than a single table — say the whole follow system with public profiles and member search — start with OpenSpec. You propose the change (proposal + delta specs + tasks), apply the tasks, then sync/archive so the spec becomes the source of truth. It forces agreement on what before how, which is where multi-feature designs actually go wrong.

The gotchas

TrapSymptomFix
New model not imported in main.pyTable missing, route 500sImport every model where create_all can see it
Altered a live table with no migrationWorks in dev, breaks in prodAlembic migration; CI drift check gates it
Logic leaking into the routerUntestable, duplicated rulesRouter stays thin; all logic in services/
Repo calls skipped, service hits DB directlyDB access scatteredOnly repositories/ touch the session
Missing UniqueConstraint on likes/followsDuplicate rows, double countsConstrain in the model; make the service idempotent
Serving unpublished contentDraft leaks to learnersService checks status == "published"

Build it yourself

The minimal version of any feature is: define the model and import it in main.py; add two or three repository functions; write one service function that owns the rules and raises ResourceNotFoundError/AuthorizationError; declare a Pydantic Out schema; add a thin router (admin routes behind require_admin); add a typed frontend service function; wire it to the page. That is a like button. It is also a bookmark, a note, and a report — same seven steps.

What changes at scale

Counting likes with count_likes on every read is fine until a lesson has thousands. Then you add a denormalized counter, or cache the count in Redis (already in the stack for LLM responses). The activity feed graduates from a synchronous write to a fan-out job. Discussions get pagination in the schema before the list grows unbounded. Notifications move to a durable workflow (DBOS already powers lesson generation) so a crash mid-send resumes instead of dropping. None of that changes the recipe — the layers stay put; you only harden the inside of each one.

Published in build journal