Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 10

Breaking Circular Dependencies

Recommended resources

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.py at the top level won't immediately see that it depends on database.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.

  1. 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
    
  2. Refactor database.py to depend on domain.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")
    
  3. Refactor models.py to be services.py and depend on both
    The name models.py is now confusing. It contained both the data model (Book) and some business logic (the save method). Let's rename it to something more appropriate, like services.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.py
  • services.py -> database.py
  • database.py -> domain.py
  • domain.py -> (nothing)

The cycle is gone. This is a far more maintainable structure.

This diagram illustrates the benefits of refactoring. The 'BEFORE' state with circular imports leads to slow boot times and dependency errors. The 'AFTER' state, with a clean, optimized import order, results in significantly improved performance and maintainability.

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.

  1. Define an abstraction in a new interfaces.py file
    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
    
  2. Refactor models.py to depend on the interface
    The Book model now operates against the BookRepository abstraction, 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)
    
  3. Refactor database.py to implement the interface
    The database.py module now provides a concrete implementation of the BookRepository contract.

    
    
    
    # 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 graph shows the result of applying DIP. Instead of a direct cycle, both 'app' (our `models.py`) and 'module2' (our `database.py`) now depend on a shared abstraction (`module2-contract`, our `interfaces.py`), breaking the cycle and promoting loose coupling.

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 domain crate with no dependencies is a very common and effective pattern.
  • Dependency Inversion: You would use a trait to define the BookRepository interface. The BookService struct would hold a generic T: BookRepository, and the DatabaseHandler struct 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 qouteall article)—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.