This lesson was translated automatically – it may contain errors.
Coupling is a measure of how inseparable an element is from the other elements. Low coupling is an evaluative model that suggests how to distribute responsibilities.
What a loosely coupled class looks like
- it depends on other classes only weakly;
- it does not depend on outside changes: a fix in one class barely affects the rest;
- it is easy to reuse.
What tight coupling looks like
A report service that does everything itself:
// The service creates the connection itself and knows the SQL for writing a report
public class ReportService
{
private readonly PostgresConnection _db = new("host=localhost;db=reports");
public void Publish(Report report)
{
report.MarkReady();
_db.ExecuteSql(
"INSERT INTO reports (title, body) VALUES (@title, @body)",
report.Title, report.Body);
}
}
// The service creates the connection itself and knows the SQL for writing a report
class ReportService {
public:
void Publish(Report& report) {
report.MarkReady();
db_.ExecuteSql("INSERT INTO reports (title, body) VALUES ($1, $2)",
report.title(), report.body());
}
private:
PostgresConnection db_{"host=localhost dbname=reports"};
};
// The service creates the connection itself and knows the SQL for writing a report
type ReportService struct {
db *PostgresConnection
}
func NewReportService() *ReportService {
return &ReportService{db: OpenPostgres("host=localhost dbname=reports")}
}
func (s *ReportService) Publish(report *Report) error {
report.MarkReady()
return s.db.ExecuteSQL(
"INSERT INTO reports (title, body) VALUES ($1, $2)",
report.Title, report.Body,
)
}
# The service creates the connection itself and knows the SQL for writing a report
class ReportService:
def __init__(self) -> None:
self._db = PostgresConnection("host=localhost dbname=reports")
def publish(self, report: Report) -> None:
report.mark_ready()
self._db.execute_sql(
"INSERT INTO reports (title, body) VALUES (%s, %s)",
report.title,
report.body,
)
// The service creates the connection itself and knows the SQL for writing a report
class ReportService {
private readonly db = new PostgresConnection('host=localhost db=reports');
publish(report: Report): void {
report.markReady();
this.db.executeSql('INSERT INTO reports (title, body) VALUES ($1, $2)', report.title, report.body);
}
}
// The service creates the connection itself and knows the SQL for writing a report
public class ReportService {
private final PostgresConnection db = new PostgresConnection("host=localhost;db=reports");
public void publish(Report report) {
report.markReady();
db.executeSql(
"INSERT INTO reports (title, body) VALUES (?, ?)",
report.getTitle(), report.getBody());
}
}
// The service creates the connection itself and knows the SQL for writing a report
class ReportService {
private val db = PostgresConnection("host=localhost db=reports")
fun publish(report: Report) {
report.markReady()
db.executeSql(
"INSERT INTO reports (title, body) VALUES ($1, $2)",
report.title, report.body,
)
}
}
The class is small, and yet it is coupled to three things at once: to a particular database, to its SQL dialect and to the connection string. Here is what comes of that:
- the test cannot be written – the connection is created inside the constructor, there is nothing to replace it with, and every run goes to a live database;
- moving to another database means editing the service – the SQL sits right here, even though it has nothing to do with publishing a report;
- the service cannot be reused – the Postgres driver travels with it into any project;
- the logic is buried in details – the method boils down to «mark it and save it», but spotting that between the connection string and the SQL is already hard.
How to untangle it
What the service needs is not a connection but the ability to save a report. That is exactly what we move into an interface, while the concrete implementation is passed in from outside:
// The service knows only about the storage interface
public interface IReportStorage
{
void Save(Report report);
}
public class ReportService
{
private readonly IReportStorage _storage;
public ReportService(IReportStorage storage) => _storage = storage;
public void Publish(Report report)
{
report.MarkReady();
_storage.Save(report);
}
}
// The service knows only about the storage interface
class IReportStorage {
public:
virtual ~IReportStorage() = default;
virtual void Save(const Report& report) = 0;
};
class ReportService {
public:
explicit ReportService(IReportStorage& storage) : storage_(storage) {}
void Publish(Report& report) {
report.MarkReady();
storage_.Save(report);
}
private:
IReportStorage& storage_;
};
// The service knows only about the storage interface
type ReportStorage interface {
Save(report *Report) error
}
type ReportService struct {
storage ReportStorage
}
func NewReportService(storage ReportStorage) *ReportService {
return &ReportService{storage: storage}
}
func (s *ReportService) Publish(report *Report) error {
report.MarkReady()
return s.storage.Save(report)
}
# The service knows only about the storage interface
class ReportStorage(Protocol):
def save(self, report: Report) -> None: ...
class ReportService:
def __init__(self, storage: ReportStorage) -> None:
self._storage = storage
def publish(self, report: Report) -> None:
report.mark_ready()
self._storage.save(report)
// The service knows only about the storage interface
interface ReportStorage {
save(report: Report): void;
}
class ReportService {
constructor(private readonly storage: ReportStorage) {}
publish(report: Report): void {
report.markReady();
this.storage.save(report);
}
}
// The service knows only about the storage interface
public interface ReportStorage {
void save(Report report);
}
public class ReportService {
private final ReportStorage storage;
public ReportService(ReportStorage storage) {
this.storage = storage;
}
public void publish(Report report) {
report.markReady();
storage.save(report);
}
}
// The service knows only about the storage interface
interface ReportStorage {
fun save(report: Report)
}
class ReportService(private val storage: ReportStorage) {
fun publish(report: Report) {
report.markReady()
storage.save(report)
}
}
The class got shorter, but the lines are not what we won:
- a test takes a minute to write – an in-memory storage goes in instead of a database;
- the database changes in one place – the SQL moves into
PostgresReportStorageand the service never finds out; a cache, a queue or writing to a file is added the same way; - the service is reusable – it compiles and runs without the Postgres driver;
- you can see what the method does – mark the report ready and save it, everything else is behind the interface.
The coupling has not disappeared: the service still depends on ReportStorage. But now the
dependency is on a stable two-line interface rather than on someone else’s library – it has
almost no reason to change. The same move is called the
dependency inversion principle in SOLID.