Circular Import Rules
Status: Mandatory. Apply before planning any new module, class, or cross-module type hint. These rules are enforced by
scripts/no_type_checking.pyon every commit.
Why circular imports are architectural failures
A circular import is not a Python triviality — it is a symptom that two modules have been assigned responsibilities that should live in a third, lower-level module that neither of them owns.
When A imports B and B imports A, Python's import system loads one of them
in an incomplete state. The runtime consequence varies — sometimes ImportError,
sometimes a None attribute, sometimes silent wrong behavior — and the cause is
invisible in normal stack traces. More importantly, the circular dependency means
the modules cannot be tested, versioned, or deployed independently.
The three root causes (every circular import is one of these):
-
Shared type placed in the wrong module.
AdefinesFoobutBneeds it;Aalso needs something fromB, creating the cycle. Fix: moveFooto a neutral moduleCbelow both. -
Bidirectional knowledge.
AknowsB's public interface andBknowsA's public interface. This means neither module has a single clear abstraction boundary. Fix: introduce a Protocol so one side depends only on the abstraction. -
Feature Envy. Strategy
SaccessesHost._private,Host.backend,Host.telemetry. The strategy is doing the host's job. Fix: expose the needed surface via a Protocol that the host satisfies; the strategy depends only on that Protocol.
BANNED workarounds
These approaches are forbidden in src/nala/athomic/. They silence the symptom
and hide the design failure — the cycle still exists at runtime.
| Workaround | Why it is banned |
|---|---|
if TYPE_CHECKING: from X import Y |
Imports only at type-check time. At runtime, the name is undefined. The cycle still exists; mypy pretends it doesn't. |
from __future__ import annotations |
Defers ALL annotation evaluation to strings. The cycle still exists at import time for any non-annotation use. Zero architectural value. |
KNOWN_OPEN whitelists |
A list that says "we know this is broken" is not a fix. Every entry in the list is technical debt that actively degrades the layer boundary enforcement. |
String-quoted types: Optional["Foo"] |
Same as TYPE_CHECKING — defers evaluation without fixing the cycle. |
The three valid fixes
Fix 1 — Extract a Protocol into a neutral module
When to use: A depends on B and B depends on A because A calls methods on B (or vice versa), and neither can be the "lower" layer.
Pattern:
# Before (circular):
# A imports B for type hint only
# B imports A to use it as a parameter type
# After:
# neutral/protocol.py ← new, imported by both A and B
class BProtocol(Protocol):
def needed_method(self) -> str: ...
# A uses BProtocol, no longer imports B directly
# B implements BProtocol implicitly (structural subtyping) — never imports A
Rules: - The Protocol lives in a module below both A and B in the layer order. - The Protocol is the minimum surface the caller needs — ISP applies here. If callers only use 2 of 5 methods, split the Protocol. - B never imports A. B simply satisfies the Protocol structurally. - The composition root (factory/wiring) is the only place that knows both A and B.
Real example from this codebase (pending fix):
- producers/strategies/protocol.py imports BaseProducer from producers/base.py
- producers/base.py imports PublishingStrategyProtocol from strategies/protocol.py
- Fix: extract ProducerTransportProtocol with only the surface strategies actually use
(backend, service_name, tracer, payload_processor, key_header_serializer,
telemetry_adapter, dispatch_bytes). Strategies depend on ProducerTransportProtocol.
BaseProducer satisfies it structurally. Neither imports the other.
Fix 2 — Move shared types to a neutral module
When to use: The cycle exists only because a dataclass, enum, or named tuple is defined in A but also needed by B, and A needs something from B.
Pattern:
# Before:
# A defines FleetSnapshot — B needs it — A also needs something from B
# After:
# shared/snapshots.py ← pure data, no service imports
@dataclass(frozen=True)
class FleetSnapshot:
pressure: float
latency_p99: float
# A imports FleetSnapshot from shared/snapshots — no longer imports B
# B imports FleetSnapshot from shared/snapshots — no longer imports A
Rules: - The neutral module contains only data — dataclasses, enums, TypedDicts, NamedTuples. - It imports nothing from any service or infrastructure module. - It lives at the lowest applicable layer so both A and B can import it without violating layer order.
Real example from this codebase (pending fix):
- pressure/composer.py → algorithms/telemetry.py → pressure/fleet.py → pressure/__init__.py → pressure/composer.py
- Fix: extract pressure/snapshots.py with FleetPressureSnapshot and PressureIndex.
Both sides of the cycle import from snapshots.py only.
Fix 3 — Restructure the dependency direction
When to use: There is no real bidirectional dependency — one side has a redundant or incorrect import. The lower-level module imported something that it does not actually need from the higher-level one.
Pattern:
# Before:
# base.py imports ConsumptionStrategyProtocol just for a type hint in a method signature
# ConsumptionStrategyProtocol imports BaseConsumer
# After:
# base.py uses a more abstract type already available at its layer (e.g., BaseService)
# ConsumptionStrategyProtocol keeps its own parameter type narrow
Rules: - Determine the actual call direction. Usually one import is unnecessary. - Use the most abstract type available that satisfies the real contract. - Never import upward in the layer order. The layer graph is a DAG.
Real example from this codebase (pending fix):
- retry/processor_service.py imports BaseConsumer for a type hint where BaseService
(already at a lower layer) is sufficient.
Decision tree: "I need to reference type X from module A, but X is in module B, which imports A"
START: Module A needs to reference type X from module B, but B also imports A.
1. Does A only need X for a type hint (parameter, return, attribute)?
└─ YES → Does B's import of A cross a layer boundary or create Feature Envy?
├─ YES (B accesses A's internals / private members)
│ └─ EXTRACT A PROTOCOL (Fix 1): surface only what B needs from A,
│ place the Protocol below both in the layer order.
└─ NO (B just needs a data shape from A)
└─ Is X a pure data type (dataclass, enum, NamedTuple)?
├─ YES → MOVE X TO NEUTRAL MODULE (Fix 2):
│ place it below both A and B.
└─ NO → RESTRUCTURE DIRECTION (Fix 3):
check whether B genuinely needs A's concrete type
or whether a more abstract type already below B suffices.
2. Does A need X at runtime (instantiation, method call)?
└─ This is Feature Envy. A is doing B's work.
→ EXTRACT A PROTOCOL (Fix 1): A depends on the Protocol; B satisfies it.
The concrete wiring happens at the composition root (factory).
3. None of the above apply?
└─ The import is wrong. Remove it and redesign.
Layer order (one-way only)
facade → ai → workflows → streaming → integration → http → plugins
→ performance → security → resilience → database
→ serializer → observability → config
Every arrow is one-way. A lower-level module never imports from a higher-level one. If you find yourself needing to do so, the type or abstraction belongs in a lower layer.
Checklist before adding any cross-module type reference
- [ ] Does the import respect layer order (higher → lower only)?
- [ ] Is the referenced type a Protocol or pure data type?
- [ ] Could the type live in a neutral module below both sides?
- [ ] Does the import create any cycle (direct or transitive)?
- [ ] Is this a genuine need, or Feature Envy (accessing private members)?
- [ ] Is
TYPE_CHECKINGanywhere in this file? If yes, stop and apply Fix 1, 2, or 3.
Enforcement
scripts/no_type_checking.py runs on every commit via pre-commit hooks. It:
- Parses all *.py files in src/nala/athomic/ via Python AST.
- Rejects any if TYPE_CHECKING: block not in KNOWN_OPEN.
- Prints the exact remediation steps (this document).
Do not add entries to KNOWN_OPEN. Every entry is existing debt. The goal is to
reduce KNOWN_OPEN to zero and then delete the whitelist entirely.