Prefer looser coupling over tighter coupling
Dependency is the key problem in software development at all scales
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.
Coupling is one of the most important ideas to think about when we start to think about how to manage complexity.
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
Example
Section titled “Example”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 typestemplate <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");}Decouple the what, not just the how
Section titled “Decouple the what, not just the how”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:
UserServiceno 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");Multiple observers can react
Section titled “Multiple observers can react”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");}See also
Section titled “See also”Footnotes
Section titled “Footnotes”-
Wikipedia, https://en.wikipedia.org/wiki/Coupling_(computer_programming) ↩
-
Hard to do without intrusive modifications to
UserService, which would violate the Open-Closed Principle. ↩