Why are stable packages depending on unstable ones?
When stable packages depend on unstable ones, you violate the Stability Principle. This creates a fragile system where changes in low-level, volatile components force massive rework in critical, high-level services. The fix requires flipping these dependencies so that stable modules remain isolated from volatile implementation details.
The Symptom of Architectural Decay
Recognizing the Violation
In a well-designed system, stability flows from the bottom up. The most stable packages sit at the foundation, while unstable packages sit on top, relying on the stable ones. When you observe a stable package importing classes or interfaces from an unstable package, the architecture has inverted.
This specific pattern is known as a stability violation. It is often hidden within the dependency graph. You might see the stable package referencing methods in a unstable package without knowing it. This usually happens when developers prioritize short-term convenience over long-term structure.
The immediate symptom is a “ripple effect” of errors. When the unstable package undergoes a refactoring to fix a bug or add a feature, every single stable package that depends on it must be reviewed and potentially updated. This makes the core system incredibly fragile.
Why This Happens
Developers often create these violations because the “unstable” package seems to provide a useful utility. They might need a specific class to perform a calculation or format data. Without a clear boundary, the stable core begins to reach out and grab these utilities directly.
Another common cause is legacy code migration. As teams rewrite old systems, they might inadvertently introduce new dependencies between old stable modules and new, evolving unstable modules before the refactoring is complete. The team assumes the dependency is temporary, but it often becomes permanent.
The lack of automated dependency rules allows these mistakes to slip through code reviews. Without strict enforcement, the stable package continues to grow its dependency list, eventually becoming a “God Package” that knows too much about the unstable implementation details.
Root Causes of Stability Violations
Missing Interface Abstractions
A primary cause is the absence of well-defined interfaces. If the unstable package exposes only concrete classes, the stable package has no choice but to depend on them. To prevent this, the unstable module must expose interfaces that the stable module consumes.
This ensures that the stable package relies on an abstraction rather than a concrete implementation. If the implementation changes, the stable package remains untouched. This is the first line of defense in maintaining a healthy stable vs unstable package dependency structure.
Developers often forget to define these interfaces during the initial design phase. They start coding immediately without mapping out the contracts between modules. This leads to tight coupling that violates the dependency rules.
Improper Package Naming and Organization
Sometimes, the violation occurs because the package structure itself is misleading. If the naming convention does not reflect the stability rank, developers may intuitively reach for the wrong module. A package named “Core” should never depend on a package named “Experimental”.
However, if the “Core” package is structured poorly, it might inadvertently import classes from a “Utility” module that is constantly changing. The organization of the files inside the package matters as much as the package hierarchy.
Without clear boundaries, “utility” code often bleeds into the core logic. These utilities are typically unstable because they are used sporadically or are subject to frequent changes. The core logic should not care about their internal state.
Developer Habits and Knowledge Gaps
Less experienced developers often default to direct implementation. They see a class that does what they need and simply add it to the import list. They do not understand the stability metric or the concept of the Dependency Inversion Principle.
This habit creates a culture where stability is ignored in favor of speed. The team might skip the step of refactoring the code to introduce an interface or an adapter. The cost of this speed is technical debt that compounds over time.
Resolution Steps
Step 1: Audit the Dependency Graph
Begin by visualizing your package dependencies. Use tools like `dependency-check` or IDE plugins to generate a graph. Identify all instances where a low-abstraction (stable) package imports from a high-abstraction (unstable) package.
List the specific classes and methods causing the violation. You need to see exactly which parts of the stable core are touching the unstable modules. This data will guide your refactoring strategy.
Step 2: Move the Dependency Up
The goal is to move the dependency to the unstable side. If the stable package needs a feature from the unstable package, the unstable package must define the interface for that feature. Move the interface definition into the stable package.
This forces the unstable package to implement the interface defined by the stable one. This inversion ensures that the stable package defines the contract, and the unstable package merely fulfills it. This is the key to fixing the stable unstable package dependency issue.
If the stable package depends on a specific algorithm, define an interface for that algorithm in the stable package. Then, implement that interface in the unstable package. The stable package no longer knows about the implementation details.
Step 3: Extract Common Contracts
Sometimes, the functionality is needed by both stable and unstable packages. In this case, extract the common logic into a shared interface. This shared contract should reside in the stable package to maintain the direction of the dependency.
Ensure that the shared contract is minimal and focused. Avoid putting business logic into the interface. Interfaces should only define the behavior, not the implementation details. This keeps the stable core clean and decoupled.
Step 4: Enforce with Build Tools
Configure your build tools to prevent violations from being introduced in the future. Most modern build systems allow you to define rules for package imports. Set a rule that disallows imports from unstable packages into stable ones.
This acts as a gatekeeper. If a developer tries to import a class from an unstable module into the stable core, the build will fail. This forces the developer to reconsider the design and find an alternative, such as an interface.
Consider using static analysis tools to scan the codebase regularly. These tools can catch potential violations before they reach production. They provide a report highlighting exactly where the stability principle is being breached.
Advanced Scenarios and Alternatives
Handling Circular Dependencies
Occasionally, fixing a stability violation creates a circular dependency. This happens when the stable package depends on the interface (in itself) and the unstable package depends on the stable package to run. This creates a cycle that prevents compilation.
To solve this, introduce a middle layer or a dependency injection container. The container holds the instance of the unstable implementation. The stable package requests the instance via the container rather than importing the class directly.
Alternatively, use the Abstract Factory pattern. This creates a factory class in the unstable package that produces instances of the interface. The stable package only interacts with the factory, breaking the direct coupling.
When to Use the Stable Abstractions Principle
The principle suggests that the stable packages should use the interfaces defined in the unstable packages, but only if the unstable packages are truly stable. If a package is unstable, it should not define the interface for a stable one.
However, sometimes the “unstable” package is actually the most complex one. In this case, it might be better to move the interface to a third, completely neutral package. This neutral package becomes the anchor for both sides, ensuring no single side becomes too coupled.
Summary of Actions
Action: Refactor Interface Placement
Move interface definitions into the stable package. This ensures that the stable package controls the contract. The unstable package must then implement this contract.
Action: Block Direct Imports
Configure your build process to block direct imports from unstable packages. This prevents developers from bypassing the architecture rules. Use strict package access levels to enforce this.
Action: Review Existing Code
Perform a full audit of existing dependencies. Identify all violations and prioritize them based on impact. Focus on the core modules that affect the most other parts of the system.
Key Takeaways
- Stability Violation: Stable packages depending on unstable ones creates fragile, unmaintainable architecture.
- Interface Ownership: Stable packages should define interfaces; unstable packages must implement them.
- Build Enforcement: Use build tools and linters to prevent dependency violations automatically.
- Dependency Inversion: Relying on abstractions rather than concrete implementations is the best solution.
- Root Cause: Missing abstractions and poor package naming are primary drivers of these violations.