Welcome to the next step in our journey toward mastering maintainable software architecture. In the previous lesson, we surveyed the common strategies for breaking circular dependencies, from tactical Python fixes to more profound architectural patterns like Dependency Inversion. You now have a theoretical map of the solutions.
Today, we transition from theory to practice. This lesson is dedicated to applying that knowledge to a concrete problem. We will take a small Python codebase crippled by a circular import and refactor it—not just once, but multiple times—using different strategies we've discussed. This exercise is designed to formalize your hands-on experience by demonstrating the trade-offs between a quick fix and a structurally sound, maintainable design.
Our goal is to refactor a codebase to eliminate a circular dependency. By doing so, you'll gain a deeper intuition for when to apply each type of solution, a critical skill for planning robust software.
The Problem: A Library System's Vicious Cycle
Let's start with a scenario. Imagine you're building a system to manage a library. You've created two core modules: models.py to define your data structures (like the Book class) and database.py to handle persistence.
Here's the initial, problematic code:
models.py
# models.py
from database import save_book # <-- Problem import
class Book:
def __init__(self, title: str, author: str):
self.title = title
self.author = author
def save(self):
print(f"Model '{self.title}' is requesting to be saved.")
save_book(self)
database.py
# database.py
from models import Book # <-- Problem import
def save_book(book: Book):
# In a real system, this would write to a DB.
print(f"Database is saving book: '{book.title}'.")
def get_book_by_title(title: str) -> Book:
# In a real system, this would read from a DB.
print(f"Database is fetching book: '{title}'.")
return Book(title=title, author="Fetched Author")
If you try to run any code that imports from models, Python will raise an ImportError. The traceback reveals the cycle: models.py tries to import database.py, which in turn tries to import models.py before the Book class has been defined.
This is the exact issue described in the dev.to article we've been using. Before we proceed, take a moment to refresh your memory on why this happens.
The Circular Import Problem: Breaking Dependency Cycles
This resource explains the mechanics of Python's import system and why circular dependencies cause an ImportError.
Please review the section explaining the execution sequence. Focus on how sys.modules contains a "partially initialized module" which leads to the error.
Now, let's fix this. We'll explore three solutions, from a quick tactical fix to a robust architectural refactoring.
Solution 1: The Tactical Fix (Late Import)
The fastest way to make the error disappear is to delay the import until it's actually needed. In models.py, the save_book function is only used inside the save method. We can move the import statement into that method.
Refactored models.py (Late Import)
# models.py
# No import at the top level!
class Book:
def __init__(self, title: str, author: str):
self.title = title
self.author = author
def save(self):
from database import save_book # <-- Import moved here
print(f"Model '{self.title}' is requesting to be saved.")
save_book(self)
The database.py file remains unchanged.
This works because the from database import save_book line is no longer executed when the models module is first loaded. It's only executed when book.save() is called, by which time both models.py and database.py have been fully initialized. The cycle at module load time is broken.
Evaluation:
- Pros: Quick, easy, and localized fix.
- Cons: It obscures the dependency. Someone reading
models.pyat the top level won't immediately see that it depends ondatabase.py. As the mCoding video from our last lesson cautioned, this can be a "code smell" if used excessively, as it hides your system's true dependency structure. It fixes the symptom, but not the underlying design flaw.
Solution 2: Structural Refactoring (Module Extraction)
A circular dependency often means that your modules are not organized correctly. In our case, models.py and database.py are tangled because the database module knows about the specific Book model. A better design would have a clear, one-way flow of dependencies.
A common pattern is:Application Logic -> Persistence Layer -> Domain Models
Our current structure is flawed:models.py <-> database.py
Let's fix this by extracting the Book class, which is a core domain entity, into its own file that has zero dependencies. We can call this domain.py.
-
Create a new file:
domain.py# domain.py # This file has NO imports. It is a pure data structure. class Book: def __init__(self, title: str, author: str): self.title = title self.author = author -
Refactor
database.pyto depend ondomain.py# database.py from domain import Book # <-- Depends on domain def save_book(book: Book): print(f"Database is saving book: '{book.title}'.") def get_book_by_title(title: str) -> Book: print(f"Database is fetching book: '{title}'.") return Book(title=title, author="Fetched Author") -
Refactor
models.pyto beservices.pyand depend on both
The namemodels.pyis now confusing. It contained both the data model (Book) and some business logic (thesavemethod). Let's rename it to something more appropriate, likeservices.py, which will now orchestrate operations.# services.py (renamed from models.py) from domain import Book from database import save_book class BookService: @staticmethod def create_and_save_book(title: str, author: str): book = Book(title=title, author=author) print(f"Service is requesting to save book '{book.title}'.") save_book(book)
Our new dependency graph is now a Directed Acyclic Graph (DAG):
services.py->domain.pyservices.py->database.pydatabase.py->domain.pydomain.py-> (nothing)
The cycle is gone. This is a far more maintainable structure.

