Skip to content

Prefer looser coupling over tighter coupling

Dependency is the key problem in software development at all scales

- Kent Beck in Test Driven Development: By Example

Software design is the art of managing inter-dependencies between software components. It aims at minimizing artificial (technical) dependencies and introduces the necessary abstractions and compromises.

- Klaus Iglberger in C++ Software Design

Coupling is one of the most important ideas to think about when we start to think about how to manage complexity.

- Dave Farley in Modern Software Engineering

All three (Beck, Iglberger, Farley) talk about the same underlying concept:

  • Dependencies are links between components.
  • Coupling is the strength and cost of those dependencies.
  • Good design minimizes unnecessary or tight coupling so components can change, test, and evolve independently.

Coupling is defined as “the degree of interdependence between software modules; a measure of how closely connected two routines or modules are; the strength of the relationships between modules.”1

In the next code snippet, there is tight coupling between UserService and ConsoleLogger, which makes UserService hard2 to test in isolation and the logger hard to replace or extend.

#include <iostream>
#include <string>
#include <vector>
using Username = std::string;
class ConsoleLogger {
public:
void log(const std::string& msg) {
std::cout << "[LOG] " << msg << "\n";
}
};
class UserService {
ConsoleLogger logger; // hard dependency
std::vector<Username> users;
public:
void createUser(const Username& name) {
users.push_back(name);
logger.log("User created: " + name);
}
};

The classical way to reduce the coupling, to remove the hard dependency between UserService and ConsoleLogger, is to use Dependency Injection.

Dependency Injection with dynamic polymorphism

Section titled “Dependency Injection with dynamic polymorphism”
#include <iostream>
#include <string>
#include <vector>
using Username = std::string;
class Logger {
public:
virtual void log(const std::string& msg) = 0;
virtual ~Logger() = default;
};
struct ConsoleLogger : Logger {
void log(const std::string& msg) override {
std::cout << "[LOG] " << msg << "\n";
}
};
class UserService {
Logger& logger; // depends on abstraction, not detail
std::vector<Username> users;
public:
explicit UserService(Logger& logger) : logger(logger) {}
void createUser(const Username& name) {
users.push_back(name);
logger.log("User created: " + name);
}
};

Dependency Injection with static polymorphism

Section titled “Dependency Injection with static polymorphism”
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using Username = std::string;
struct ConsoleLogger {
void log(const std::string& msg) const {
std::cout << "[LOG] " << msg << '\n';
}
};
template <typename Logger>
class UserService {
Logger logger;
std::vector<Username> users;
public:
explicit UserService(Logger logger)
: logger(std::move(logger)) {}
void createUser(const Username& name) {
users.push_back(name);
logger.log("User created: " + name);
}
};
int main() {
ConsoleLogger logger;
UserService<ConsoleLogger> service{logger};
service.createUser("Alice");
}
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
using Username = std::string;
struct ConsoleLogger {
void log(const std::string& msg) const {
std::cout << "[LOG] " << msg << '\n';
}
};
template <typename Logger>
class UserService {
static_assert(std::is_invocable_v<decltype(&Logger::log), Logger, const std::string&>,
"Logger must have a log method that accepts const std::string&");
Logger logger;
std::vector<Username> users;
public:
explicit UserService(Logger logger)
: logger(std::move(logger)) {}
void createUser(const Username& name) {
users.push_back(name);
logger.log("User created: " + name);
}
};
int main() {
ConsoleLogger logger;
UserService service{logger}; // CTAD in C++17
service.createUser("Alice");
}
#include <iostream>
#include <string>
#include <concepts>
#include <utility>
#include <vector>
using Username = std::string;
// Define a concept to constrain Logger types
template <typename T>
concept LoggerConcept = requires(T logger, const std::string& msg) {
{ logger.log(msg) } -> std::same_as<void>;
};
struct ConsoleLogger {
void log(const std::string& msg) const {
std::cout << "[LOG] " << msg << '\n';
}
};
template <LoggerConcept Logger>
class UserService {
Logger logger;
std::vector<Username> users;
public:
explicit UserService(Logger logger)
: logger(std::move(logger)) {}
void createUser(const Username& name) {
users.push_back(name);
logger.log("User created: " + name);
}
};
int main() {
ConsoleLogger logger;
UserService service{logger};
service.createUser("Alice");
}

