Cohesion and coupling are architectural concerns in the design of well-formed objects. Well-formed objects are cohesive and loosely coupled. Poor cohesion and tight coupling are design flaws, and are reasons to refactor a class architecture. (For an overview of object-oriented design, have a look at Object-Oriented Design Basics: Objects, Classes and Inheritance.)
Cohesion
Cohesive objects do one thing, and avoid doing other things.
The main indicator of a lack of cohesion is excessive decision logic. If an object has to put too much work into deciding what it is going to do when it’s in use, then the object is addressing too many unrelated concerns.
Example of Lack of Cohesion
Here’s an example (in JavaScript) of an object that lacks sufficient cohesion:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
class Shape { constructor(shapeType, shapeData) { this.shapeType = shapeType; this.shapeData = shapeData; } area() { switch (this.shapeType) { case 'circle': return Number((Math.PI * this.shapeData[0] ** 2).toFixed(2)); break; case 'square': return this.shapeData[0] ** 2; break; case 'rectangle': return this.shapeData[0] * this.shapeData[1]; break; default: return "Don't recognize that shape"; } } } const myCircle = new Shape('circle', [4]); const mySquare = new Shape('square', [4]); const myRect = new Shape('rectangle', [6, 3]); for (let shape of [myCircle, mySquare, myRect]) { console.log(shape.area()); } // 50.27 // 16 // 18 |
The architectural idea here is that because all shapes have an area, we should have a single area
method to support all of them. On the face of it, that makes sense. But the trouble is that different shapes calculate the area in different ways. We have to add decision logic to determine what kind of shape we are working with, and we have to go through that decision logic every time we invoke the area
method. Also, the more shapes we add, the heavier the decision logic becomes. So, we have an object with excessive decision logic, an object that spends too much time deciding what it’s going to do when it’s in use.
Cohesion problems often arise from situations like this, where certain related objects do the same thing but do it differently. So, when applying the principle of cohesion, it is important to keep in mind not only what the object does, but also how it does it. If different instances of an object do the same thing differently, it’s better to break the design into different classes.
How to Fix It
In the case of our Shape
class, we’re better off breaking the class into separate classes, and implementing area
in each of them in a manner appropriate to the particular shape:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
class Circle { constructor(radius) { this.radius = radius; } area() { return Number((Math.PI * this.radius ** 2).toFixed(2)); } } class Rectangle { constructor(x, y) { [this.x, this.y] = [x, y]; } area() { return this.x * this.y; } } class Square extends Rectangle { constructor(side) { super(side, side); } } const myCircle = new Circle(4); const mySquare = new Square(4); const myRect = new Rectangle(6, 3); for (let shape of [myCircle, mySquare, myRect]) { console.log(shape.area()); } // 50.27 // 16 // 18 |
With this change, there is no longer a need to evaluate which type of shape we are dealing with when calculating an area. Consumers of the objects only need to instantiate the type of object they want, rather than telling the Shape
object what type of object they want when they instantiate it. So, although we now have three classes instead of one, the classes are much simplified and are easier to use as compared to the first design.
Note that a square is a rectangle, so our Square
class can inherit from Rectangle
. It also makes sense to have it do so, because they both the same formula to calculate area. A square differs from a rectangle only in that all sides are equal instead of just opposite sides as with a rectangle. So our Square
object just needs to ensure that the values passed to our Rectangle
constructor’s x
and y
are the same. To do this, we can pass the length of one side to the Square
constructor, and send it to both x
and y
in the Rectangle
constructor.
Coupling
Objects with loose coupling minimize the amount of communication that they have to have with other objects to do what they do.
Objects that are too tightly coupled know more than they need to about each other, and have to spend unnecessary time interacting with each other to do the work they are intended to do. Also, when objects are too tightly coupled, it is difficult to change one without changing the other.
Example of Overly Tight Coupling
Here’s an example of an architecture with overly tight coupling:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
class Food { constructor(type) { this.type = type; } eatWithFingers() { new Utensil().operate(this); } eatWithFork() { new Fork().operate(this); } eatWithSpoon() { new Spoon().operate(this); } } class Utensil { constructor(action = 'eats', name = 'fingers') { this.action = action; this.name = name; } operate(food) { console.log(`The hungry human ${this.action} the ${food.type} with the ${this.name}.`); } } class Fork extends Utensil { constructor() { super('stabs', 'fork'); } } class Spoon extends Utensil { constructor() { super('scoops', 'spoon'); } } let pasta = new Food('spaghetti'); pasta.eatWithFork(); // The hungry human stabs the spaghetti with the fork. let soup = new Food('soup'); soup.eatWithSpoon(); // The hungry human scoops the soup with the spoon. let popcorn = new Food('popcorn'); popcorn.eatWithFingers(); // The hungry human eats the popcorn with the fingers. |
In this design, the Food
object has to call different methods for the different utensils that are used to eat it. This is a coupling problem, because every time you add a new utensil, you also have to add a new method to the Food
object. For example, if you add a Chopsticks
utensil, you’ll also have to add an eatWithChopsticks
method to the Food
object.
Furthermore, the utensil object has to know what kind of food it’s eating to be able to call its operate
method. One object typically needs to know something about another to use it, but if that knowledge goes both ways, that’s probably a coupling problem.
So, these two objects are too tightly coupled. The Food
object should know as little as possible about what utensil it’s using to be eaten, and the different utensil objects don’t need to know anything at all about the type of food they are eating.
How to Fix It
We can fix these problems by decoupling the Food
object from the Utensil
object and its subclasses.
We’ll start by replacing all the eatWith
methods with a single eat
method. We’ll let this method do the eating, instead of calling Utensil
‘s operate
method and passing the current Food
instance to it.
To let the Food
object know what utensil it’s using, we’ll store an instance of a Utensil
object or one of its subclasses to a property of the Food
object when we construct it. The eat
method can use whatever utensil it’s given, and doesn’t have to know anything about it beyond that it’s some sort of Utensil
instance with an action
and a name
property.
Finally, we’ll get rid of the Utensil
‘s operate
method, since we no longer need it.
Here’s the result:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
class Food { constructor(type, tool = Utensil) { this.type = type; this.tool = new tool(); } eat() { console.log(`The hungry human ${this.tool.action} the ${this.type} with the ${this.tool.name}.`); } } class Utensil { constructor(action = 'eats', name = 'fingers') { this.action = action; this.name = name; } } class Fork extends Utensil { constructor() { super('stabs', 'fork'); } } class Spoon extends Utensil { constructor() { super('scoops', 'spoon'); } } class Chopsticks extends Utensil { constructor() { super('picks up', 'chopsticks') } } let donut = new Food('donut'); donut.eat(); // The hungry human eats the donut with the fingers. let pasta = new Food('spaghetti', Fork); pasta.eat(); // The hungry human stabs the spaghetti with the fork. let soup = new Food('soup', Spoon); soup.eat(); // The hungry human scoops the soup with the spoon. let sushi = new Food('sushi', Chopsticks); sushi.eat(); // The hungry human picks up the sushi with the chopsticks. |
This considerably simplifies the structure. The Food
object no longer has to know about different ways it can be eaten, so it can focus on the concern of getting eaten. The instances no longer have to call different methods for different ways of eating food (eatWithFork
, eatWithSpoon
, eatWithChopsticks
, etc.); they can all call the same eat
method. Also, adding new Utensil
classes such as the Chopsticks
class no longer requires any changes to the Food
class.
Correlation Between Cohesion and Coupling
You may have noticed that the tightly coupled example also exhibits poor cohesion. Cohesion and coupling issues often go together, because both issues stem from insufficient separation of concerns.
Here, the Food
object has to do different things depending on which utensil to use while it’s being eaten. This strays from its chief purpose of just being a food item that gets eaten, and violates the cohesion principle of sticking to one thing.
It’s important to keep cohesion and coupling in mind when designing a class architecture. Cohesive, loosely coupled classes are easier to use, perform better, and are easier to alter and change when implementing changes to business requirements.