The Principle of High Cohesion: A Pillar of Reliable Software Design
Today, we're going to tap into a core principle of robust, maintainable, and reliable software design – High Cohesion. It's an often-underscored cornerstone of software architecture and is one that can make or break the dependability and efficiency of your code.
This is the third part of the series: Principles of Reliable Software Design.
Understanding Cohesion
Before we can learn the specifics of high cohesion, it's crucial to first understand what cohesion means in the context of software development. In simple terms, cohesion refers to how closely the responsibilities of a module, class, or component in a system are related to each other. When we talk about 'high cohesion,' we're referring to scenarios where a module or class has a single, well-defined role or responsibility.
The Importance of High Cohesion
High cohesion is not just a theoretical concept. It has tangible, practical benefits when applied in software design:
Simplicity and Understandability: When each module has a singular, clearly defined function, it becomes more straightforward to understand and reason about. This simplicity extends to anyone working on the code, making it easier for other developers to maintain and enhance.
Easier Maintenance and Modification: High cohesion typically leads to fewer dependencies. Fewer dependencies mean that changes in one part of the system are less likely to impact others, reducing the potential for bugs and simplifying modifications.
Increased Reusability: When a module is designed with a single responsibility, it becomes a highly reusable component. You can leverage this component in different parts of the application, or even across different projects.
Better Testing and Debugging: Testing becomes simpler with cohesive modules, as each module can be tested in isolation. Any bugs found are also easier to track and fix, as they are likely confined to a specific, well-defined area of the codebase.
Implementing High Cohesion
Achieving high cohesion isn't always straightforward. It requires careful design decisions. Here are a few tips to ensure your codebase remains highly cohesive:
Single Responsibility Principle (SRP)
Each class or module should have only one reason to change. SRP, one of the principles of SOLID design, states that a class should have only one responsibility. This can serve as a guideline for maintaining high cohesion.
For example, let's assume we have a class in our trading application named TradeManager
, which is currently responsible for placing trades and logging trade activities. This design violates the Single Responsibility Principle (SRP) because the class has more than one reason to change. Here's what it might look like:
type TradeManager struct {
//...
}
func (t *TradeManager) placeTrade(stockSymbol string, quantity int, tradeType string) {
// logic for placing the trade
//...
}
func (t *TradeManager) logTradeActivity(tradeActivity string) {
// logic for logging the trade activity
//...
}
To adhere to the SRP and achieve high cohesion, we should separate these responsibilities into two different classes. One class can handle placing trades while another handles logging trade activities. Here's a refactored version of the code:
type TradeManager struct {
//...
}
func (t *TradeManager) placeTrade(stockSymbol string, quantity int, tradeType string) {
// logic for placing the trade
//...
}
type TradeActivityLogger struct {
//...
}
func (l *TradeActivityLogger) logTradeActivity(tradeActivity string) {
// logic for logging the trade activity
//...
}
In the refactored version, TradeManager
and TradeActivityLogger
each have a single responsibility, making the code more cohesive and easier to maintain.
Decompose Complex Classes
If you identify a class doing too many things, break it down into smaller, more manageable classes, each with a single responsibility. This decomposition will increase the overall cohesion of your software.
let's look at an example where an OrderManager
class is managing all aspects of an order, including creating, validating, executing, canceling, listing, and getting orders. This class clearly has too many responsibilities:
type OrderManager struct {
//...
}
func (o *OrderManager) createOrder(stockSymbol string, quantity int, orderType string) {
// logic to create a new order
//...
}
func (o *OrderManager) validateOrder(order Order) bool {
// logic to validate an order
//...
}
func (o *OrderManager) executeOrder(order Order) {
// logic to execute an order
//...
}
func (o *OrderManager) cancelOrder(order Order) {
// logic to cancel an order
//...
}
func (o *OrderManager) listOrders() []Order {
// logic to list all orders
//...
}
func (o *OrderManager) getOrder(orderId string) Order {
// logic to get a specific order
//...
}
his class is doing too much, and it violates the Single Responsibility Principle. We can separate the responsibilities into two classes, OrderManager
and OrderRepository
.
The OrderManager
class will be responsible for operations directly related to the order's lifecycle, such as creating, validating, executing, and canceling orders. The OrderRepository
will handle the data-oriented operations, such as listing and getting specific orders.
type OrderManager struct {
//...
}
func (o *OrderManager) createOrder(stockSymbol string, quantity int, orderType string) {
// logic to create a new order
//...
}
func (o *OrderManager) validateOrder(order Order) bool {
// logic to validate an order
//...
}
func (o *OrderManager) executeOrder(order Order) {
// logic to execute an order
//...
}
func (o *OrderManager) cancelOrder(order Order) {
// logic to cancel an order
//...
}
type OrderRepository struct {
//...
}
func (r *OrderRepository) listOrders() []Order {
// logic to list all orders
//...
}
func (r *OrderRepository) getOrder(orderId string) Order {
// logic to get a specific order
//...
}
By segregating responsibilities into the OrderManager
and OrderRepository
classes, the design is now more aligned with the Single Responsibility Principle, enhancing the cohesion, maintainability, and readability of the code. Each class can be developed, modified, and tested independently, reducing the chances of changes in one class inadvertently impacting the other.
Cohesive Sets of Operations
Ensure that the operations within a module or class form a cohesive set. If there's an operation that doesn't fit well, consider moving it to another more suitable module or creating a new one.
A cohesive set of operations means that operations within a given module or class are closely related and contribute to a single responsibility. Here's an example with a StockTrade
class in a trading system:
type StockTrade struct {
stockSymbol string
quantity int
tradeType string
}
func (s *StockTrade) setStockSymbol(stockSymbol string) {
s.stockSymbol = stockSymbol
}
func (s *StockTrade) setQuantity(quantity int) {
s.quantity = quantity
}
func (s *StockTrade) setTradeType(tradeType string) {
s.tradeType = tradeType
}
func (s *StockTrade) getStockSymbol() string {
return s.stockSymbol
}
func (s *StockTrade) getQuantity() int {
return s.quantity
}
func (s *StockTrade) getTradeType() string {
return s.tradeType
}
In the above example, the StockTrade
class maintains a cohesive set of operations. All the getter and setter methods are related to the attributes of the stock trade,
If, for instance, we were to add a method for executing a trade or logging trade execution within this class, it would be a violation of the principle of High Cohesion, as executing and logging are different responsibilities and do not belong in the StockTrade
class. Instead, executing and logging should be delegated to different classes specifically designed for these other purposes.
Avoid "God" Objects
A "God" object is an object that knows too much or does too much. These are low-cohesion objects and can be a nightmare to maintain. Decomposing such objects into smaller, highly cohesive components improves understandability and maintainability.
Let's consider an example of a "God" interface in the context of a trading application. We'll call this interface TradingSystem
. It attempts to do everything related to trading, from managing stocks, trades, orders, to user accounts, etc.
type TradingSystem interface {
addStock(stock Stock)
removeStock(stock Stock)
updateStock(stock Stock)
listStocks() []Stock
getStock(id string) Stock
placeTrade(trade Trade)
cancelTrade(trade Trade)
listTrades() []Trade
getTrade(id string) Trade
createOrder(order Order)
validateOrder(order Order)
executeOrder(order Order)
cancelOrder(order Order)
listOrders() []Order
getOrder(id string) Order
createUser(user User)
deleteUser(user User)
updateUser(user User)
listUsers() []User
getUser(id string) User
}
This interface is problematic as it's trying to do too many things at once. It not only knows about trading activities like adding/removing/updating/listing stocks, placing/canceling/listing/getting trades, creating/validating/executing/canceling/listing/getting orders but also knows about user management activities like creating/deleting/updating/listing/getting users.
The interface could be broken down into more cohesive and manageable interfaces, each handling a single responsibility, such as StockManager
, TradeManager
, OrderManager
, and UserManager
. This way, each interface and its implementing classes are easier to understand, maintain, and test.
Conclusion
High cohesion isn't just a theoretical nicety. It's a practical principle that drives robustness and maintainability in software design. It promotes simplicity, easy maintenance, high reusability, and efficient testing - all of which contribute to reliable and robust software. By taking the time to ensure your modules are highly cohesive, you're not only crafting a better codebase but also future-proofing your software, ensuring it can be easily understood, tested, and enhanced by others in the future.
If you enjoy this type of content, and would like to get notified when we publish similar posts, we invite you to subscribe to our newsletter.
Member discussion