In a previous article I explored the concept of Knowledge coupling
and its various levels. Here we will explore more levels of coupling between modules, specifically focusing on the levels identified by the Structured Design. Structured design’s module coupling refers to the degree of interdependence between software modules.
Here are the types of coupling in order of increasing interdependence:
Content coupling happens when one module interacts with another module through the most implicit interface possible, one that may not be documented by the module’s author. This means that the consuming module must have detailed knowledge of the inner workings and implementation details of the module it is using, far beyond what should be necessary or recommended. This tight coupling to internal specifics makes the system more fragile and difficult to maintain, as any changes in the implementation of the upstream module can potentially break the functionality of the downstream module. This type of integration undermines modular design principles, as it bypasses the intended abstraction and encapsulation that modules are supposed to provide.
mod module_a {
pub struct Data {
pub value: i32,
}
impl Data {
pub fn new(value: i32) -> Self {
Data { value }
}
}
}
mod module_b {
use super::module_a::Data;
pub fn process_data(data: &Data) -> i32 {
// Relying on the public field `value` of `Data` from `module_a`
// This creates content coupling because `module_b` is now dependent
// on the specific structure of `Data`.
data.value * 2
}
}
fn main() {
let data = module_a::Data::new(42);
let result = module_b::process_data(&data);
println!("Processed value: {}", result);
}
Rust’s compile time verification makes content coupling more tricky than other languages, e.g. you cannot access a private method of another struct. In some
languages content coupling by mistake can be done quite easily, e.g. in Javascript,
while in other languages certain features of the language make it more easy to be “achieved”. A typical example of content coupling is the use of Reflection in Java. The InvoiceGenerator
class has a private method verifyInput
.
The ReflectionExample
class contains a method doSomething
that uses reflection to access and invoke the private verifyInput
method of the InvoiceGenerator
class.
The getDeclaredMethod
method retrieves the private method, and setAccessible(true)
allows access to it. Finally, invoke
is used to call the private method on an instance of InvoiceGenerator
. This kind of reflection-based access breaks encapsulation and leads to content coupling because ReflectionExample
becomes tightly coupled to the internal implementation details of InvoiceGenerator
. If the private method verifyInput
is changed or removed, it will break the doSomething
method in ReflectionExample
.
import java.lang.reflect.Method;
class InvoiceGenerator {
// Private method
private void verifyInput(String input) {
System.out.println("Verifying input: " + input);
}
}
public class ReflectionExample {
public void doSomething() {
try {
// Create an instance of InvoiceGenerator
InvoiceGenerator invoice = new InvoiceGenerator();
// Get the class type of InvoiceGenerator
Class<?> clazz = invoice.getClass();
// Get the private method 'verifyInput' from the class type
Method privateMethod = clazz.getDeclaredMethod("verifyInput", String.class);
// Make the private method accessible
privateMethod.setAccessible(true);
// Invoke the private method on the instance of InvoiceGenerator
privateMethod.invoke(invoice, "input");
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ReflectionExample example = new ReflectionExample();
example.doSomething();
}
}
Common coupling occurs when multiple modules share access to the same global data structure. This means that all the modules can read from and write to this shared data, leading to a high degree of interdependence. Changes to the shared data structure can have wide-ranging impacts, as every module that accesses it must be aware of and accommodate those changes. This type of coupling makes it difficult to understand and manage the flow of data within the system, as well as to track which modules are responsible for modifications. It also increases the risk of errors and conflicts, particularly in concurrent or multi-threaded environments, as different modules might simultaneously attempt to modify the shared data.
use std::sync::{Mutex, Arc};
use std::thread;
lazy_static::lazy_static! {
static ref GLOBAL_DATA: Arc<Mutex<SharedData>> = Arc::new(Mutex::new(SharedData {
alpha: 0,
beta: 0,
gamma: 0,
delta: 0,
}));
}
struct SharedData {
alpha: i32,
beta: i32,
gamma: i32,
delta: i32,
}
mod module_a {
use super::{GLOBAL_DATA, SharedData};
pub fn modify_alpha(value: i32) {
let mut data = GLOBAL_DATA.lock().unwrap();
data.alpha = value;
println!("module_a: alpha set to {}", data.alpha);
}
pub fn read_data() {
let data = GLOBAL_DATA.lock().unwrap();
println!(
"module_a: alpha={}, beta={}, gamma={}, delta={}",
data.alpha, data.beta, data.gamma, data.delta
);
}
}
mod module_b {
use super::{GLOBAL_DATA, SharedData};
pub fn modify_beta(value: i32) {
let mut data = GLOBAL_DATA.lock().unwrap();
data.beta = value;
println!("module_b: beta set to {}", data.beta);
}
pub fn read_data() {
let data = GLOBAL_DATA.lock().unwrap();
println!(
"module_b: alpha={}, beta={}, gamma={}, delta={}",
data.alpha, data.beta, data.gamma, data.delta
);
}
}
fn main() {
let thread_a = thread::spawn(|| {
module_a::modify_alpha(10);
module_a::read_data();
});
let thread_b = thread::spawn(|| {
module_b::modify_beta(20);
module_b::read_data();
});
thread_a.join().unwrap();
thread_b.join().unwrap();
}
Common coupling can span the boundaries of a service, for example:
Control coupling occurs when one module dictates the behavior of another module by passing control information, such as flags, commands, or options, that specifies not just what needs to be done, but also how it should be done. This results in the calling module micromanaging the execution details of the receiving module, leading to a high level of dependency. The receiving module’s internal logic is influenced directly by the control data, making it less autonomous and harder to maintain or modify independently. Changes to the control information in the calling module can necessitate corresponding changes in the receiving module, thereby tightly coupling their functionalities and complicating the system’s overall design and adaptability.
fn process_payment(payment_method: &str, amount: f64) {
match payment_method {
"CreditCard" => process_credit_card(amount),
"PayPal" => process_paypal(amount),
"Bitcoin" => process_bitcoin(amount),
_ => panic!("Unsupported payment method"),
}
}
fn process_credit_card(amount: f64) {
println!("Processing credit card payment of ${:.2}", amount);
}
fn process_paypal(amount: f64) {
println!("Processing PayPal payment of ${:.2}", amount);
}
fn process_bitcoin(amount: f64) {
println!("Processing Bitcoin payment of ${:.2}", amount);
}
struct User {
prefers_credit_card: bool,
prefers_paypal: bool,
prefers_bitcoin: bool,
}
fn make_payment(user: &User, amount: f64) {
let payment_method = if user.prefers_credit_card {
"CreditCard"
} else if user.prefers_paypal {
"PayPal"
} else if user.prefers_bitcoin {
"Bitcoin"
} else {
panic!("No suitable payment method found for user");
};
process_payment(payment_method, amount);
}
fn main() {
let user = User {
prefers_credit_card: true,
prefers_paypal: false,
prefers_bitcoin: true,
};
let amount = 100.0;
make_payment(&user, amount);
}
Data-structured coupling (also known as Stamp Coupling), occurs when modules share composite data structures, such as objects, structures, or records. Instead of sharing only the necessary data elements, a module passes a whole data structure to another module, even if the receiving module only needs a part of that structure. This leads to a higher degree of coupling because the receiving module depends on the structure of the passed data, making changes in the data structure impact all dependent modules.
// Define a composite data structure
struct Order {
id: u32,
customer_name: String,
item: String,
quantity: u32,
price: f64,
}
fn print_order_summary(order: &Order) {
println!(
"Order ID: {}, Customer: {}, Item: {}, Quantity: {}",
order.id, order.customer_name, order.item, order.quantity
);
}
fn calculate_total_price(order: &Order) -> f64 {
order.quantity as f64 * order.price
}
fn main() {
let order = Order {
id: 1,
customer_name: String::from("John Doe"),
item: String::from("Laptop"),
quantity: 2,
price: 1500.0,
};
print_order_summary(&order);
let total_price = calculate_total_price(&order);
println!("Total Price: ${:.2}", total_price);
}
Both print_order_summary
and calculate_total_price
functions accept the entire Order struct as an argument, even though they only use some of its fields. If the Order
struct changes (e.g., if a field is renamed), both functions might be affected, leading to higher maintenance overhead.
To reduce stamp coupling, you can pass only the necessary fields to the functions instead of the entire struct:
fn print_order_summary(id: u32, customer_name: &str, item: &str, quantity: u32) {
println!(
"Order ID: {}, Customer: {}, Item: {}, Quantity: {}",
id, customer_name, item, quantity
);
}
fn calculate_total_price(quantity: u32, price: f64) -> f64 {
quantity as f64 * price
}
fn main() {
let order = Order {
id: 1,
customer_name: String::from("John Doe"),
item: String::from("Laptop"),
quantity: 2,
price: 1500.0,
};
print_order_summary(order.id, &order.customer_name, &order.item, order.quantity);
let total_price = calculate_total_price(order.quantity, order.price);
println!("Total Price: ${:.2}", total_price);
}
This form of coupling where we pass only the nessecary information, is called data coupling and is the lowest form of coupling.