The app stopped starting. Not a failing test, not a 500 — uvicorn refused to
import the package at all.
The cause was two service modules that had quietly begun importing each other.
services/tags.py needed the pagination parameters, which lived in
services/posts.py. And services/posts.py needed get_or_create from
services/tags.py to attach tags to a post. Neither import looked wrong on its
own. Together they made a cycle Python cannot resolve.
The fix I reached for first
My first instinct was to break the cycle where it was visible: import inside
the function instead of at module level, or move get_or_create somewhere
convenient. Both would have worked. Both would have left the actual problem
in place.
Because the cycle was not really about imports. It was about one thing sitting in the wrong module.
What the cycle was telling me
Pagination — a skip and a limit — is not a property of posts. Tags are paged
the same way. So are an author's posts. It had ended up next to posts only
because posts were the first thing I paged.
Once I saw that, the fix chose itself. Pagination moved to
schemas/pagination.py, at the boundary where both services can read it and
neither has to know the other exists. The cycle disappeared, and it disappeared
because the module graph now matched what the code actually meant.
That is the part worth keeping: a circular import is rarely a problem with the imports. It is a piece of code claiming to belong somewhere it does not.
Making sure it stays fixed
The layout only survives if something checks it. So I wrote a test that walks the real import graph:
core settings, JWT/password crypto
infrastructure database engine, ORM models, S3, email
schemas pydantic shapes at the boundary
services what can be done, and under what conditions
presentation api/ (JSON) and web/ (Jinja pages)
Each layer may import downward or sideways, never up. tests/test_import_graph.py
reads every module, collects its imports, and fails if an edge points the wrong
way.
The point is not that the rule is clever. It is that a rule nobody checks is a comment. Before the test, "services must not import presentation" was something I intended. After it, it is something that fails CI.
I would not have arrived at that layout by reading about clean architecture. I arrived at it because the application refused to start, and the shortest way out happened to be the right one.