Solution 3: Architectural Inversion (DIP)
The most robust solution involves applying the Dependency Inversion Principle (DIP). The core issue in our original design is that a high-level policy module (models.py) depends on a low-level implementation detail (database.py). DIP tells us that both should depend on abstractions.
In Python, we can define this abstraction using a Protocol.
-
Define an abstraction in a new
interfaces.pyfile
This file defines the contract that our persistence layer must fulfill.# interfaces.py from typing import Protocol # We use a forward reference string 'Book' to avoid a new import cycle here. # Alternatively, the Book class could be in this file if it's simple. from __future__ import annotations class BookRepository(Protocol): def save_book(self, book: 'Book'): ... def get_book_by_title(self, title: str) -> 'Book': ... # We also need the Book definition somewhere stable. Let's place it here too. class Book: def __init__(self, title: str, author: str): self.title = title self.author = author -
Refactor
models.pyto depend on the interface
TheBookmodel now operates against theBookRepositoryabstraction, not a concrete database implementation.# models.py from interfaces import Book, BookRepository # Depends on the abstraction class BookService: def __init__(self, repository: BookRepository): self.repository = repository def create_and_save_book(self, title: str, author: str): book = Book(title=title, author=author) print(f"Service is requesting to save book '{book.title}'.") self.repository.save_book(book) -
Refactor
database.pyto implement the interface
Thedatabase.pymodule now provides a concrete implementation of theBookRepositorycontract.# database.py from interfaces import Book, BookRepository # Depends on the abstraction class DatabaseHandler: # Implements the BookRepository protocol implicitly def save_book(self, book: Book): print(f"Database is saving book: '{book.title}'.") def get_book_by_title(self, title: str) -> Book: print(f"Database is fetching book: '{title}'.") return Book(title=title, author="Fetched Author")
The dependency has been inverted. Instead of models -> database, we now have models -> interfaces and database -> interfaces.

This approach is powerful. Your high-level logic in models.py is now completely decoupled from the database. You could swap in a different database implementation (e.g., for testing, or migrating from Postgres to Mongo) without changing a single line of code in models.py.
Connection to Rust
These principles translate directly to Rust.
- Structural Refactoring: You would organize your crates and modules to ensure a DAG, just as we did in Solution 2. A
domaincrate with no dependencies is a very common and effective pattern. - Dependency Inversion: You would use a
traitto define theBookRepositoryinterface. TheBookServicestruct would hold a genericT: BookRepository, and theDatabaseHandlerstruct would implement that trait (impl BookRepository for DatabaseHandler). The principle is identical. - Callback Cycles: As we saw in the previous lesson, logical cycles in Rust often appear with parent-child relationships and callbacks. The solutions—passing state as arguments or using an event-as-data model (as detailed in the
qouteallarticle)—are forms of decoupling, much like DIP. They break a tight, cyclical coupling by introducing a level of indirection (arguments or an event queue).
Conclusion
We have successfully refactored a problematic codebase in three different ways, moving from a quick fix to a fundamental architectural improvement.
Key Takeaways:
- Refactoring is a choice: You can choose a tactical fix like a late import to get unblocked quickly, but you incur technical debt by obscuring dependencies.
- Structure dictates maintainability: A clean, one-way dependency flow, achieved by extracting shared components into lower-level modules, is a massive step towards a maintainable system.
- Abstractions are powerful: Using the Dependency Inversion Principle to make high-level modules depend on interfaces (Protocols in Python, traits in Rust) rather than concrete implementations is the gold standard for decoupling. It makes your system more flexible, testable, and resilient to change.
The presence of a circular dependency is an architectural signal. By learning to address the root cause rather than just the symptom, you are building the skills necessary to design truly professional, long-lasting software.
In our final solution, BookService depended on an abstract BookRepository. But how does it get a concrete repository to work with? The process of providing concrete dependencies to components that require abstractions is called Dependency Injection. In our next lesson, we will dive deep into how to implement this pattern cleanly in both Python and Rust.