Published May 8, 2020
by Doug Klugh
Avoid this design smell by building loosely coupled software systems through effective dependency management. A Viscose system is one in which development activities (such as building, testing, and deploying) are difficult to perform and take a long time to execute. When components span multiple layers of a system, it is often viscose because even the simplest change is costly to make. Apply the Component Design Principles to help manage the structure of large object-oriented systems.
Engineering Structural Quality
Following are a few engineering principles that promote structural quality — most of which promote cohesion:
Every object should have a single responsibility and that responsibility should be entirely encapsulated by the class.
Write code that is open for extension but closed for modification.
Build classes in such a way that a derived class can always replace its base class without changing behavior or side effects of the base class.
Don't force classes, modules, or components to depend on (or even know about) methods they don't need.
High level policies should not depend on low-level details.
Components need to be large enough to justify the cost of managing the release cycle.
Classes that change for the same reason should be grouped together in the same component.
Create components where if one class in the component is used, they’re all used.
Design your software in such a way that the dependencies between your components do not form a cycle. The resulting component dependency structure will form a Directed Acyclic Graph — such as a tree structure. Draw your component graph as a tree and make sure all the arrows point downward. Then rest assured that your dependencies do not form a cycle.
Components should depend upon each other in the direction of increasing stability.
The more stable a component is, the more abstract it should be — so that it can conform to the Open/Closed Principle.
Manage side effects within your code by controlling how and where they occur. Write functions such that if they change state, they do not return values. And if they return values, they do not change state. For example, instead of returning error codes, throw exceptions to indicate a failed state change (such as a database update or user login).
Build code that has limited knowledge of other parts of the system and interacts directly only with closely related classes. Methods should be protected from understanding the internal structure of other methods or classes. This is also known as the Principle of Least Knowledge, a proven strategy for promoting information hiding and building high quality, loosely coupled software. This can be achieved through the practice of Tell, Don't Ask.
Building resiliency into your system by identifying exceptions in the requirements, then defining exception handlers during analysis.
Write code in a way that makes it easy to read and easy to understand. Reading code is an inevitable part of maintaining and extending an existing code base. Since developers spend much more time reading code than they do writing it, writing readable code contributes greatly to productivity. Applying many of the prior DevTips will help to achieve effective abstraction, encapsulation, and Separation of Concerns that will greatly promote readability.
Build software that is flexible, reliable, and easy to understand. In other words, build software that is simple. Do not over-engineer the initial design (YAGNI). Start with the simplest solution, then work towards complexity (only as warranted). Ensure your code passes all of its tests, reveals its intent, contains no duplication, and is as small as possible. Writing simple code can often be difficult. It is not about writing code by the simplest means. Simple != easy.
Deliver good software by releasing code that is free from defects in both structure and behavior. A good structure that is free from defects will easily support and encourage changing requirements. It is more important to make software right than it is to make it work. Software that is Flexible can easily be made to work and support long-term enhancements. However, software that works but is Rigid will be difficult to change and will inhibit your ability to support your customers’ future needs.
To decouple functionality, write methods that tell objects what to do. Do not ask for their state, then perform functions on behalf of those objects. This will reduce query functions, encapsulate knowledge, and ensure compliance with The Law of Demeter. While not always feasible, this should be followed as much as possible to promote adaptability.
Design and implement solutions that are needed today; do not invest in capabilities or features that may never be needed. While this may require more effort tomorrow, time is more often wasted by investing in requirements that change or become irrelevant. Building for tomorrow will likely slow down delivery for today. Keep your codebase as small as possible. This will promote simplicity of design, implementation, and validation.
Build abstract classes to encapsulate the responsibility of creating and composing families of related objects. This will help control the classes of objects that a system creates and promotes independent deployability by enabling the system to create objects without depending on those objects. This also enforces consistency among classes of objects by forcing the system to use objects from only one family at a time. This approach enables you to swap factories in and out at runtime, providing real-time flexibility as to how objects are created.
Extend functionality by adding properties and behaviors to individual objects instead of entire classes. While inheritance can be used to statically add responsibilities to every subclass instance, decorating individual objects dynamically provides much greater flexibility. This is achieved by enclosing a component within a decorator, which conforms to the interface of the component and provides transparency to the clients of that component. This transparency enables an unlimited number of added responsibilities.
Reduce software complexity by minimizing the communication and dependencies between subsystems. By interposing a Facade object between low-level objects and their clients, you can control what the clients can do and how the low-level objects can respond. This helps to maintain high-level abstraction when applying the Single Responsibility Principle. The clients will have no knowledge that functionality has been encapsulated within smaller objects, each having only one responsibility. The functions represented by the facade can be implemented across numerous objects without the clients ever knowing.
Define an interface for creating objects so that the instantiation of new objects is delegated to methods within the implementing class. This will decouple application-specific classes from your code and enable those factory methods to provide extended versions of objects. Unlike the Abstract Factory Pattern, it is not necessary to instantiate separate factory classes. The downside is you're stuck with just one type of factory and you cannot change it at runtime.
Use the Strangler Pattern to build reusable software by enhancing existing behavior, creating better abstractions, and replacing code that is not quite right. This is an effective method for eliminating technical debt and adhering to the Open/Closed Principle when your code is used by clients outside of your system. Use an [Obsolete] attribute to generate compiler warnings notifying clients that a particular class will eventually be deprecated.
Keep your functions small by extracting code fragments into their own functions. If you have to spend even a small amount of time figuring out what a code fragment does, then it should be extracted into its own function and given a name that describes what it does. Then when you read it again, the purpose of the code should be obvious. This will clarify the intention of your code (separate from the implementation), while making it self-documenting, more maintainable, and more testable.
Use local variables to name complex expressions (or portions of them) to help decompose those expressions into more manageable pieces of logic and provide clarity around the purpose of that code. These simple abstractions help to simplify those expressions, making them easier to read and easier to understand — not to mention how easy it is to provide a hook for debugging.
Refactor code before optimizing to make your software more adaptable to performance tuning. Building software that is well-factored without attention to performance will produce finer granularity for performance analysis — providing effective identification of performance hot spots. Even when refactoring impacts performance, you can apply more effective performance-tuning enhancements with well-factored code. First, write clean code that facilitates performance tuning, then optimize for time and footprint.
Refactor your code by grouping related statements together. This will promote Readability while setting you up for the next refactor — usually Extract Function. It is important to examine the code you are sliding and the code you are sliding over to ensure they do not interfere with each other in a way that would change the observable behavior of the system. As always, ensure reliable test coverage prior to refactoring and rerun tests often.
When a loop is doing multiple things, split it into multiple loops with each doing just one thing. If you need to modify one of the loops, this will ensure that you need to understand only the behavior that you need to modify. This also sets you up for the next refactor — usually Slide Statements or Extract Function. While this forces iterations of multiple loops, it is important to first Refactor, Then Optimize. If the loops turn out to be a performance bottleneck (which is rarely the case), it is easy enough to merge them back together.
As software is developed by making behavioral and structural changes, do not attempt to do both at the same time. Wear one hat to add new capabilities (including tests) without changing existing code, then wear the other hat to restructure the code without changing behavior. Build software by swapping hats frequently to develop and test very small capabilities, then refactor to improve the design and the overall quality of the code.
Verify the expected behavior of unit and component tests by comparing them to the indirect outputs of the System Under Test (SUT) as they occur. This is necessary when the expected results are transient and cannot be verified through State Verification. This requires us to intercept the behavior at an observation point between the SUT and a dependent component. Procedural Behavior Verification can be used to capture indirect outputs of the SUT using Spy Objects. Otherwise, we can load the Expected Behavior Specification into Mock Objects to verify the method calls.
Define the state of your test environment by designing and building a test fixture that supports a single run of a single test. Build a Transient Fixture when its lifetime should persist no longer than the duration of the test method and is initialized prior to each method. Building a Persistent Fixture that survives the life of the test must be torn down within the test method to avoid side effects and keep the fixture fresh.
Avoid conditional test logic by replacing if statements with assertions that fail when the conditions are not satisfied, preventing execution of statements that would cause test errors. This will promote Defect Localization, document pre-conditions enforced by the guard assertions, and minimize the effort required to verify your tests. If you have to write tests for your tests, when would it ever stop?
Write tests such that each produces the same result from a given initial state without any manual intervention between runs. Unit tests must verify Single Test Conditions by executing a single code path through the System Under Test (SUT) and executing the same, exact code path each time it runs. Verifying one condition for each test helps to minimize Test Overlap and ensures we have fewer tests to maintain if we later modify the SUT. Isolating the SUT ensures that we only have to focus on code paths through a single object.
Build tests in such a way that the number of tests impacted by any change is small. This is achieved by minimizing overlap and dependencies between tests and ensuring changes to the test environment have no affect on our tests. Isolate the System Under Test (SUT) and verify just one condition for each test. Changes that affect test fixtures should be encapsulated behind test utility methods.
Define the state of your test environment by designing and building a Persistent Test Fixture to share system resources across a suite of tests. This will help optimize test execution by managing resources that are expensive to allocate (such as database connections) that can be setup once and shared across multiple tests. Be sure not to persist resources with side effects as this will create coupling between tests and inhibit independent and concurrent testing.
When writing Self-Checking Tests, inspect the state of the System Under Test (SUT) after it has been exercised to ensure it matches the expected state. If the SUT is stateful, this can be achieved through Procedural State Verification or Expected State Specification. Otherwise, Behavior Verification can be used to verify indirect outputs of the SUT as it is being exercised when the state does not change or there is no state to verify. To determine the best approach, we must ascertain whether the expected outcome is a change of state or if we need to examine what occurs while the SUT is being exercised.
Build domain models that encapsulate functionality (business logic, calculations, validations, etc.) along with attributes and properties. Domain models should not contain just data — that's the purpose of data structures. Keep your service layer thin. It should only be used to orchestrate behavior contained within domain objects.
Anemic Domain Model is an anti-pattern that contradicts object-oriented design principles and should not be used.
Avoid writing tests that verify multiple assertions within a single test method. Numerous assertions make it difficult to determine which one caused a test to fail — inhibiting Defect Localization. This anti-pattern is often caused by Eager Tests that try to minimize the number of tests we need to write. Apply the Extract Function refactoring to tease apart these tests into single-condition tests so that the reasons for test failures are obvious.
Avoid this code smell by ensuring that your test methods do not verify too many test conditions. This often occurs when we try to minimize the number of tests we need to write. A unit test should verify only one condition by executing a single code path through the SUT. We achieve this by minimizing the Cyclomatic Complexity of the test and ensuring the SUT is isolated through Dependency Inversion. Acceptance tests should also be kept as short as possible. Eliminating this code smell will promote Defect Localization and minimize Test Overlap.
Avoid this design smell by building software that is highly modular, highly cohesive, and loosely coupled. A change to one part of your system should never break another part that is completely unrelated. High level policies (i.e. business rules) should never be impacted by changes to low level implementations (i.e. data persistence). Even related functionality should be decoupled enough to extend functionality without affecting related components. There are many engineering patterns and practices that facilitate flexibility, extensibility, adaptability, along with many other qualities that inhibit fragility.
Avoid this design smell by building software from which it is easy to extract and reuse internal components in new environments. This smell is often caused by dependencies that are tightly integrated with other parts of the system. Promote mobility by decoupling components from low-level implementations, such as data persistence, logging, user interfaces, etc. For example, business rules should be encapsulated within components to enable reuse across multiple systems.
Avoid this design smell by building software that is flexible and easy to change. Rigidity is often observed when a small change forces a complete rebuild and redeploy. Small changes should be able to be built, tested, and deployed very quickly and independently of each other. Long build times are a symptom of high coupling. To promote flexibility, manage the dependencies between modules to ensure when one module changes, the others remain unaffected.
Build your software and your tests in ways that minimize test execution time. Common causes for long running tests include over-engineered test fixtures, asynchronous code, components with high latency, Test Overlap, and too many tests due to a tightly coupled architecture. This causes bottlenecks in Continuous Integration, inhibits rapid feedback facilitated by automated testing, and constrains frequent code merges that are required for Trunk-Based Development.
Minimize the number of tests that depend on a particular piece of functionality such that each test condition is covered by exactly one test. Tests that verify the same functionality will usually fail at the same time and require the same maintenance when the functionality is modified. Give attention to tests that verify the same code in different ways as these may indicate different test conditions. Avoid Eager Tests that verify too many test conditions. Picking the right tests is essential in employing a risk-based approach to testing.
Avoid stringing method calls together on a single line of code to invoke methods within different objects. This type of call chain violates the Law of Demeter, while increasing complexity - inhibiting our ability to understand, maintain, and reuse the code. Instead of building a series of "train cars", apply the principle of Tell, Don't Ask to resolve all of these issues.