Степень связности – мера неотрывности элемента от других элементов. Слабое зацепление – это оценочная модель, которая подсказывает, как распределить обязанности.
Каким бывает класс со слабым зацеплением
- имеет слабую зависимость от других классов;
- не зависит от внешних изменений: правка в одном классе слабо влияет на остальные;
- прост для повторного использования.
Как выглядит сильное зацепление
Сервис отчётов, который всё делает сам:
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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);
}
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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"};
};
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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,
)
}
# Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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,
)
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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);
}
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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());
}
}
// Сервис сам создаёт соединение и сам знает, каким SQL писать отчёт
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,
)
}
}
Класс небольшой, а зацеплен сразу с тремя вещами: с конкретной СУБД, с её диалектом SQL и со строкой подключения. Что из этого выйдет:
- тест не написать – соединение создаётся внутри конструктора, подменить его нечем, и любой прогон идёт в живую базу;
- переезд на другую СУБД правит сервис – SQL лежит здесь же, хотя к публикации отчёта отношения не имеет;
- сервис не переиспользовать – вместе с ним в любой проект приезжает драйвер Postgres;
- логику не видно за деталями – суть метода в двух словах «пометить и сохранить», но разглядеть их между строкой подключения и SQL уже сложно.
Как развязать
Сервису нужно не соединение, а возможность сохранить отчёт. Ровно это и выносим в интерфейс, а конкретную реализацию передаём снаружи:
// Сервис знает только про интерфейс хранилища
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);
}
}
// Сервис знает только про интерфейс хранилища
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_;
};
// Сервис знает только про интерфейс хранилища
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)
}
# Сервис знает только про интерфейс хранилища
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)
// Сервис знает только про интерфейс хранилища
interface ReportStorage {
save(report: Report): void;
}
class ReportService {
constructor(private readonly storage: ReportStorage) {}
publish(report: Report): void {
report.markReady();
this.storage.save(report);
}
}
// Сервис знает только про интерфейс хранилища
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);
}
}
// Сервис знает только про интерфейс хранилища
interface ReportStorage {
fun save(report: Report)
}
class ReportService(private val storage: ReportStorage) {
fun publish(report: Report) {
report.markReady()
storage.save(report)
}
}
Класс стал короче, но выиграли не строки:
- тест пишется за минуту – вместо базы подставляется хранилище в памяти;
- СУБД меняется в одном месте – SQL уезжает в
PostgresReportStorage, сервис об этом не узнает; так же добавляется кеш, очередь или запись в файл; - сервис переиспользуется – он компилируется и работает без драйвера Postgres;
- видно, что делает метод – пометить отчёт готовым и сохранить, остальное за интерфейсом.
Зацепление не исчезло: сервис по-прежнему зависит от ReportStorage. Но зависимость теперь
на устойчивый интерфейс из двух строк, а не на чужую библиотеку – меняться ему причин
почти нет. Тот же ход в SOLID называется
принципом инверсии зависимостей.