More on coupling

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:

  • Data Coupling: Modules share data through parameters. It’s the lowest and most desirable form of coupling.
  • Data Structure Coupling: Modules share a composite data structure, but use only a part of it.
  • Control Coupling: One module controls the behavior of another by passing control information.
  • External Coupling: Modules share external data formats, communication protocols, or device interfaces.
  • Common Coupling: Modules share global data.
  • Content Coupling: One module directly accesses or modifies the content of another module. It’s the highest and least desirable form of coupling.

Content Coupling

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

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:

common coupling

Control Coupling

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-structure coupling

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.

Published 30 Jun 2024

Tüftler (someone who enjoys working on and solving technical problems, often in a meticulous and innovative manner). Opinions are my own and not necessarily the views of my employer.
Avraam Mavridis on Twitter