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:

# requirements/local.txt
import-linter==2.0

Configuration

Create a .importlinter file at your repository root:

[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:

# 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:

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:

# .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:

# 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:

# 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.

# {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:

# {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.

{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:

- 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