Where one piece of code depends on another piece of code -- whether by inheriting from it, by calling it, or just by mentioning it -- the two are coupled. By definition a system needs some coupling between its parts; otherwise it is no more than a set of disconnected parts rather than a system. But too much coupling and a system becomes hard to understand, hard to integrate, hard to extend, hard to fix, hard to test. In short, it becomes hard. If software is supposed to be soft, dependency management is one of the things that will allow it to be so.
Away from the vibrant discussion of the blogosphere and the considered advice found in many articles and books, the assumption that developers follow (or are even aware of) various best practices and discussions about design techniques does not hold as often as the well-informed developer crowd might believe, wish, or wish to believe. A lot of code is still written in monolithic style, with logic lumped together in roughly hewn, imposing slabs of code.
Such problems of code quality are often addressed by partitioning code into smaller units. There are many ways to break up and organize the general big picture of the system. But inevitably, in making any separations, there is also a need to make connections. As Edsger Dijkstra observed, "Separation of tasks is a good thing; on the other hand, we have to tie the loose ends together again!"
For example, from a deployment perspective, dynamic link libraries (DLL) offer distinct units of execution. Although they are discrete, they still have usage dependencies on one another. From a development perspective, one of the most common ways to partition a system is with respect to classes of objects. Again, although they are nameable and distinct, classes must still be related — by inheritance, association, or instantiation — otherwise nothing much is going to happen. The same applies to data in databases: In the relational model, data is partitioned across rows in tables. But the tables are not just buckets of arbitrary data (or at least they're not supposed to be); they are related through keys.
It is not enough just to have mechanisms for partitioning. The class keyword does not automatically bless code with good design qualities, and keys in a database do not automatically unlock good data models from a mass of data. Partitioning needs to be done with sensibility. In relational models, the most common guideline is to follow third normal form (3NF) — attributes should depend on the key, the whole key, and nothing but the key (so help me Codd). Of course, 3NF is not the only way to structure data, but it is nonetheless a common and practical form of dependency management.
In other technical domains the partitioning guidelines are not always so obvious or as easily summarized, but that does not mean that such guidelines do not exist. For example, it is possible to apply the concept of normalization to code as well as to data. Duplication in code is often a candidate for re-factoring: Extracting a new class or a new method that holds the common code introduces a partitioning in the code, as well as coherent dependencies upon it. Groups of interrelated function arguments can be grouped into a single construct, reducing the length of potentially long argument lists. A common and simple example would be to replace passing around three individual integers representing a date with a single date object.
Interfaces, whether for classes, procedural APIs, or distributed services, can often suffer from over featurism, becoming a bucket for unrelated operations. Such broad interfaces can unnecessarily increase the surface area of dependency for any client of the interface. Breaking up the interface according to role and common usage — so the dependency is on the interface, the whole interface, and nothing but the interface — offers a simple way of rationalizing and sorting through disparate features. This style applies just as much to header files in C as it does to packages and classes in Java.
Another consideration in managing dependencies is the question of change. The dependency structure of a system can enable or discourage change, which makes dependency management a question of architecture, not merely one of fine-grained code quality. In Structured Programming Edsger Dijkstra noted:
Our program should not only reflect (by structure) our understanding of it, but it should also be clear from its structure what sort of adaptations can be catered for smoothly. Thank goodness the two requirements go hand in hand.
However, just because requirements for clear structure and adaptability go hand in hand, it does not mean that they are automatically fulfilled. Such attention to structure and change is honored more in the breach than in the observance.
For example, code that makes frequent use of global state, such as singleton objects, tends to resist change more than code without. Class hierarchies that are based on reuse of common code become harder to work with and evolve than class hierarchies that are based on classification and interface usage. Systems that ensure access to external dependencies via interface types rather than concrete types offer better opportunities for extension and isolation of change.
Agility in architecture
If a rigid architecture is one that resists change by amplifying the effort (and therefore cost) involved in change, an agile architecture is one that that affords change more easily. Dependency management is key for sustainability in architecture. Using the term component in its most general sense (any constituent unit in a software system, whether executable component, package, class, function, or file), such architectures tend to share a number of common dependency-related properties:
- Dependencies on external components, technologies, and systems are encapsulated and separated in such a way that the logic of the core code is testable without needing the externals needing to be present. From a unit-testing point of view, this means that it is easy to substitute test doubles, such as mocks. From a general point of view, it means that when the database server goes down development work can continue.
- There is an identifiable and coherent large-scale partitioning, such as packaging, layering, etc. Importantly, dependencies across the large-scale partitioning are acyclic — a notionally layered arrangement that contains dependency loops is not actually layered, although it is still partitioned.
- Dependencies are localized, so that the dependency horizon of a component is close rather than distant — following the chain of dependencies out from a component should not encompass large tracts of the system. In languages such as Java this normally involves use of explicit interfaces at significant boundaries, at the root of class hierarchies, at points of behavioral variation, and so on.
- Components are cohesive, sufficient, and distinct rather than overburdened with a mish-mash of responsibilities and speculative features.
- Stable components do not depend on less stable components and components that change together are grouped together. This view of stability also encourages sufficiency in design and the use of explicit interfaces to emphasize separation.
This list is overlapping and by no means exhaustive, but it serves to highlight some of the practical implications of the common but relatively abstract guideline that system components should be loosely coupled and highly cohesive.
Another concrete way of looking at such architectures is that they have a high degree of locality. In Implementation Patterns Kent Beck offers the following guideline:
Structure the code so changes have local consequences. If a change here can cause a problem there, then the cost of change rises dramatically. Code with mostly local consequences communicates effectively. It can be understood gradually without first having to assemble an understanding of the whole.
Feed back to go forward
It is all very well to highlight principles and their outcomes when focusing on the happy-day scenario — you have influence over code not yet written — but this does not reflect the rainy-day reality of many systems. A tangle of dependencies may have been created in the past that is causing problems in the present. What hope for the future? And, even in the happy-day scenario, how confident are we that we can predict the future anyway, even given an apparently clean sheet?
Any practical approach to dependency management needs to be continuous and responsive in nature. It cannot sensibly be based on grand planning at the start of development or stopping the whole development process for a cycle of management-visible architectural maintenance (having such work appear on the radar is often not a good sign). There is no silver bullet to dependency management, but there are a few sensibilities that will support any dependency management effort:
- Practical awareness of dependency management guidelines. In other words, something more concrete than just "low coupling, high cohesion."
- A culture of managing dependencies in new code.
- Using uncertainty as a driver, so that difficult or unstable decisions are taken as a hint to partition.
- Feedback mechanisms that highlight emerging or existing dependency-related problems. Unit testing, code reviews, static analysis, architectural retrospectives, and casual observations from outsiders and newcomers can offer feedback on the general health of a code base.
- The opportunity and knowledge to respond to any perceived problems. Being able to see the problem is only half the story; without the ability to follow through to a solution the learning is wasted.
You must also consider recurrence and continuity. Like other forms of management, dependency management is not just a single-shot affair.
About the author: Kevlin Henney is an independent consultant and trainer based in the UK. His work focuses on software architecture, patterns, development process and programming languages. He is a coauthor of A Pattern Language for Distributed Computing and On Patterns and Pattern Languages, two recent volumes in the Pattern-Oriented Software Architecture series. You may contact him at email@example.com.