Module Boundary Enforcement
===========================
Enforce module boundaries in your modular monolith using static analysis tools and database design patterns. Without enforcement, boundaries erode as developers take shortcuts.
Overview
--------
The modular monolith depends on clear boundaries between modules. Philosophy alone isn't enough. You need tooling that fails the build when boundaries are violated and database patterns that make violations impossible.
This guide covers three enforcement layers:
1. **Import enforcement**: Prevent code in one module from importing internal code from another
2. **Database enforcement**: Prevent direct foreign key relationships between modules
3. **CI integration**: Catch violations before they reach production
Import Enforcement with import-linter
-------------------------------------
`import-linter `_ analyzes your Python import graph and checks it against defined contracts. It uses `grimp `_ to build a complete picture of how modules depend on each other.
Installation
^^^^^^^^^^^^
Add import-linter to your dev requirements:
.. code-block:: text
# requirements/local.txt
import-linter==2.0
Configuration
^^^^^^^^^^^^^
Create a ``.importlinter`` file at your repository root:
.. code-block:: ini
[importlinter]
root_package = {project_slug}
# Contract 1: Domain modules cannot import from each other
[importlinter:contract:module-independence]
name = Domain modules are independent
type = independence
modules =
{project_slug}.users.domain
{project_slug}.orders.domain
{project_slug}.billing.domain
{project_slug}.inventory.domain
# Contract 2: No direct model imports across modules
[importlinter:contract:no-cross-module-models]
name = No direct cross-module model imports
type = forbidden
source_modules =
{project_slug}.orders
{project_slug}.billing
forbidden_modules =
{project_slug}.users.models
{project_slug}.inventory.models
# Contract 3: Layered architecture within modules
[importlinter:contract:layers]
name = Layers are respected
type = layers
layers =
{project_slug}.orders.api
{project_slug}.orders.services
{project_slug}.orders.models
Contract Types
^^^^^^^^^^^^^^
**Independence contracts** prevent modules from importing each other, even transitively. If module A imports module B which imports module C, and A and C are in an independence contract, the check fails.
**Forbidden contracts** block specific imports. Use these for finer-grained rules like "the orders module cannot import user models directly."
**Layers contracts** enforce hierarchical architecture where higher layers can import lower layers but not vice versa. This prevents circular dependencies within a module.
Running import-linter
^^^^^^^^^^^^^^^^^^^^^
Check your contracts manually:
.. code-block:: bash
# From Docker
docker compose -f docker-compose.local.yml run --rm django lint-imports
# Or directly
lint-imports
If violations exist, you'll see output like:
.. code-block:: text
BROKEN CONTRACTS:
No direct cross-module model imports
------------------------------------
{project_slug}.orders.services -> {project_slug}.users.models.User (l. 5)
{project_slug}.orders.services imports {project_slug}.users.models
CI Integration
^^^^^^^^^^^^^^
Add import-linter to your CI pipeline. In GitHub Actions:
.. code-block:: yaml
# .github/workflows/ci.yml
- name: Check import boundaries
run: |
docker compose -f docker-compose.local.yml run --rm django lint-imports
The command returns exit code 1 on violations, failing the build.
Programmatic Testing with grimp
-------------------------------
For custom architectural rules beyond what import-linter contracts support, use ``grimp`` directly in pytest:
.. code-block:: python
# tests/test_architecture.py
import pytest
from grimp import build_graph
@pytest.fixture(scope="session")
def import_graph():
"""Build import graph once per test session."""
return build_graph("{project_slug}")
def test_no_circular_dependencies(import_graph):
"""Verify no circular dependencies exist between top-level modules."""
modules = ["users", "orders", "billing", "inventory"]
for module_a in modules:
for module_b in modules:
if module_a != module_b:
chain = import_graph.find_shortest_chain(
importer=f"{{project_slug}}.{module_a}",
imported=f"{{project_slug}}.{module_b}"
)
if chain:
reverse = import_graph.find_shortest_chain(
importer=f"{{project_slug}}.{module_b}",
imported=f"{{project_slug}}.{module_a}"
)
assert not reverse, (
f"Circular dependency: {module_a} <-> {module_b}"
)
def test_domain_events_only_import_from_allowed_modules(import_graph):
"""Verify domain_events only imports from standard library and base classes."""
details = import_graph.find_modules_that_directly_import(
"{project_slug}.domain_events"
)
# Custom assertion logic here
Lightweight Enforcement with Ruff
---------------------------------
For simpler projects, Ruff's ``flake8-tidy-imports`` rules provide lightweight enforcement without a full import graph:
.. code-block:: toml
# pyproject.toml
[tool.ruff.lint]
select = ["TID"]
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"{project_slug}.users.models".msg = "Import from {project_slug}.users.services instead"
"{project_slug}.orders.models".msg = "Import from {project_slug}.orders.services instead"
This catches common violations but won't detect transitive imports or complex dependency chains.
Database Boundary Enforcement
-----------------------------
Import boundaries prevent code coupling, but database foreign keys create a different kind of coupling. When module A has a foreign key to module B's model, you can't:
- Extract module B to a separate service without complex migrations
- Test module A in true isolation
- Scale module B's database independently
The No-FK Pattern
^^^^^^^^^^^^^^^^^
The Makimo pattern enforces module independence at the database level: **no foreign keys between modules**. Modules reference each other by ID only and communicate through service functions.
.. code-block:: python
# {project_slug}/orders/models.py
from django.db import models
class Order(models.Model):
# Reference user by ID, not FK
user_id = models.IntegerField(db_index=True)
# NOT this:
# user = models.ForeignKey("users.User", on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=50)
Cross-Module Queries
^^^^^^^^^^^^^^^^^^^^
Without foreign keys, you query across modules through service functions:
.. code-block:: python
# {project_slug}/orders/services.py
from {project_slug}.users.services import user_exists, user_get_by_id
def order_create(*, user_id: int, items: list[dict]) -> Order:
"""Create an order for a user."""
# Validate user exists through service, not FK constraint
if not user_exists(user_id):
raise ValidationError("User does not exist")
order = Order.objects.create(user_id=user_id)
# ... create order items
return order
def order_with_user_details(order_id: int) -> dict:
"""Get order with user details for display."""
order = Order.objects.get(id=order_id)
user = user_get_by_id(order.user_id)
return {
"order_id": order.id,
"status": order.status,
"user_name": user.name if user else "Unknown",
"user_email": user.email if user else None,
}
Trade-offs
^^^^^^^^^^
The no-FK pattern has real costs:
**Lost Django ORM features**:
- No ``select_related()`` across modules
- No ``prefetch_related()`` across modules
- No cascading deletes (handle manually or via events)
**More queries**:
Cross-module operations may require additional queries. Mitigate with caching or denormalization where appropriate.
**Benefits**:
- True module independence—extract any module to a service
- Clear ownership—each module owns its data completely
- Testable in isolation—mock the service interface, not the database
- Explicit contracts—the service function signature is the API
When to Apply
^^^^^^^^^^^^^
Apply the no-FK pattern between **bounded contexts**—modules that represent different business domains. Within a single module, foreign keys are fine.
.. code-block:: text
{project_slug}/
├── users/ # User module - FKs within are OK
│ ├── models.py # User, Profile, UserPreferences all interlinked
│ └── ...
├── orders/ # Orders module - FKs within are OK
│ ├── models.py # Order, OrderItem, OrderNote all interlinked
│ └── ... # BUT: Order.user_id is an integer, not FK
Combining Enforcement Layers
----------------------------
For maximum architectural integrity, combine all three layers:
1. **import-linter contracts** catch import violations at commit time
2. **grimp tests** enforce custom rules in your test suite
3. **No-FK pattern** ensures database independence
A typical CI job runs:
.. code-block:: yaml
- name: Architectural checks
run: |
lint-imports # import-linter
pytest tests/test_architecture.py # grimp tests
The no-FK pattern is enforced by code review and the constraint that ``ForeignKey`` to cross-module models simply doesn't work without the import.
See Also
--------
- :doc:`/0-introduction/the-modular-monolith` — Philosophy behind the modular monolith
- :doc:`adding-modules` — How to create new modules
- :doc:`event-driven-architecture` — Cross-module communication without imports
- :doc:`service-layer-patterns` — Organizing business logic