Understanding Cohesion, Coupling and Connascence in Software Design
How to improve the quality, maintainability, and flexibility of your software systems by applying the principles of low coupling, high cohesion, and minimal connascence.
Introduction
Cohesion, coupling and connascence are three terms that describe the quality and maintainability of software design, especially in object-oriented programming. They measure how well the components of a software system are related and dependent on each other, and how easily they can be changed or extended.
Cohesion refers to the degree to which the elements within a module are related to each other. A highly cohesive module concentrates on a single functionality, making its purpose clear and its behaviour predictable. In contrast, low cohesion indicates that the module's elements are loosely related, leading to confusion and increased complexity.
Coupling describes the degree to which modules are dependent on each other. High coupling implies that modules are intertwined, making changes in one module ripple through other parts of the system. This tight interdependence hinders maintainability and reduces flexibility. Low coupling, on the other hand, promotes modularity, allowing modules to function independently and adapt to changes more effectively.
Connascence, closely related to cohesion, focuses on the visibility of implementation details within a module. High connascence exposes internal implementation details to external modules, making the module's behaviour less transparent and more prone to brittleness. Low connascence encapsulates implementation details, shielding external modules from internal workings and promoting maintainability.
Achieving a balance between cohesion, coupling, and connascence is crucial for crafting well-designed software. High cohesion promotes focused modules, low coupling enhances modularity, and low connascence ensures module independence. By striving for this balance, developers can create software that is easier to understand, test, maintain, and extend.
In this article, we delve into the intricacies of cohesion, coupling, and connascence, exploring their impact on software design and providing practical strategies for achieving a harmonious balance between these principles. We will illustrate these principles with real-world examples, demonstrating how to apply them in Python code to create robust and maintainable software systems.
Cohesion
Types of Cohesion
Coincidental Cohesion: This is the weakest form of cohesion, where the elements of a module are related by chance or for historical reasons. There is no clear underlying reason for their connection, and the module's functionality is difficult to understand and maintain.
Logical Cohesion: This type of cohesion exists when the elements of a module are related by a common logical function or task. The module performs a specific operation or solves a particular problem, but its elements may not be tightly related.
Sequential Cohesion: Sequential cohesion occurs when the elements of a module are related by the order in which they are executed. The module's functionality is defined by the sequence of its tasks, but there may not be a clear underlying reason for the order.
Communicative Cohesion: This type of cohesion exists when the elements of a module are related by the data they share or exchange. The module operates on a common set of data, but its elements may not be tightly related.
Functional Cohesion: This is the strongest form of cohesion, where the elements of a module are related by a single, well-defined purpose. Each element contributes directly to the module's primary functionality, making the module easy to understand, maintain, and test.
Temporal cohesion: This is a type of cohesion in software design that describes a module whose elements are related based on the time they are processed. In other words, the module's functionality is defined by the order in which its operations are executed.
Procedural cohesion: This is a type of cohesion in software design that describes a module whose elements are related based on the sequence of steps or operations they perform. In other words, the module's functionality is defined by the order in which its procedures are executed.
What is Low Cohesion?
Low cohesion, in essence, refers to the degree to which the elements within a software module or class are unrelated or diverse. In other words, it indicates that the module's functionalities are scattered and dispersed, making it difficult to understand and maintain. A module with low cohesion serves multiple, unrelated purposes, often leading to code that is difficult to follow, modify, and test.
Drawbacks of Low Cohesion
Low cohesion poses significant drawbacks for software development:
Reduced Readability and Understandability: Low cohesive modules are often convoluted and difficult to decipher, as their functions are unrelated and poorly organized. This hinders comprehension and makes it challenging for developers to grasp the module's overall purpose and functionality.
Increased Maintenance Complexity: Modifying or extending low cohesive modules can be a nightmare, as changes in one function may ripple through unrelated parts of the module, leading to unintended consequences and potential bugs. This complexity makes it difficult to keep the code up-to-date and maintainable.
Limited Reusability and Flexibility: Low cohesive modules are less reusable and adaptable, as their disparate functionalities make them difficult to integrate into different systems or extend with new features. This lack of flexibility hinders the code's ability to adapt to evolving requirements.
Elevated Coupling and System Complexity: Low cohesion often leads to increased coupling between modules, as unrelated functionalities become intertwined and interdependent. This increased coupling makes the overall system structure more complex and difficult to manage, increasing the risk of errors and instability.
Enhancing Cohesion with Design Patterns
To further enhance cohesion and promote modularity, developers can adopt design patterns that encourage separation of concerns. Some common patterns include:
Single Responsibility Principle (SRP): Each module should have a single, well-defined responsibility.
Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.
Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions.
By applying these principles and design patterns, developers can create software that is easier to understand, maintain, and extend, making their applications more scalable and resilient in the face of change.
In Python, a popular programming language that supports multiple paradigms, there are some best practices and tools to achieve high cohesion, low coupling and minimal connascence. Some of them are:
Use the principle of single responsibility to design your modules, classes and functions. Each one should have one and only one reason to change.
Use abstraction and encapsulation to hide the implementation details of your modules and classes. Provide clear and consistent interfaces for communication and collaboration.
Use inheritance and polymorphism to create hierarchies of classes that share common behaviour and can be substituted for each other. Use composition and delegation to create complex objects from simpler ones, without creating tight coupling.
Use duck typing and interfaces to define the expected behaviour of your objects, rather than their types. This allows you to write more flexible and generic code that can work with any object that implements the required methods and attributes.
Use decorators and metaclasses to add functionality or modify the behaviour of your classes and functions, without changing their original code. This can help you implement cross-cutting concerns, such as logging, caching, validation, etc.
Use cohesion and pylint tools to measure the cohesion and coupling of your code, and identify areas for improvement. Cohesion is a tool for measuring Python class cohesion, and pylint is a tool for checking the quality and style of your code.
Examples of Low Cohesion and Refactoring
Low cohesion is a detrimental aspect of software design that can lead to code that is difficult to understand, maintain, and extend. By understanding the drawbacks of low cohesion and adopting design patterns that promote high cohesion, developers can create software that is more maintainable, scalable, and adaptable.
Logical Cohesion
Low cohesion example:
def process_order(order_id): """Processes an order, handles customer support, and manages products.""" order = get_order_details(order_id) validate_order(order) prepare_shipment(order) send_order_confirmation(order) respond_to_customer_queries(order) update_product_inventory(order)
This function exhibits low logical cohesion because it combines three unrelated tasks: order processing, customer support, and product management. This makes the code difficult to understand and maintain, as it's unclear what the function's main purpose is.
Refactored to high cohesion:
def order_processing(): """Handles order processing tasks.""" order = get_order_details() validate_order(order) prepare_shipment(order) def customer_support(): """Handles customer support inquiries.""" respond_to_customer_queries() def product_management(): """Manages product inventory.""" update_product_inventory()
This refactored code divides the functionality into separate functions based on their logical cohesion. This makes the code more modular and easier to understand, as each function has a clear and focused purpose.
Sequential Cohesion
Low cohesion example:
def schedule_tasks(task_list): """Schedules tasks, sends emails, and prints documents in a specific order.""" for task in task_list: schedule_task(task) send_email_notification(task) print_task_summary(task)
This function exhibits low sequential cohesion because it performs a sequence of unrelated tasks that don't share a common purpose. This makes the code difficult to understand and modify, as changes to one part of the sequence may affect other parts unintentionally.
Refactored to high cohesion:
def task_scheduling(): """Schedules tasks.""" for task in task_list: schedule_task(task) def task_notification(): """Sends emails for scheduled tasks.""" for task in task_list: send_email_notification(task) def task_summary(): """Generates task summaries.""" for task in task_list: print_task_summary(task)
This refactored code divides the functionality into separate functions based on their sequential relationship. This makes the code more structured and easier to understand, as each function represents a specific step in the sequence.
Communicational Cohesion
Low cohesion example:
def manage_database(connection): """Connects to a database, executes queries, and processes results.""" connect_to_database(connection) execute_query("SELECT * FROM users") process_query_results() close_database_connection()
This function exhibits low communicational cohesion because it combines three unrelated tasks that are all related to the database but don't share a common purpose. This makes the code difficult to understand and maintain, as it's unclear what the function's overall goal is.
Refactored to high cohesion:
def database_connection(): """Establishes and closes database connections.""" connection = connect_to_database() try: # Perform database operations execute_query("SELECT * FROM users") process_query_results() finally: close_database_connection(connection) def query_execution(): """Executes database queries.""" connection = database_connection() execute_query("SELECT * FROM users") def result_processing(): """Processes query results.""" connection = database_connection() process_query_results()
This refactored code divides the functionality into separate functions based on their communicational relationship.
Coupling
Types of Coupling
Content Coupling: The most tightly coupled form of coupling, where modules directly access and modify the internal data or implementation details of other modules. Changes in one module can directly affect the behaviour of the other, making the system difficult to understand, maintain, and test.
Stamp Coupling: Occurs when modules explicitly depend on specific object instances or data structures within other modules. Changes in those objects can disrupt the behaviour of dependent modules, hindering modularity and reusability.
Control Coupling: This type of coupling arises when modules rely on specific control structures or sequences of operations within other modules. Changes in these control structures can break the communication between modules, making the system brittle and difficult to test.
Data Coupling: The weakest form of coupling, where modules interact through shared data structures or variables. While still interconnected, data coupling allows for some level of separation between modules, making the system more modular and adaptable.
External coupling: Modules communicate by using external resources, such as files, databases, or network connections. This type of coupling depends on the environment and can introduce errors or inconsistencies.
Common coupling: Modules communicate by using global variables or constants. This type of coupling is highly undesirable, as it creates a shared state that can be modified by any module.
Benefits of Low Coupling
Maintainability: Low coupling makes code easier to understand, modify, and extend. Changes in one module are less likely to break other modules.
Testability: Low coupling allows modules to be tested in isolation, making it easier to write unit tests and ensure the correctness of individual components.
Resilience to Change: Low coupling makes code more adaptable to changes in requirements or external factors. Modifications in one module can be made without affecting other modules significantly.
Achieving Low Coupling
Loose coupling leads to more maintainable, testable, and adaptable code. Here are some refactoring techniques to achieve loose coupling:
Encapsulation: Hide internal implementation details and provide well-defined interfaces for public access.
Dependency Injection: Provide dependencies through constructor arguments or setter methods, allowing flexible testing and abstraction.
Abstract Interface: Define a common interface for related modules, ensuring consistent interactions and decoupling implementation details.
Message Passing: Communicate through messages instead of direct data or control flow, promoting loose coupling and modularity.
Mocking and Stubbing: Utilize mocking and stubbing techniques to isolate modules and test them independently, without relying on real dependencies.
By following these guidelines and employing low-coupling techniques, you can create more maintainable, testable, and adaptable software that is easier to manage and evolve over time.
Examples of High Coupling and Refactoring
There are several different types of coupling, each with its own level of interdependence. Here are some of the most common types of coupling:
Data Coupling
High coupling example:
class Calculator: def add(self): total = 0 for number in data: total += number return total
Here, the
Calculator
class is directly accessing thedata
list in the global namespace. Any changes made to thedata
list will directly affect theCalculator
class's behaviour.Refactored to low coupling:
To reduce data coupling, create a separate
DataManager
class to manage thedata
list:class DataManager: def __init__(self): self.data = [] def add_data(self, number): self.data.append(number) class Calculator: def __init__(self, data_manager): self.data_manager = data_manager def add(self): total = 0 for number in self.data_manager.data: total += number return total
By encapsulating the data in the
DataManager
class, theCalculator
class no longer directly depends on thedata
list. This improves loose coupling and makes the code more modular.Control Coupling
High coupling example:
class Controller: def start(self): self.process_data() def stop(self): self.terminate_process()
Here, the
Controller
class directly calls the methodsprocess_data()
andterminate_process()
in the same class. This tight coupling makes the code difficult to test and maintain.Refactored to low coupling:
To reduce control coupling, create separate
ProcessData
andTerminateProcess
classes:class ProcessData: def process(self): # Implement data processing logic pass class TerminateProcess: def terminate(self): # Implement process termination logic pass class Controller: def start(self): process_data = ProcessData() process_data.process() def stop(self): terminate_process = TerminateProcess() terminate_process.terminate()
By encapsulating the control logic in separate classes, the
Controller
class becomes more loosely coupled and easier to test.Stamp Coupling
High coupling example:
class UserService: def create_user(self, username): user = User(username) user.save()
Here, the
UserService
class creates aUser
object directly and directly invokes itssave()
method. This tight coupling makes the code difficult to test and reuse.Refactored to low coupling:
To reduce stamp coupling, introduce a
UserRepository
class to manage user creation and persistence:class UserRepository: def create_user(self, username): user = User(username) user.save() class UserService: def create_user(self, username): user_repository = UserRepository() user_repository.create_user(username)
By encapsulating the user creation and persistence logic in the
UserRepository
, theUserService
class becomes more loosely coupled and easier to test.
Common coupling
High coupling example:
Consider a
Counter
class that increments a globaltotal_count
variable:total_count = 0 class Counter: def increment(self): global total_count total_count += 1
In this example, the
Counter
class directly modifies thetotal_count
global variable. This creates a tight coupling between theCounter
class and any other module that accesses or modifies thetotal_count
variable.Refactored to low coupling:
To reduce common coupling, create a
CounterManager
class to manage thetotal_count
variable:class CounterManager: def __init__(self): self.total_count = 0 def increment(self): self.total_count += 1 def get_total_count(self): return self.total_count class Counter: def __init__(self, counter_manager): self.counter_manager = counter_manager def increment(self): self.counter_manager.increment()
The
CounterManager
class now encapsulates the management of thetotal_count
variable. TheCounter
class no longer directly accesses the global variable, reducing the tight coupling and improving code maintainability.
Connascence
Connascence refers to the degree to which the internal implementation details of a module are exposed to external modules. High connascence means that external modules have direct access to the internal workings of the module, making the module less modular and adaptable. Conversely, low connascence promotes encapsulation and abstraction, shielding external modules from the inner workings of the module and enhancing maintainability.
There are two types of connascence: static and dynamic. Static connascence can be found by visually examining the code, while dynamic connascence appears when the code runs.
Static connascence
Static connascence refers to the degree to which the internal implementation details of a module are visible to external modules at compile time. Higher static connascence leads to more tightly coupled modules, while lower static connascence promotes loose coupling and encapsulation.
There are four main types of static connascence:
Name Connascence: This is the weakest form of static connascence, where modules only know about each other through their names. This minimal level of visibility allows for greater modularity and flexibility, as external modules are not directly exposed to the internal workings of the module.
Type Connascence: This type of static connascence occurs when modules directly refer to specific types of data or objects within other modules. Changes to the types used in one module can necessitate modifications to the other modules, hindering modularity and reusability.
Meaning Connascence: Occurs when modules depend on the specific meaning or interpretation of data or objects within other modules. Changes in the meaning or interpretation of that data can break the communication between modules, making the system brittle and difficult to understand.
Position Connascence: This type of static connascence arises when modules directly reference the order or position of data or objects within other modules. Changes to the order or position of that data can disrupt the operation of dependent modules, making the system difficult to test.
Dynamic connascence
Dynamic connascence refers to the degree to which the internal implementation details of a module are visible to external modules during runtime. Higher dynamic connascence leads to more tightly coupled modules, while lower dynamic connascence promotes loose coupling and encapsulation.
There are three main types of dynamic connascence:
Execution Connascence: This type of dynamic connascence occurs when modules directly interact with each other's internal execution details. This includes directly calling methods, accessing internal variables, or relying on specific control flow structures within the other module.
Timing Connascence: Occurs when modules depend on the timing of each other's execution. This includes waiting for specific events or conditions to be met before proceeding, or relying on the order in which other modules execute.
Value Connascence: This type of dynamic connascence arises when modules depend on the values of data exchanged between them. Changes to the values of that data can disrupt the communication between modules, making the system brittle and difficult to test.
Temporal connascence: This is a type of dynamic connascence in software design that occurs when modules are coupled due to the specific timing of their interactions. In other words, the functionality of one module is dependent on the precise order in which it receives data or events from another module.
In general, it is desirable to minimize connascence in software designs, aiming for low connascence and high cohesion. This promotes modularity, reusability, and maintainability, making the code easier to understand, modify, and test.
Examples of High Connascence and Refactoring
Temporal Connascence
Temporal connascence is a type of coupling in software design that occurs when the order or timing of operations affects the correctness or performance of the system.
Here is a Python example of how to refactor temporally connascent code using the state pattern:
import time class OrderProcessor: def process_order(self, order): self.validate_order(order) time.sleep(2) # Simulating order processing time self.ship_order(order) def validate_order(self, order): print(f"Validating order: {order}") def ship_order(self, order): print(f"Shipping order: {order}") class PaymentProcessor: def process_payment(self, order): time.sleep(1) # Simulating payment processing time print(f"Processing payment for order: {order}") class OrderFacade: def place_order(self, order): order_processor = OrderProcessor() payment_processor = PaymentProcessor() order_processor.process_order(order) payment_processor.process_payment(order) # Example usage order_facade = OrderFacade() order_facade.place_order("12345")
Refactored to low connascence:
import time class OrderValidator: def validate_order(self, order): print(f"Validating order: {order}") class OrderShipper: def ship_order(self, order): print(f"Shipping order: {order}") class OrderProcessor: def __init__(self, order_validator, order_shipper): self.order_validator = order_validator self.order_shipper = order_shipper def process_order(self, order): self.order_validator.validate_order(order) time.sleep(2) # Simulating order processing time self.order_shipper.ship_order(order) class PaymentProcessor: def process_payment(self, order): time.sleep(1) # Simulating payment processing time print(f"Processing payment for order: {order}") class OrderFacade: def __init__(self, order_processor, payment_processor): self.order_processor = order_processor self.payment_processor = payment_processor def place_order(self, order): self.order_processor.process_order(order) self.payment_processor.process_payment(order) # Example usage order_validator = OrderValidator() order_shipper = OrderShipper() order_processor = OrderProcessor(order_validator, order_shipper) payment_processor = PaymentProcessor() order_facade = OrderFacade(order_processor, payment_processor) order_facade.place_order("12345")
Explanation:
Separation of Concerns:
In the original code, the
OrderProcessor
was responsible for both validating and shipping the order. In the refactored code, we've separated the concerns by creatingOrderValidator
andOrderShipper
classes.
Dependency Injection:
The
OrderProcessor
now acceptsOrderValidator
andOrderShipper
as dependencies through its constructor. This allows for better flexibility, testability, and easier modification of components.
Modular Structure:
Each component (
OrderValidator
,OrderShipper
,OrderProcessor
,PaymentProcessor
) now has a well-defined responsibility, promoting a more modular and maintainable codebase.
Dependency Inversion:
The
OrderFacade
class no longer directly creates instances ofOrderProcessor
andPaymentProcessor
. Instead, it receives these instances through its constructor, adhering to the Dependency Inversion Principle.
Simulation of Processing Time:
We've retained the
time.sleep
calls to simulate processing time, but these can be adjusted or replaced with actual processing logic based on the system's needs.
By refactoring the code, we've improved its maintainability, testability, and flexibility. Each class now has a single responsibility, and dependencies are managed more explicitly through dependency injection.
Data Connascence
High connascence example:
def get_order_details(order_id): """Retrieves order details from a global dictionary.""" return global_order_details[order_id] def process_order(order_id): """Processes an order using global order details.""" order = get_order_details(order_id) validate_order(order) prepare_shipment(order) send_order_confirmation(order)
This code exhibits high data connascence because it relies on a global dictionary to store order details. This makes the code tightly coupled to the global state, making it difficult to test and reuse.
Refactored to low connascence:
def get_order_details(order_id): """Retrieves order details from a database.""" connection = connect_to_database() order_details = get_order_details_from_database(order_id, connection) connection.close() return order_details def process_order(order_id): """Processes an order using a local data structure.""" order_details = get_order_details(order_id) validate_order(order_details) prepare_shipment(order_details) send_order_confirmation(order_details)
The refactored code uses local data structures to store order details, minimizing data connascence and making the code more modular and testable.
Control Connascence
High connascence example:
def process_order(order_id): """Processes an order using nested conditional statements.""" if order_id == 1234: if is_order_valid(order): if is_payment_received(order): prepare_shipment(order) send_order_confirmation(order) else: print("Order not found")
This code exhibits high control connascence because it makes decisions using nested conditional statements within the same function. This makes the code difficult to understand and maintain, as the control flow is convoluted.
Refactored to low connascence:
def process_order(order_id): """Processes an order using separate functions for decision making.""" order = get_order_details(order_id) if not is_order_valid(order): print("Order not valid") return if not is_payment_received(order): print("Payment not received") return prepare_shipment(order) send_order_confirmation(order)
The refactored code moves the decision-making logic into separate functions, minimizing control connascence and improving code readability. This makes the code easier to understand and maintain, as the control flow is more explicit.
These examples demonstrate how high connascence can lead to code that is difficult to understand, modify, and test. By refactoring code to low connascence, developers can create more modular, maintainable, and adaptable software
Summary
Understanding and applying cohesion, coupling, and connascence principles are essential for creating maintainable, flexible, and scalable software systems. Striking the right balance between these principles requires thoughtful design and consideration of the specific requirements of the system being developed. By adhering to these principles, developers can build robust and adaptable software that stands the test of time.
References
Object-Oriented Software Construction, 2nd edition (entire text from the 1997 edition) (bertrandmeyer.com) - Chapter 3, Bertrand Meyer
What is Modularity, Cohesion, Coupling and Connascence? - Ehsan Movaffagh
[Summary — Chap 3] Fundamentals of Software Architecture by Mark Richards (Author), Neal Ford (Author) - Bianca Magri
Coupling and Cohesion – Software Engineering - geeksforgeeks
Coupling, Cohesion & Connascence - Khalil Stemmler