In a system that is both complex and tightly coupled, accidents are inevitable.
Charles Perrow’s Normal Accidents theory in one sentence
Avoid large modules in order to achieve loose coupling between them.
Do this by assigning responsibilities to separate modules and hiding implementation details behind interfaces.
This improves maintainability because changes in a loosely coupled codebase are much easier to oversee and execute than changes in a tightly coupled codebase.
The guidelines presented in the previous chapters are all what we call unit guidelines: they address improving maintainability of individual units (methods/constructors) in a system. In this chapter, we move up from the unit level to the module level.
Remember that the concept of a module translates to a class in object-oriented languages such as Java.
This module-level guideline addresses relationships between classes. This guideline is about achieving loose coupling.
We will use a true story to illustrate what tight coupling between classes is and why it leads to maintenance problems. This story is about how a class called UserService
in the service layer of a web application started growing while under development and kept on growing until it violated the guideline of this chapter.
In the first development iteration, the UserService
class started out as a class with only three methods, the names and responsibilities of which are shown in this code snippet:
public
class
UserService
{
public
User
loadUser
(
String
userId
)
{
...
}
public
boolean
doesUserExist
(
String
userId
)
{
...
}
public
User
changeUserInfo
(
UserInfo
userInfo
)
{
...
}
}
In this case, the backend of the web application provides a REST interface to the frontend code and other systems.
A REST interface is an approach for providing web services in a simplified manner. REST is a common way to expose functionality outside of the system. The class in the REST layer that implements user operations uses the UserService
class like this:
import
company.system.services.UserService
;
// The @Path and @GET attributes are defined by the the Java REST Service API
@Path
(
"/user"
)
public
class
UserRestAPI
{
private
final
UserService
userService
=
new
UserService
();
...
@GET
@Path
(
"/{userId}"
)
public
Response
getUser
(
@PathParam
(
value
=
"userId"
)
String
userId
)
{
User
user
=
userService
.
loadUser
(
userId
);
return
toJson
(
user
);
}
}
During the second development iteration, the UserService
class is not modified at all. In the third development iteration, new requirements were implemented that allowed a user to register to receive certain notifications. Three new methods were added to the UserService
class for this requirement:
public
class
UserService
{
public
User
loadUser
(
String
userId
)
{
...
}
public
boolean
doesUserExist
(
String
userId
)
{
...
}
public
User
changeUserInfo
(
UserInfo
userInfo
)
{
...
}
public
List
<
NotificationType
>
getNotificationTypes
(
User
user
)
{
...
}
public
void
registerForNotifications
(
User
user
,
NotificationType
type
)
{
...
}
public
void
unregisterForNotifications
(
User
user
,
NotificationType
type
)
{
...
}
}
These new functionalities were also exposed via a separate REST API class:
import
company.system.services.UserService
;
@Path
(
"/notification"
)
public
class
NotificationRestAPI
{
private
final
UserService
userService
=
new
UserService
();
...
@POST
@Path
(
"/register/{userId}/{type}"
)
public
Response
register
(
@PathParam
(
value
=
"userId"
)
String
userId
,
@PathParam
(
value
=
"type"
String
notificationType
))
{
User
user
=
userService
.
loadUser
(
userId
);
userService
.
registerForNotifications
(
user
,
notificationType
);
return
toJson
(
HttpStatus
.
SC_OK
);
}
@POST
@Path
(
"/unregister/{userId}/{type}"
)
public
Response
unregister
(
@PathParam
(
value
=
"userId"
)
String
userId
,
@PathParam
(
value
=
"type"
String
notificationType
))
{
User
user
=
userService
.
loadUser
(
userId
);
userService
.
unregisterForNotifications
(
user
,
notificationType
);
return
toJson
(
HttpStatus
.
SC_OK
);
}
}
In the fourth development iteration, new requirements for searching users, blocking users, and listing all blocked users were implemented (management requested that last requirement for reporting purposes). All of these requirements caused new methods to be added to the UserService
class.
public
class
UserService
{
public
User
loadUser
(
String
userId
)
{
...
}
public
boolean
doesUserExist
(
String
userId
)
{
...
}
public
User
changeUserInfo
(
UserInfo
userInfo
)
{
...
}
public
List
<
NotificationType
>
getNotificationTypes
(
User
user
)
{
...
}
public
void
registerForNotifications
(
User
user
,
NotificationType
type
)
{
...
}
public
void
unregisterForNotifications
(
User
user
,
NotificationType
type
)
{
...
}
public
List
<
User
>
searchUsers
(
UserInfo
userInfo
)
{
...
}
public
void
blockUser
(
User
user
)
{
...
}
public
List
<
User
>
getAllBlockedUsers
()
{
...
}
}
At the end of this development iteration, the class had grown to an impressive size. At this point the UserService
class had become the most used service in the service layer of the system. Three frontend views (pages for Profile, Notifications, and Search), connected through three REST API services, used the UserService
class. The number of incoming calls from other classes (the fan-in) has increased to over 50. The size of class has increased to more than 300 lines of code.
These kind of classes have what is called the large class smell, briefly discussed in Chapter 4. The code contains too much functionality and also knows implementation details about the code that surrounds it. The consequence is that the class is now tightly coupled. It is called from a large number of places in the code, and the class itself knows details on other parts of the codebase. For example, it uses different data layer classes for user profile management, the notification system, and searching/blocking other users.
Coupling means that two parts of a system are somehow connected when changes are needed. That may be direct calls, but classes could also be connected via a configuration file, database structure, or even assumptions they make (in terms of business logic).
The problem with these classes is that they become a maintenance hotspot. All functionalities related (even remotely) to users are likely to end up in the UserService
class. This is an example of an improper separation of concerns. Developers will also find the UserService
class increasingly more difficult to understand as it becomes large and unmanageable. Less experienced developers on the team will find the class intimidating and will hesitate to make changes to it.
Two principles are necessary to understand the significance of coupling between classes.
Coupling is an issue on the class level of source code. Each of the methods in UserService
complies with all guidelines presented in the preceding chapters. However, it is the combination of methods in the UserService
class that makes UserService
tightly coupled with the classes that use it.
Tight and loose coupling are a matter of degree. The actual maintenance consequence of tight coupling is determined by the number of calls to that class and the size of that class. Therefore, the more calls to a particular class that is tightly coupled, the smaller its size should be. Consider that even when classes are split up, the number of calls may not necessarily be lower. However, the coupling is then lower, because less code is coupled.
The biggest advantage of keeping classes small is that it provides a direct path toward loose coupling between classes. Loose coupling means that your class-level design will be much more flexible to facilitate future changes. By “flexibility” we mean that you can make changes while limiting unexpected effects of those changes. Thus, loose coupling allows developers to work on isolated parts of the codebase without creating change ripples that affect the rest of the codebase. A third advantage, which cannot be underestimated, is that the codebase as a whole will be much more open to less experienced developers.
The following sections discuss the advantages of having small, loosely coupled classes in your system.
When a class is tightly coupled with other classes, changes to the implementation of the class tend to create ripple effects through the codebase. For example, changing the interface of a public method leads to code changes everywhere the method is called. Besides the increased development effort, this also increases the risk that class modifications lead to bugs or inconsistencies in remote parts of the codebase.
Not only does a good separation of concerns keep the codebase flexible to facilitate future changes, it also improves the analyzability of the codebase since classes encapsulate data and implement logic to perform a single task. Just as it is easier to name methods that only do one thing, classes also become easier to name and understand when they have one responsibility. Making sure classes have only one responsibility is also known as the single responsibility principle.
Classes that violate the single responsibility principle become tightly coupled and accumulate a lot of code over time. As with the UserService
example in the introduction of this chapter, these classes become intimidating to less experienced developers, and even experienced developers are hesitant to make changes to their implementation. A codebase that has a large number of classes that lack a good separation of concerns is very difficult to adapt to new requirements.
In general, this guideline prescribes keeping your classes small (by addressing only one concern) and limiting the number of places where a class is called by code outside the class itself. Following are three development best practices that help to prevent tight coupling between classes in a codebase.
Designing classes that collectively implement functionality of a software system is the most essential step in modeling and designing object-oriented systems. In typical software projects we see that classes start out as logical entities that implement a single functionality but over time gain more responsibilities. To prevent classes from getting a large class smell, it is crucial that developers take action if a class has more than one responsibility by splitting up the class.
To demonstrate how this works with the UserService
class from the introduction, we split the class into three separate classes. Here are the two newly created classes and the modified UserService
class:
public
class
UserNotificationService
{
public
List
<
NotificationType
>
getNotificationTypes
(
User
user
)
{
...
}
public
void
register
(
User
user
,
NotificationType
type
)
{
...
}
public
void
unregister
(
User
user
,
NotificationType
type
)
{
...
}
}
public
class
UserBlockService
{
public
void
blockUser
(
User
user
)
{
...
}
public
List
<
User
>
getAllBlockedUsers
()
{
...
}
}
public
class
UserService
{
public
User
loadUser
(
String
userId
)
{
...
}
public
boolean
doesUserExist
(
String
userId
)
{
...
}
public
User
changeUserInfo
(
UserInfo
userInfo
)
{
...
}
public
List
<
User
>
searchUsers
(
UserInfo
userInfo
)
{
...
}
}
After we rewired the calls from the REST API classes, the system now has a more loosely coupled implementation. For example, the UserService
class has no knowledge about the notification system or the logic for blocking users. Developers are also more likely to put new functionalities in separate classes instead of defaulting to the UserService
class.
We can also achieve loose coupling by hiding specific and detailed implementations behind a high-level interface. Consider the following class, which implements the functionality of a digital camera that can take snapshots with the flash on or off:
public
class
DigitalCamera
{
public
Image
takeSnapshot
()
{
...
}
public
void
flashLightOn
()
{
...
}
public
void
flaslLightOff
()
{
...
}
}
And suppose this code runs inside an app on a smartphone device, like this:
// File SmartphoneApp.java:
public
class
SmartphoneApp
{
private
DigitalCamera
camera
=
new
DigitalCamera
();
public
static
void
main
(
String
[]
args
)
{
...
Image
image
=
camera
.
takeSnapshot
();
...
}
}
A more advanced digital camera becomes available. Apart from taking snapshots, it can also record video, has a timer feature, and can zoom in and out. The DigitalCamera
class is extended to support the new features:
public
class
DigitalCamera
{
public
Image
takeSnapshot
()
{
...
}
public
void
flashLightOn
()
{
...
}
public
void
flaslLightOff
()
{
...
}
public
Image
takePanoramaSnapshot
()
{
...
}
public
Video
record
()
{
...
}
public
void
setTimer
(
int
seconds
)
{
...
}
public
void
zoomIn
()
{
...
}
public
void
zoomOut
()
{
...
}
}
From this example implementation, it is not difficult to imagine that the extended version of the DigitalCamera
class will be much larger than the initial version, which has fewer features.
The codebase of the smartphone app still uses only the original three methods. However, because there is still just one DigitalCamera
class, the app is forced to use this larger class. This introduces more coupling in the codebase than necessary. If one (or more) of the additional methods of DigitalCamera
changes, we have to review the codebase of the smartphone app, only to find that it is not affected. While the smartphone app does not use any of the new methods, they are available to it.
To lower coupling, we use an interface that defines a limited list of camera features implemented by both basic and advanced cameras:
// File SimpleDigitalCamera.java:
public
interface
SimpleDigitalCamera
{
public
Image
takeSnapshot
();
public
void
flashLightOn
();
public
void
flashLightOff
();
}
// File DigitalCamera.java:
public
class
DigitalCamera
implements
SimpleDigitalCamera
{
...
}
// File SmartphoneApp.java:
public
class
SmartphoneApp
{
private
SimpleDigitalCamera
camera
=
SDK
.
getCamera
();
public
static
void
main
(
String
[]
args
)
{
...
Image
image
=
camera
.
takeSnapshot
();
...
}
}
This change leads to lower coupling by a higher degree of encapsulation. In other words, classes that use only basic digital camera functionalities now do not know about all of the advanced digital camera functionalities. The SmartphoneApp
class accesses only the SimpleDigitalCamera
interface. This guarantees that SmartphoneApp
does not use any of the methods of the more advanced camera.
Also, this way your system becomes more modular: it is composed such that a change to one class has minimal impact on other classes. This, in turn, increases modifiability: it is easier and less work to modify the system, and there is less risk that modifications introduce defects.
A third situation that typically leads to tight module coupling are classes that provide generic or utility functionality. Classic examples are classes called StringUtils
and FileUtils
.
Since these classes provide generic functionality, they are obviously called from many places in the codebase. In many cases this is an occurrence of tight coupling that is hard to avoid. A best practice, though, is to keep the class sizes limited and to periodically review (open source) libraries and frameworks to check if they can replace the custom implementation. Apache Commons and Google Guava are widespread libraries with frequently used utility functionality. In some cases, utility code can be replaced with new Java language features or a company-wide shared library.
The following are typical objections to the principle explained in this chapter.
“Tight coupling is a side effect of code reuse, so this guideline conflicts with that best practice.”
Of course, code reuse can increase the number of calls to a method. However, there are two reasons why this should not lead to tight coupling:
Reuse does not necessarily lead to methods that are called from as many places as possible. Good software design—for example, using inheritance and hiding implementation behind interfaces—will stimulate code reuse while keeping the implementation loosely coupled, since interfaces hide implementation details.
Making your code more generic, to solve more problems with less code, does not mean it should become a tightly coupled codebase. Clearly, utility functionality is expected to be called from more places than specific functionality. Utility functionality should then also embody less source code. In that way, there may be many incoming dependencies, but the dependencies refer to a small amount of code.
“It doesn’t make sense to use Java interfaces to prevent tight coupling.”
Indeed, using interfaces is a great way to improve encapsulation by hiding implementations, but it does not make sense to provide an interface for every class. As a rule of thumb, an interface should be implemented by at least two classes in your codebase. Consider splitting your class if the only reason to put an interface in front of your class is to limit the amount of code that other classes see.
“Utility code will always be called from many locations in the codebase.”
That is true. In practice, even highly maintainable codebases contain a small amount of code that is so generic that it is used by many places in the codebase (for example, logging functionality or I/O code). Highly generic, reusable code should be small, and some of it may be unavoidable. However, if the functionality is indeed that common, there may be a framework or library available that already implements it and can be used as is.
“Frameworks that implement inversion of control (IoC) achieve loose coupling but make it harder to maintain the codebase.”
Inversion of control is a design principle to achieve loose coupling. There are frameworks available that implement this for you. IoC makes a system more flexible for extension and decreases the amount of knowledge that pieces of code have of each other.
This objection holds when such frameworks add complexity for which the maintaining developers are not experienced enough. Therefore, in cases where this objection is true, it is not IoC that is the problem, but the framework that implements it.
Thus, the design decision to use a framework for implementing IoC should be considered with care. As with all engineering decisions, this is a trade-off that does not pay off in all cases. Using these types of frameworks just to achieve loose coupling is a choice that can almost never be justified.