The previous examples still couple createUser() to the concept of logging. That is already better than depending on ConsoleLogger, but it still says that logging is the thing that must happen after a user is created.

A looser design is to express the more general event: “a user was created”.

Then UserService only performs the domain action and notifies an injected listener. Logging becomes just one possible reaction.

#include <functional>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using Username = std::string;
using UserCreatedListener = std::function<void(const Username&)>;
class UserService {
UserCreatedListener onUserCreated;
std::vector<Username> users;
public:
explicit UserService(UserCreatedListener onUserCreated)
: onUserCreated(std::move(onUserCreated)) {}
void createUser(const Username& name) {
users.push_back(name);
onUserCreated(name);
}
};
int main() {
auto logUserCreated = [](const Username& name) {
std::cout << "[LOG] User created: " << name << '\n';
};
UserService service{logUserCreated};
service.createUser("Alice");
}
#include <concepts>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using Username = std::string;
template <std::invocable<const Username&> Listener>
class UserService {
Listener onUserCreated;
std::vector<Username> users;
public:
explicit UserService(Listener onUserCreated)
: onUserCreated(std::move(onUserCreated)) {}
void createUser(const Username& name) {
users.push_back(name);
onUserCreated(name);
}
};
int main() {
auto logUserCreated = [](const Username& name) {
std::cout << "[LOG] User created: " << name << '\n';
};
UserService service{logUserCreated}; // CTAD
service.createUser("Alice");
}

This is a useful next step in the design:

  • UserService no longer knows about logging
  • the side effect is expressed as a domain event
  • other reactions can be added without changing createUser()

It also improves testability. A test that only cares about whether the user was added does not need to provide a mock logger anymore. It can inject a no-op listener and keep the test focused on the behavior under test.

auto ignoreUserCreated = [](const Username&) {};
UserService service{ignoreUserCreated};
service.createUser("Alice");

Once the event is modeled explicitly, multiple reactions can be composed into one listener.

#include <functional>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using Username = std::string;
using UserCreatedListener = std::function<void(const Username&)>;
class UserService {
UserCreatedListener onUserCreated;
std::vector<Username> users;
public:
explicit UserService(UserCreatedListener onUserCreated)
: onUserCreated(std::move(onUserCreated)) {}
void createUser(const Username& name) {
users.push_back(name);
onUserCreated(name);
}
};
int main() {
auto logUserCreated = [](const Username& name) {
std::cout << "[LOG] User created: " << name << '\n';
};
auto emailAdmin = [](const Username& name) {
/* Use email service to email the admin */
};
auto notifyAll = [=](const Username& name) {
logUserCreated(name);
emailAdmin(name);
};
UserService service{notifyAll};
service.createUser("Alice");
}
#include <concepts>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using Username = std::string;
template <std::invocable<const Username&> Listener>
class UserService {
Listener onUserCreated;
std::vector<Username> users;
public:
explicit UserService(Listener onUserCreated)
: onUserCreated(std::move(onUserCreated)) {}
void createUser(const Username& name) {
users.push_back(name);
onUserCreated(name);
}
};
int main() {
auto logUserCreated = [](const Username& name) {
std::cout << "[LOG] User created: " << name << '\n';
};
auto emailAdmin = [](const Username& name) {
/* Use email service to email the admin */
};
auto notifyAll = [=](const Username& name) {
logUserCreated(name);
emailAdmin(name);
};
UserService service{notifyAll}; // CTAD
service.createUser("Alice");
}
  1. Wikipedia, https://en.wikipedia.org/wiki/Coupling_(computer_programming)

  2. Hard to do without intrusive modifications to UserService, which would violate the Open-Closed Principle.