A circular import told me where I should start my app

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.

All posts