Чистая архитектура (англ. Clean Architecture) — это набор принципов и рекомендаций по проектированию программного обеспечения, направленный на создание модульного и гибкого кода, который легко поддерживать и расширять.
Вот какие плюсы дают принципы чистой архитектуры:
-
Высокое качество кодовой базы: код хорошо читается, не требуется много времени на погружение нового разработчика в проект.
-
Высокая скорость адаптации к изменениям: срок интеграции нового функционала сводится к минимуму.
-
Независимость от технологий: каждый слой не зависит от выбора технологий, используемых в других слоях, что позволяет легко вносить изменения в технологии без затрагивания других слоёв.
-
Лёгкость в тестировании: каждый слой может быть протестирован независимо от других, что облегчает написание модульных тестов.
-
Гибкость: чистая архитектура позволяет легко и быстро добавлять новые функции или изменять существующие, так как каждый слой может быть расширен или изменён независимо от других.
-
Уменьшение связности: разделение системы на слои уменьшает количество связей между ними, что упрощает понимание и поддержку системы.
Дальше мы постараемся раскрыть, с помощью каких средств достигаются вышеперечисленные блага. Но сперва — небольшой дисклеймер.
Дисклеймер
Дисклеймер
Тема чистой архитектуры отлично описана в книге Роберта Мартина «Чистый код», написано множество статей и разборов сквозь призмы различных языков программирования. Тема очень обширная, по ней можно написать отдельный учебник. В этом параграфе мы рассмотрим только необходимые вещи для дальнейшего изучения инструментов Flutter.
Если вам интересно углубиться в тематику, то рекомендуем изучить следующие материалы:
И ещё кое-что.
Далее часто будут встречаться термины «абстракция», «абстрактный класс», «интерфейс». Эти термины используются в более широком смысле, чем может показаться на первый взгляд. Говоря «абстракция», мы имеем в виду не конкретно «абстрактный класс» или «интерфейс», а средство/контракт, которые позволяют одной сущности не зависеть от другой.
Всё, мы готовы приступать.
Слои
Одним из главных принципов Дяди Боба — именно так часто называют Роберта Мартина — стало разделение программного продукта на слои, каждый из которых выполняет конкретную функцию и не зависит от другого. Слои взаимодействуют между собой через чётко определённые интерфейсы.
Принцип разделения на слои можно описать с помощью схемы:
-
Корпоративные бизнес-правила (Enterprise Business Rules) — объекты, которые представляют данные и бизнес-логику приложения, не зависят от фреймворка или технологий, используемых для реализации приложения.
-
Прикладные бизнес-правила (Application Business Rules) — правила, которые описывают, как система должна взаимодействовать с внешним миром, определяют, какое поведение разрешено или запрещено в системе. Другими словами, определяют, как система будет работать.
-
Адаптеры интерфейса (Interface Adapters) — классы, которые реализуют взаимодействие между внутренними слоями приложения. Они преобразуют запросы из внешнего мира в вызовы методов внутри системы.
-
Фреймворки и драйверы (Frameworks & Drivers) — внешний слой, отвечающий за взаимодействие с I/O-сущностями (базы данных, устройства, UI) и их адаптацию к внутренним слоям.
Схему Дядюшки Боба можно представить в горизонтальном разрезе:
Здесь мы видим три слоя:
-
слой представления (Presentation layer);
-
доменный слой (Domain layer);
-
слой доступа к данным (Data layer).
Поговорим о них подробнее.
Слой представления
Слой представления (или UI, пользовательский интерфейс) отвечает за отображение данных и взаимодействие с пользователем. Это может быть:
-
графический интерфейс пользователя (GUI) — например, веб-сайт или мобильное приложение;
-
консоль или командная строка для взаимодействия с системой;
-
API для интеграции с другими системами.
Слой представления отображает информацию, полученную от доменного слоя, и передаёт входящую информацию обратно. Слой должен инкапсулировать весь специфичный для фреймворка код (то есть всё, что относится к Flutter), в остальных слоях должен находиться только Dart-код. Это позволяет подменить слой представления без внесения изменений в доменный слой и/или слой доступа к данным.
Слой представления явно зависит от доменного слоя, именно он определяет, каким будет представление. В итоге получается, что представление зависит от доменного слоя, но от представления не зависят другие слои.
Слой представления должен быть:
-
Легко тестируемым. Тесты должны проверять, правильно ли работает пользовательский интерфейс и соответствует ли он требованиям пользователей.
-
Гибким и расширяемым. Пользовательский интерфейс должен адаптироваться к изменениям требований и предпочтений пользователей. Это достигается как раз из-за того, что от слоя никто не зависит.
Доменный слой
Этот слой отвечает за бизнес-логику системы, определяет основные сущности и правила работы с ними, а также инкапсулирует всю логику. Другими словами — это фундамент для всего проекта, и именно он определяет, какими будут слой представления и слой доступа к данным.
Основные функции доменного слоя:
-
Определение сущностей.
Слой доменаопределяет основные объекты и понятия предметной области, такие как «пользователи», «заказы», «товары» и так далее. -
Бизнес-правила. В этом слое определяются правила и ограничения, которые должны соблюдаться при работе с объектами предметной области. Например, правила проверки данных, ограничения на количество товаров в заказе и тому подобное.
-
Инкапсуляция логики. Слой скрывает детали реализации бизнес-правил от других слоёв системы. Это позволяет легко изменять или обновлять логику без влияния на пользовательский интерфейс и
слой доступа к данным.
Доменный слой использует различные инструменты для реализации бизнес-логики и построения взаимодействия с источниками данных, такие как UML (Unified Modeling Language), ООП (объектно-ориентированное программирование), шаблоны проектирования и другие.
Слой доступа к данным
Этот слой отвечает за прямую работу с источниками данных (БД, девайсы, сеть и так далее). Источники могут быть изменены в ходе жизненного цикла проекта. Основная задача слоя доступа к данным заключается в получении данных и удовлетворении контрактов, установленных доменным слоем.
Мостом для коммуникации между слоем доступа к данным и доменным слоем служит контракт, который удовлетворяет требования доменного слоя. Кроме того, контракт служит для обеспечения абстракции.
Основная задача абстракции заключается в обеспечении безболезненной смены источника данных (БД, девайсов, сетевого протокола и т. д). То есть смена должна происходить без изменений остальных слоёв.
Основные функции:
-
Взаимодействие с источниками данных. Отвечает за соблюдение контрактов
доменного слоя. Другими словами, отвечает за получение данных от источников, обработку результатов запросов и маппинг длядоменного слоя. -
Абстракция от источников данных. Скрывает детали реализации источников данных от других слоёв системы. Это позволяет легко менять источники данных при необходимости.
-
Обработка ошибок. Обрабатывает ошибки, возникающие при взаимодействии с источниками данных, и предоставляет информацию об ошибках другим слоям.
Связи между слоями
Связь между слоями осуществляется через контракты.
Например, слой представления взаимодействует с доменным слоем через контракт, который определяет методы для работы с объектами доменного слоя. Слой доступа к данным также взаимодействует с доменным слоем через контракт, но уже для выполнения операций с данными. Содержание и формат контрактов определяет доменный слой, так как он является фундаментальным.
Разделение ответственности между слоями позволяет легко изменять или обновлять каждый из них без влияния на другие. Это обеспечивает независимость слоёв друг от друга и упрощает тестирование, обслуживание и расширение.
Пример
Разберём на примере. Представим, что нам необходимо сделать программу, которая будет представлять собой записную книжку контактов.
Пользователь через UI-интерфейс может отправлять запрос на получение списка контактов. Этот запрос будет обработан доменным слоем, который обратится к слою доступа к данным за необходимыми данными, затем обработает данные и отправит уже готовый список контактов обратно в UI. При этом UI не знает, как именно доменный слой получает список контактов, а доменный слой не знает, откуда берутся данные. Все эти детали скрыты за интерфейсами и абстракциями.
Доменный слой описывает некоторый контракт о том, как он хочет работать с данными, а слой доступа к данным реализует этот контракт уже средствами конкретных технологий. UI будет взаимодействовать с доменным слоем для отображения данных и получения списка контактов. В итоге мы получаем картину, когда слой представления и слой доступа к данным зависят от доменного слоя, а доменный слой не зависит ни от кого.
Давайте рассмотрим процесс взаимодействия слоёв при запросе данных о конкретном контакте:
- Слой данных
- Конкретная реализация этого класса обеспечивает доступ к
ContactDatabase(конечная реализация источников может быть совершенно разной: Database, SharedPreference, SecureStorage, Network API и так далее).
- Доменный слой
-
Класс
ContactServiceиспользует интерфейсContactSourceдля взаимодействия с базой данных. -
UI-интерфейспередаёт идентификатор пользователя через методgetContactByIdвContactService, который, в свою очередь, используетContactSource, чтобы получить информацию о пользователе, обработать и вернуть вслой представления. -
Интерфейс
СontactSourceопределяет методы для работы с источником данных (например,get,set,update), аContactDatabaseреализует эти методы.
-
Слой представления
-
Форма для поиска пользователя получает идентификатор от пользователя и передаёт его в
ContactService. -
После того как
ContactServiceвернёт данные о пользователе,UIотображает эту информацию пользователю. Это произойдёт реактивно, например через Stream, и это позволит доменному слою не знать ослое представления, а просто выставить стрим, на который уже подписывается UI.
-
Cхематично это может выглядеть следующим образом:
Этот пример демонстрирует, как слои могут взаимодействовать друг с другом через абстракции и интерфейсы. Это обеспечивает гибкость системы и позволяет легко изменять или заменять реализации без изменения кода в других слоях.
Теперь, когда мы разобрались в разделении чистой архитектуры на слои, можно рассмотреть принципы S.O.L.I.D. Они помогут улучшить структуру и качество кода.
S.O.L.I.D
Это группа принципов, которая лежит в основе чистой архитектуры. Каждая буква аббревиатуры соответствует отдельному принципу.
Single Responsibility Principle
Или принцип единственной ответственности. В контексте чистой архитектуры он означает, что каждый модуль или компонент системы должен иметь только одну причину для изменения.
Это помогает упростить обслуживание и развитие системы, поскольку изменения в одном компоненте не влияют на другие.
1// Класс, отвечающий только за представление данных контакта
2class PhoneContact {
3 final String name;
4 final String phone;
5
6 PhoneContact(this.name, this.phone);
7}
8
9// Класс, отвечающий за управление списком контактов
10class PhoneContactList {
11 final List<PhoneContact> _phoneContacts = [];
12
13 void addPhoneContact(PhoneContact phoneContact) => _phoneContacts.add(phoneContact);
14
15 void removePhoneContact(PhoneContact phoneContact) => _phoneContacts.remove(phoneContact);
16
17 List<PhoneContact> get phoneContacts => List.unmodifiable(_phoneContacts);
18}
19
20// Класс, отвечающий за сохранение контактов в файл
21class PhoneContactSaver {
22 void saveToFile(List<PhoneContact> phoneContacts) {
23 // Тут будет код, отвечающий за работу с хранилищем
24 }
25}
26
27// Класс, отвечающий за поиск контактов
28class PhoneContactSearch {
29 List<PhoneContact> searchByName(List<PhoneContact> phoneContacts, String name) {
30 // Тут будет код, отвечающий за поиск контакта по имени
31 }
32}
33
34void main() {
35 // Создаём менеджер контактов
36 final phoneContactList = PhoneContactList();
37
38 // Создаём менеджер поиска
39 final phoneContactSearch = PhoneContactSearch();
40
41 // Создаём менеджер хранения
42 final phoneContactSaver = PhoneContactSaver();
43
44 // Добавляем контакты
45 phoneContactList
46 ..addPhoneContact(PhoneContact("Павел", "+7 999 143-45-88"))
47 ..addPhoneContact(PhoneContact("Мария", "+7 910 123-45-67"));
48
49 // Сохраняем контакты
50 phoneContactSaver.saveToFile(phoneContactList.phoneContacts);
51
52 // Ищем контакт
53 final results = phoneContactSearch.searchByName(phoneContactList.phoneContacts, "Мар");
54
55 // Вывод результатов поиска
56 results.forEach((c) => print("Found: ${c.name} (${c.phone})"));
57}
Как это работает:
-
Если изменится формат хранения (JSON, SQLite) — расширяем только
ContactSaver. -
Если добавится новый способ поиска (по номеру телефона) — расширяем
ContactSearch. -
Если поменяется структура контакта (добавится email) — расширяем только
Contact.
В итоге у каждого класса есть только одна причина для изменения, что соответствует принципу единственной ответственности.
Open-Closed Principle
Или принцип «открытости/закрытости». Он гласит, что программные сущности (классы, модули, функции и так далее) должны быть открыты для расширения, но закрыты для модификации. Это означает, что расширение функциональности сущности не должно приводить к необходимости вносить изменения во внутреннюю реализацию этой сущности.
В контексте чистой архитектуры этот принцип применяется к компонентам приложения, которые отвечают за доменный слой и взаимодействие с внешними системами. Эти компоненты должны быть спроектированы таким образом, чтобы их можно было легко изменять и дополнять новыми функциями без влияния на другие части системы.
Например, если в нашу реализацию записной книжки требуется добавить новый класс контактов — AddressContact, — интеграция должна произойти без внесения изменений в доменный слой.
1// Абстракция контакта (закрыта для изменений)
2abstract class Contact {
3 void displayInfo();
4}
5
6// Конкретная реализация: email-контакт (можно расширять)
7class EmailContact implements Contact {
8 final String name;
9 final String email;
10
11 EmailContact(this.name, this.email);
12
13 @override
14 void displayInfo() => print("Email: $name ($email)");
15}
16
17// Конкретная реализация: телефонный контакт (можно расширять)
18class PhoneContact implements Contact {
19 final String name;
20 final String phone;
21
22 PhoneContact(this.name, this.phone);
23
24 @override
25 void displayInfo() => print("Телефон: $name ($phone)");
26}
27
28// Класс для работы с контактами (не требует изменений при добавлении новых типов)
29class ContactBook {
30 void displayContacts(List<Contact> contacts) {
31 for (var contact in contacts) {
32 contact.displayInfo();
33 }
34 }
35}
36
37// Использование
38void main() {
39 final book = ContactBook();
40 final contacts = [
41 EmailContact("Иван", "ivan@example.com"),
42 PhoneContact("Мария", "+7 999 123-45-67"),
43 ];
44 book.displayContacts(contacts);
45}
Как это работает:
-
Класс
ContactBookзакрыт для изменений (не меняется при добавлении новых типов контактов). -
Новые типы (например,
SocialMediaContact) можно добавить через реализацию интерфейсаContact— он открыт для расширений.
Добавим AddressContact без изменения ContactBook:
1class AddressContact implements Contact {
2 final String name;
3 final String address;
4
5 AddressContact(this.name, this.address);
6
7 @override
8 void displayInfo() => print("Адрес: $name ($address)");
9}
Liskov Substitution Principle (LSP)
Или принцип подстановки Барбары Лисков. Его можно сформулировать следующим образом: вместо базового типа можно использовать любой его подтип, при этом исходная программа будет работать не противореча поведению базового типа.
В контексте чистой архитектуры этот принцип применяется к компонентам приложения, которые реализуют интерфейсы или абстрактные классы. Эти компоненты должны быть спроектированы таким образом, чтобы их можно было заменять на другие компоненты, которые соответствуют тем же интерфейсам или классам. Другими словами: поведение наследующих классов не должно противоречить поведению, заданному базовым классом.
Простым примером может служить класс геометрических фигур. Базовый тип — это абстрактный класс или интерфейс «Фигура» (Shape), а подтипы — это конкретные классы: «Квадрат» (Square), «Треугольник» (Triangle) и так далее. Все они наследуются от класса «Фигура».
Если у нас есть метод, который принимает на вход фигуру, то мы можем передать ему любой из подтипов без изменения поведения программы. Например, если метод должен вычислить площадь фигуры, он сможет корректно обработать и квадрат, и треугольник. Это и есть пример соблюдения принципа LSP.
Но, к сожалению, мир не идеален — и встречаются кейсы, где принцип LSP нарушается. Давайте рассмотрим такое нарушение в Dart. Возьмём класс UnmodifiableListView, он наследует класс UnmodifiableListBase, а UnmodifiableListBase наследуется от класса ListBase, согласно принципу LSP, UnmodifiableListView должен соответствовать базовому классу ListBase.
1class UnmodifiableListView<E> extends UnmodifiableListBase<E>
2
3abstract class UnmodifiableListBase<E> = ListBase<E> with UnmodifiableListMixin<E>;
4
5abstract mixin class ListBase<E> implements List<E>
6
Но мы видим что у UnmodifiableListView переопределены и не реализованы базовые методы родительского класса.
1mixin UnmodifiableListMixin<E> implements List<E> {
2 void add(E value) {
3 throw new UnsupportedError("Cannot add to an unmodifiable list");
4 }
5
6 bool remove(Object? element) {
7 throw new UnsupportedError("Cannot remove from an unmodifiable list");
8 }
9}
Этот пример показывает что реальный мир не идеален и даже в таких крупных проектах, как Flutter, встречаются изъяны и нарушения принципа LSP. Чтобы избегать таких кейсов, вам необходимо всегда держать в голове не только принцип LSP, но и остальные принципы S.O.L.I.D.
Давайте рассмотрим LSP на примере нашей записной книжки контактов.
1// Класс контакта
2class PhoneContact {
3 final String name;
4 final String phone;
5
6 PhoneContact(this.name, this.phone);
7
8 @override
9 String toString() => "$name ($phone)";
10}
11
12// Базовый класс для работы с контактами
13abstract class PhoneContactManager {
14 void addPhoneContact(PhoneContact contact);
15
16 void removeContactByPhone(String phone);
17
18 List<PhoneContact> getPhoneContacts();
19}
20
21// Правильная реализация базового функционала
22class BasicPhoneContactManager implements PhoneContactManager {
23 final List<PhoneContact> _contacts = [];
24
25 @override
26 void addPhoneContact(PhoneContact contact) => _contacts.add(contact);
27
28 @override
29 void removeContactByPhone(String phone) => _contacts.removeWhere((c) => c.phone == phone);
30
31 @override
32 List<PhoneContact> getPhoneContacts() => _contacts;
33}
34
35// Расширенная реализация с архивом (корректно следует LSP)
36class AdvancedPhoneContactManager implements PhoneContactManager {
37 final List<PhoneContact> _contacts = [];
38 final List<PhoneContact> _archive = [];
39
40 @override
41 void addPhoneContact(PhoneContact contact) => _contacts.add(contact);
42
43 @override
44 void removeContactByPhone(String phone) {
45 final contact = _contacts.firstWhere(
46 (c) => c.phone == phone,
47 orElse: () => throw Exception("Contact not found")
48 );
49 _contacts.remove(contact);
50 _archive.add(contact); // Архивирование при удалении
51 }
52
53 @override
54 List<PhoneContact> getPhoneContacts() => _contacts;
55
56 // Можем достать список контактов в архиве — дополнительный метод, не нарушающий LSP
57 List<PhoneContact> getArchivedContacts() => _archive;
58}
59
60// Функция-клиент, работающая с базовым классом
61void showPhoneContacts(PhoneContactManager manager) => printPhoneContacts(manager.getPhoneContacts());
62
63void printPhoneContacts(List<PhoneContact> contacts) => contacts.forEach(print);
64
65void main() {
66 // Использование базовой реализации
67 final basicManager = BasicPhoneContactManager();
68 basicManager
69 ..addPhoneContact(PhoneContact("Мария", "+7 920 423-45-76"))
70 ..addPhoneContact(PhoneContact("Дмитрий", "+7 919 834-23-67"))
71 ..addPhoneContact(PhoneContact("Иван", "+7 999 124-88-11"));
72
73 basicManager.removeContactByPhone("+7 999 124-88-11");
74 showPhoneContacts(basicManager);
75
76 // Использование расширенной реализации
77 final advancedManager = AdvancedPhoneContactManager();
78 advancedManager
79 ..addPhoneContact(PhoneContact("Мария", "+7 920 423-45-76"))
80 ..addPhoneContact(PhoneContact("Дмитрий", "+7 919 834-23-67"));
81
82 // Подстановка наследника вместо базового класса
83 showPhoneContacts(advancedManager); // Работает корректно
84
85 // Вызов базовых методов класса и вызов методов наследника
86 basicManager.removeContactByPhone("+7 999 124-88-11"); // Работает корректно
87 printPhoneContacts(advancedManager.getArchivedContacts()); // Работает корректно
88}
Как это работает:
-
Корректная подстановка.
AdvancedContactManagerможет быть использован везде, где ожидаетсяPhoneContactManager, например в функцииshowPhoneContacts. -
Соблюдён контракт. Новых условий не добавлено (например,
addPhoneContactможет принимать любой PhoneContact), старые условия не изменены (послеremoveContactByPhoneконтакт удаляется из основного списка). -
Дополнительная функциональность. Новый метод
getArchivedContactsдобавлен в классAdvancedPhoneContactManagerбез нарушения работы базового функционала.
Нарушения LSP (как не надо делать):
1// Реализацию PhoneContactManager, PhoneContact,
2// BasicPhoneContactManager берём из предыдущего примера
3
4// Подкласс с нарушением
5class LimitedPhoneContactManager implements PhoneContactManager {
6 final List<PhoneContact> _contacts = [];
7
8 // Нарушение LSP: добавлено новое условие
9 final int _maxContacts;
10
11 LimitedPhoneContactManager(this._maxContacts);
12
13 @override
14 void addPhoneContact(PhoneContact contact) {
15 // Нарушение LSP: добавлено новое условие
16 if (_contacts.length >= _maxContacts) {
17 throw Exception("Превышен лимит контактов!");
18 }
19 _contacts.add(contact);
20 }
21
22 @override
23 void removeContactByPhone(String phone) => _contacts.removeWhere((c) => c.phone == phone);
24
25 @override
26 List<PhoneContact> getPhoneContacts() => _contacts;
27}
28
29void main() {
30 // Работает корректно с базовой реализацией
31 // Реализацию берём из предыдущего примера
32 final basicManager = BasicPhoneContactManager();
33 basicManager
34 ..addPhoneContact(PhoneContact("Мария", "+7 920 423-45-76"))
35 ..addPhoneContact(PhoneContact("Дмитрий", "+7 919 834-23-67"))
36 ..addPhoneContact(PhoneContact("Анастасия", "+7 910 114-44-48"));
37
38
39 // Нарушение LSP при использовании наследника
40 final limitedManager = LimitedPhoneContactManager(2);
41 limitedManager
42 ..addPhoneContact(PhoneContact("Мария", "+7 920 423-45-76"))
43 ..addPhoneContact(PhoneContact("Дмитрий", "+7 919 834-23-67"))
44 ..addPhoneContact(PhoneContact("Анастасия", "+7 910 114-44-48")); // Выбросит исключение на третьем контакте
45}
Чем нарушен LSP:
-
Усиление предусловий. Подкласс
LimitedPhoneContactManagerдобавляет в методaddPhoneContactновое условие, которое отсутствует в базовом классе.Mainне ожидает исключения при вызовеaddPhoneContact. -
Несоответствие контракту. Базовый класс не декларирует ограничений на количество контактов. Подкласс
LimitedPhoneContactManagerвводит скрытое ограничение, становясь некорректной заменой базового класса. -
Нарушение базовой логики. Метод
addPhoneContactработает корректно сBasicPhoneContactManager, но ломается при использованииLimitedPhoneContactManager, хотя формально следует контрактуPhoneContactManager.
Правильная реализация LSP гарантирует, что расширения системы будут безопасными и предсказуемыми.
Interface Segregation Principle (ISP)
Или принцип разделения интерфейса. Этот принцип представляет собой набор рекомендаций, выполнение которых позволяет уменьшить количество методов в интерфейсе и упростить его реализацию.
Рекомендации:
-
Не создавайте зависимостей от чего-либо неиспользуемого.
-
Не вынуждайте пользователей компонента зависеть от того, что им не требуется.
-
Не пользуйтесь сами компонентом, который заставляет вас зависеть от того, что вам не требуется.
Если есть класс A с большим количеством методов и данных и класс B, который пользуется какой-то частью этих данных, то лучше не передавать классу B ссылку на объект класса A, а выделить отдельный интерфейс, который будет содержать только нужные методы/данные.
В контексте чистой архитектуры этот принцип применяется к компонентам приложения, которые реализуют интерфейсы или абстрактные классы. Эти компоненты должны быть спроектированы таким образом, чтобы их интерфейсы были максимально простыми и содержали только методы, необходимые для выполнения конкретной задачи. Это позволяет избежать избыточности и усложнения кода.
1// Класс контакта
2class PhoneContact {
3 final String name;
4 final String phone;
5
6 PhoneContact(this.name, this.phone);
7}
8
9// Интерфейс для хранения контактов
10abstract class PhoneContactStorage {
11 void addPhoneContact(PhoneContact phoneContact);
12
13 void removePhoneContact(PhoneContact phoneContact);
14
15 List<PhoneContact> getAllContacts();
16}
17
18// Интерфейс для расширенного поиска
19abstract class PhoneContactSearch {
20 List<PhoneContact> searchByName(String query);
21
22 List<PhoneContact> searchByPhone(String phone);
23}
24
25// Интерфейс для синхронизации с облаком
26abstract class CloudSync {
27 void uploadToCloud();
28
29 void downloadFromCloud();
30}
31
32// Реализация локальной записной книжки (без синхронизации), но с расширенным поиском
33class LocalPhoneContactBook implements PhoneContactStorage, PhoneContactSearch {
34 final List<PhoneContact> _phoneContacts = [];
35
36 @override
37 void addPhoneContact(PhoneContact phoneContact) => _phoneContacts.add(phoneContact);
38
39 @override
40 void removePhoneContact(PhoneContact phoneContact) => _phoneContacts.remove(phoneContact);
41
42 @override
43 List<PhoneContact> getAllContacts() => List.unmodifiable(_phoneContacts);
44
45 @override
46 List<PhoneContact> searchByName(String query) {
47 // Тут будет код, отвечающий за поиск контакта по имени
48 }
49
50 @override
51 List<PhoneContact> searchByPhone(String phoneNumber) {
52 // Тут будет код, отвечающий за поиск контакта по телефону
53 }
54}
55
56// Реализация облачной записной книжки (с синхронизацией), без поиска
57class CloudPhoneContactBook implements PhoneContactStorage, PhoneCloudSync {
58 final List<PhoneContact> _phoneContacts = [];
59
60 @override
61 void addPhoneContact(PhoneContact phoneContact) => _phoneContacts.add(phoneContact);
62
63 @override
64 void removePhoneContact(PhoneContact phoneContact) => _phoneContacts.remove(phoneContact);
65
66 @override
67 List<PhoneContact> getAllContacts() => List.unmodifiable(_phoneContacts);
68
69 @override
70 void uploadToCloud() {
71 // Тут будет код, отвечающий за выгрузку контактов в облако
72 }
73
74 @override
75 void downloadFromCloud() {
76 // Тут будет код, отвечающий за скачивание контактов из облака
77 }
78
79void main() {
80 final localPhoneBook = LocalPhoneContactBook();
81
82 // Добавляем контакты
83 localPhoneBook
84 ..addPhoneContact(PhoneContact("Павел", "+7 999 143-45-88"))
85 ..addPhoneContact(PhoneContact("Мария", "+7 910 123-45-67"));
86
87 localPhoneBook.searchByName("Мари").forEach(print); // Найдена Мария
88
89 // Облачная книга реализует только свои интерфейсы
90 final cloudPhoneBook = CloudPhoneContactBook();
91
92 cloudPhoneBook.addPhoneContact(PhoneContact("Дмитрий", "+78005553535"));
93
94 cloudPhoneBook.uploadToCloud();
95}
Про разделение интерфейсов:
-
Класс
PhoneContactStorageпредназначен только для добавления/удаления/получения данных. -
Класс
PhoneContactSearchреализует только поисковые методы. -
Класс
CloudSyncотвечает только за синхронизацию с облаком.
В итоге каждый класс спроектирован максимально простым, он содержит только необходимые методы.
Класс PhoneLocalContactBook не содержит методов синхронизации, но содержит поиск. А CloudPhoneContactBook не реализует методы поиска, но умеет синхронизировать контакты. Оба класса не содержат «пустых» методов (например, CloudPhoneContactBook не должен реализовывать searchByPhone), а реализуют только необходимые методы. Новые функции добавляются через новые интерфейсы, не ломая старый код.
Нарушение ISP (как не надо делать):
1// Плохой интерфейс-монолит
2abstract class MonolithContactBook {
3 void addPhoneContact(PhoneContact contact);
4
5 void searchByName(String query);
6
7 void uploadToCloud();
8}
Если вам нужен только метод addPhoneContact и вы решаете наследовать MonolithContactBook, то придётся реализовывать заглушки для неиспользуемых методов searchByName, uploadToCloud. Такой подход нарушает ISP.
Dependency Inversion Principle (DIP)
Или принцип инверсии зависимостей. Его суть заключается в том что редко меняющиеся сущности не должны зависеть от часто меняющихся сущностей, а часто меняющиеся сущности должны зависеть от редко меняющихся.
Реализация этого принципа достигается через создание высокоуровневых абстракций. Принцип меняет направление зависимостей: высокоуровневые сущности (доменный слой) перестают зависеть от низкоуровневых (технические детали, например база данных), а оба слоя начинают зависеть от абстракций (интерфейсов).
Рассмотрим на примере взаимодействия двух классов. Класс А — редко меняющийся, а класс Б — часто меняющийся. Класс А нуждается в данных класса Б, а класс Б не нуждается в классе А. Давайте рассмотрим на практике, как организовать взаимодействие классов А и Б и при этом соблюсти принцип инверсии зависимостей в Dart:
-
Класс А убирает зависимость от Б, предоставляя функции обратного вызова (коллбэки). Класс Б использует эти функции. Именно эти функции будут являться абстракцией.
-
Класс А создаёт
StreamControllerи отдаёт наружу объект, принимающий события (Sink). Класс Б передаёт данные черезSink, ничего не зная о классе А.
Давайте рассмотрим принцип инверсии зависимостей c другого ракурса на примере записной книжки контактов.
1// Класс контакта
2class PhoneContact {
3 final String name;
4 final String phone;
5
6 PhoneContact(this.name, this.phone);
7}
8
9// Интерфейс для работы с хранилищем контактов
10abstract class PhoneContactStorage {
11 List<PhoneContact> loadContacts();
12
13 void saveContacts(List<PhoneContact> contacts);
14}
15
16// Реализация хранилища в оперативной памяти
17class InMemoryContactStorage implements PhoneContactStorage {
18 @override
19 List<PhoneContact> loadContacts() {
20 // Тут будет код, отвечающий за выгрузку данных из оперативной памяти
21 }
22
23 @override
24 void saveContacts(List<PhoneContact> phoneContacts) {
25 // Тут будет код, отвечающий за сохранение данных в оперативной памяти
26 }
27}
28
29// Реализация хранилища в файле
30class FileContactStorage implements PhoneContactStorage {
31 @override
32 List<PhoneContact> loadPhoneContacts() {
33 // Тут будет код, отвечающий за выгрузку данных из файла
34 }
35
36 @override
37 void saveContacts(List<PhoneContact> phoneContact) {
38 // Тут будет код, отвечающий за сохранение данных в файл
39 }
40}
41
42// Сервис для работы с контактами
43class PhoneContactService {
44 final PhoneContactStorage _storage;
45
46 PhoneContactService(this._storage);
47
48 List<PhoneContact> get phoneContact => _storage.loadPhoneContacts();
49
50 void addPhoneContact(PhoneContact proneContact) {
51 final updatedList = phoneContact..add(phoneContact);
52 _storage.saveContacts(updatedList);
53 }
54
55 void removePhoneContact(PhoneContact proneContact) {
56 final updatedList = phoneContact..remove(phoneContact);
57 _storage.saveContacts(updatedList);
58 }
59}
60
61void main() {
62 // Создаём сервис для работы с хранилищем в оперативной памяти
63 final inMemoryContactStorage = InMemoryContactStorage();
64 final inMemoryContactService = PhoneContactService(inMemoryContactStorage);
65
66 // Создаём сервис для работы с файловым хранилищем
67 final fileContactStorage = FileContactStorage();
68 final fileContactService = PhoneContactService(fileContactStorage);
69
70
71 memoryService.addContact(PhoneContact("Мария", "+7 910 123-45-67"));
72 fileService.addContact(PhoneContact("Павел", "+7 999 143-45-88"));
73}
Абстрактный класс PhoneContactStorage служит для определения общего контракта для любого типа хранилищ через методы loadContacts() и saveContacts(). Класс InMemoryContactStorage работает с оперативной памятью. Класс FileContactStorage отвечает за работу с файловой системой (сохраняет список контактов в файл). Используя контакт PhoneContactStorage, мы можем добавить другие хранилища, при этом нам не придётся изменять остальной код.
PhoneContactService зависит только от абстракции PhoneContactStorage, в итоге мы не знаем деталей работы хранилища и не зависим от них.
Нарушение DIP (как не надо делать):
1class PhoneContactService {
2 final InMemoryContactStorage _storage = InMemoryContactStorage();
3
4 // ... остальные методы
5}
Мы зависим от конкретной реализации; в случае если придётся изменить тип используемого хранилища, придётся изменять класс PhoneContactService.
Реализация принципов S.O.L.I.D
Теперь поговорим о том, как принципы S.O.L.I.D реализованы в чистой архитектуре. Роберт Мартин выделяет следующие подходы:
-
Разделение на слои. Система разделяется на несколько слоёв: данные, домен, интерфейс пользователя. Каждый слой имеет свою ответственность и не зависит от других слоёв. Это помогает реализовать принцип единственной ответственности.
-
Инверсия зависимостей. Зависимости должны быть направлены от абстракций к конкретике, а не наоборот. Это позволяет легко менять реализации и упрощает тестирование.
-
Использование интерфейсов (инкапсуляция). Вместо того чтобы напрямую взаимодействовать с конкретными классами, используйте интерфейсы. Это защищает внутренние компоненты от внешних изменений, что способствует соблюдению принципа открытости/закрытости, а также даёт возможность заменять реализации без изменения кода.
-
Декомпозиция. Как сказал Роберт Мартин: «Собирайте вместе всё, что изменяется по одной причине и в одно время. Разделяйте всё, что изменяется в разное время и по разным причинам». Другими словами, разбивать большие системы на более мелкие компоненты необходимо с умом. Помимо разделения, нужно ещё грамотно компоновать элементы системы. Такой подход упрощает разработку, тестирование и поддержку системы.
Более подробно с деталями S.O.L.I.D можете ознакомиться в наших уроках Школы Мобильной Разработки:
-
Flutter — Flutter App Architecture Overview. State Management (Yandex for Developers)
-
Flutter App Architecture Overview. State Management (Young&&Yandex: мобильная разработка)
-
Architecture. Часть 1: App Architecture Overview — ШМР Flutter 2024
-
Architecture. Часть 2: Flutter, State Management, существующие решения — ШМР Flutter 2024
После того как вы познакомились с чистой архитектурой, детально изучили все принципы S.O.L.I.D, давайте поговорим про подводные камни.
Подводные камни
-
Всё не так просто, как кажется. На первый взгляд, чистая архитектура очень проста и понятна, но это обманчивое чувство. Чтобы выстроить действительно эффективную систему, потребуется тщательно изучить всё, что описано в книге Роберта Мартина.
-
Не списывайте точь-в-точь. Крайне не рекомендуется полностью переносить реализацию чистой архитектуры из одного проекта в другой, так как не существует абсолютно одинаковых проектов с точки зрения бизнес-требований и сути самих проектов. Каждая чистая архитектура по-своему уникальна, со своими нюансами и тонкостями.
-
Высокая цена ошибки. Если при проектировании допущены критические ошибки, то исправить их нужно как можно раньше. Чем больше кода будет написано по ошибочным контрактам, тем дороже будет исправлять эти ошибки.
При грамотном подходе к проектированию и построению архитектуры вы сможете избежать подводных камней. Но для этого необходимо глубоко погрузиться в идеологию Роберта Мартина и понять её тонкости, а также тщательно изучить все нюансы будущего проекта. Обобщив эти знания, вы сможете построить по-настоящему чистую архитектуру.
Итак, чистая архитектура — это не киллер-фича, которая 100% приведёт вас или вашу команду к успеху, это всего лишь инструмент, один из многих. У него есть свои плюсы и свои минусы. И только вам решать, следует ли руководствоваться его догмами.
Но хочется подчеркнуть, что разумное и рациональное использование чистой архитектуры при проектировании и повседневной разработке поможет создать надёжные и эффективные программные продукты, которые будут успешно развиваться и адаптироваться к изменяющимся требованиям рынка.
В этом параграфе вы познакомились с основами чистой архитектуры, узнали о принципах её построения. Также изучили принципы S.O.L.I.D, разобрав, что кроется за каждой из букв аббревиатуры. Эти знания очень пригодятся для изучения и более глубокого понимания дальнейших параграфов.
А в следующем параграфе мы изучим и сравним разные структуры построения проекта — «сначала слои» и «сначала фичи»: детально разберём каждую, узнаем их плюсы, минусы и определим случаи, когда их стоит применять.