Делаем архитектуру вашего Android‑приложения SOLID'нее... Часть 1 можно прочитать по ссылке.

Современные KotlinAndroid) проекты часто следуют принципам «Чистой» архитектуры (Clean Architecture), чтобы сделать код более структурированным и удобными для тестирования. Суть Чистой архитектуры заключается в следующем:

Что мы рассмотрим в этой статье

В предыдущей части:

  • мы начали с создания одного простого юзкейса, внедрили в него Koin и продемонстрировали это во ViewModel.

  • обсудили, как сгруппировать несколько юзкейсов в Manager, чтобы избежать загромождения наших ViewModels.

  • Мы рассмотрели пример работы с несколькими реализациями одного интерфейса (в разделе 7) — например, с разными платежными сервисами — чтобы продемонстрировать, как принцип подстановки Лисков позволяет легко менять или расширять эти реализации, без необходимости переписывать большие фрагменты кода.

В этой части:

  • Мы обсудим многомодульный подход к реализации нашего примера с оплатой.

  • Наконец, в заключительном разделе мы поговорим о том, как поддерживать сбалансированную Чистую архитектуру, избегая оверинжиниринга.

8. Переход от одномодульной архитектуры к многомодульной

Основная цель перехода от одномодульной к многомодульной структуре заключается в отделении основной логики от специфики реализаций для конкретных провайдеров. В рамках новой структуры мы создадим новый модуль «payment‑core», в который будут вынесены общие интерфейсы и классы данных, и пару дополнительных модулей для логики, связанной с особенностями разных провайдеров.

Ниже представлен пример того, как код можно разбить на несколько модулей. Мы начнем с размещения обычных платежных контрактов в модуле payment‑core, а затем создадим отдельные модули для Stripe и PayPal с именами payment‑stripe и payment‑paypal соответственно.

1. Модуль “payment-core”

Этот модуль содержит все наши ключевые интерфейсы, базовые модели данных и логику PaymentManager. Он станет основой, от которой будет зависеть каждый модуль провайдера.

// Определение общих интерфейсов и моделей
interface ChargePaymentUseCase {
   suspend operator fun invoke(amount: Double, currency: String): PaymentResult
}


interface VerifyPaymentUseCase {
   suspend operator fun invoke(paymentId: String): Boolean
}


interface RefundPaymentUseCase {
   suspend operator fun invoke(paymentId: String, amount: Double): PaymentResult
}


data class PaymentResult(
   val success: Boolean,
   val transactionId: String? = null,
   val errorMessage: String? = null
)


// Контейнер для группировки юзкейсов, связанных с конкретными провайдерами
data class PaymentProviderUseCases(
   val charge: ChargePaymentUseCase,
   val verify: VerifyPaymentUseCase?,
   val refund: RefundPaymentUseCase?
)


// PaymentManager оперирует юзкейсами соответсвующего провайдера
interface PaymentManager {
   suspend fun charge(providerName: String, amount: Double, currency: String): PaymentResult
   suspend fun verify(providerName: String, paymentId: String): Boolean
   suspend fun refund(providerName: String, paymentId: String, amount: Double): PaymentResult
}


class PaymentManagerImpl(
   private val providers: Map<String, PaymentProviderUseCases>
) : PaymentManager {


   override suspend fun charge(providerName: String, amount: Double, currency: String): PaymentResult {
       val useCases = providers[providerName.lowercase()]
           ?: return PaymentResult(false, errorMessage = "Unknown provider: $providerName")
       return useCases.charge(amount, currency)
   }


   override suspend fun verify(providerName: String, paymentId: String): Boolean {
       val useCases = providers[providerName.lowercase()] ?: return false
       val verifyUC = useCases.verify ?: return false
       return verifyUC(paymentId)
   }


   override suspend fun refund(providerName: String, paymentId: String, amount: Double): PaymentResult {
       val useCases = providers[providerName.lowercase()]
           ?: return PaymentResult(false, errorMessage = "Unknown provider: $providerName")
       val refundUC = useCases.refund
           ?: return PaymentResult(false, errorMessage = "Refund not supported for $providerName")
       return refundUC(paymentId, amount)
   }
}

В модуле payment‑core мы определяем наши основные интерфейсы (например, ChargePaymentUseCase), класс‑контейнер (PaymentProviderUseCases) и PaymentManager (а также его реализацию по умолчанию). Здесь нет никакой логики связанной с конкретными поставщиками.

2. Модуль провайдера: “payment-stripe”

Этот модуль реализует интерфейсы из модуля payment‑core специально для Stripe. Обратите внимание, что он ссылается на классы ChargePaymentUseCase, VerifyPaymentUseCase и RefundPaymentUseCase из com.example.paymentcore.

// Реализация для Stripe
class StripeChargePaymentUseCase : ChargePaymentUseCase {
   override suspend fun invoke(amount: Double, currency: String): PaymentResult {
       // Гипотетический вызов Stripe API
       return PaymentResult(success = true, transactionId = "stripe_tx_12345")
   }
}


class StripeVerifyPaymentUseCase : VerifyPaymentUseCase {
   override suspend fun invoke(paymentId: String): Boolean = true
}


class StripeRefundPaymentUseCase : RefundPaymentUseCase {
   override suspend fun invoke(paymentId: String, amount: Double): PaymentResult {
       // Гипотетический запрос возврата средств
       return PaymentResult(success = true, transactionId = "stripe_ref_67890")
   }
}


// Koin-модуль для Stripe
val stripeModule = module {
   single<PaymentProviderUseCases>(named("stripe")) {
       PaymentProviderUseCases(
           charge = StripeChargePaymentUseCase(),
           verify = StripeVerifyPaymentUseCase(),
           refund = StripeRefundPaymentUseCase()
       )
   }
}

Модуль payment‑stripe отвечает только за работу со Stripe. Он реализует интерфейсы из основного модуля и предоставляет их через определение Koin‑модуля (stripeModule). Это позволяет другим частям вашего приложения легко задействовать функционал Stripe, просто включая библиотеку «payment‑stripe» и ее Koin‑определения.

3. Еще один модуль провайдера: “payment-paypal”

Аналогично, модуль payment‑paypal содержит все необходимое для работы с PayPal. Он также базируется на payment‑core, но не зависит от payment‑stripe, что позволяет обновлять или удалять модуль Stripe без необходимости изменять код для PayPal.

// Реализация для PayPal 
class PayPalChargePaymentUseCase : ChargePaymentUseCase {
   override suspend fun invoke(amount: Double, currency: String): PaymentResult {
       // Гипотетический вызов API PayPal
       return PaymentResult(success = true, transactionId = "paypal_tx_ABC")
   }
}


class PayPalVerifyPaymentUseCase : VerifyPaymentUseCase {
   override suspend fun invoke(paymentId: String): Boolean = false
}


class PayPalRefundPaymentUseCase : RefundPaymentUseCase {
   override suspend fun invoke(paymentId: String, amount: Double): PaymentResult {
       // Гипотетический запрос возврата средств
       return PaymentResult(success = false, errorMessage = "Refund failed")
   }
}


// Koin-модуль для PayPal
val paypalModule = module {
   single<PaymentProviderUseCases>(named("paypal")) {
       PaymentProviderUseCases(
           charge = PayPalChargePaymentUseCase(),
           verify = PayPalVerifyPaymentUseCase(),
           refund = PayPalRefundPaymentUseCase()
       )
   }
}

4. Объединение модулей в основном приложении

В нашем основном приложении (или в другом специально выделенном модуле) мы объединяем базовый модуль и те модули провайдеров, которые нам нужны. Мы создаете ассоциативный массив провайдеров, а затем передаем его в класс PaymentManagerImpl.

// Koin-модуль основного приложения, объединяющий модули провайдеров
val paymentAggregatorModule = module {
   // Строим map по названиям провайдеров и их PaymentProviderUseCases
   single<Map<String, PaymentProviderUseCases>> {
       mapOf(
           "stripe" to get<PaymentProviderUseCases>(named("stripe")),
           "paypal" to get<PaymentProviderUseCases>(named("paypal"))
       )
   }
   // Предоставляем PaymentManager из базового модуля
   single<PaymentManager> { PaymentManagerImpl(get()) }
}

Наш PaymentViewModel или любой другой класс, который зависит от PaymentManager, остается неизменным. Ему не нужно заботиться или даже знать о том, какие модули провайдера загружены. Благодаря нашему подход с map в менеджере вся маршрутизация выполняется за кулисами.

Обзор зависимостей модулей:

  • Модуль payment‑core — это наш базовый модуль, который содержит все интерфейсы и контракты. Он не зависит от других платежных модулей.

  • Модули провайдеров (payment‑stripe, payment‑paypal) — каждый из них зависит только от payment‑core, и не пересекается с другими модулями провайдеров. Это обеспечивает изоляцию реализаций провайдеров, позволяя добавлять или удалять их не затрагивая других провайдеров.

  • Модуль вашего приложения или специальный модуль для обработки платежей будет зависеть от модуля payment‑core и модулей провайдеров, которые вы собираетесь использовать. Эта чистая структура зависимостей позволяет легко добавлять или исключать платежных провайдеров, просто включая или исключая соответствующие модули. При этом не требуется вносить изменения в логику работы других провайдеров или в основную логику обработки платежей.

Как это помогает крупным проектам

Разделение логики оплаты на отдельные модули позволяет каждой команде или разработчику сосредоточиться в работе на своей части, не затрагивая остальную кодовую базу. Например, вы можете обновить Stripe SDK в модуле payment‑stripe или добавить новые функции для PayPal в модуль payment‑paypal, не создавая конфликтов в коде.

Обычно это также уменьшает время сборки, так как вы перестраиваете только измененный модуль, а не весь проект. Благодаря такому гибкому подходу, вы можете добавлять, удалять или обновлять провайдеров как вам угодно с минимальными сбоями в работе, сохраняя при этом чистую архитектуру и принципы SOLID, о которых мы говорили в предыдущем части.

9. Почему мы бы нам не объединить весь код, связанный с провайдерами, в отдельном модуле?

Естественная эволюция

На этом этапе вы можете задаться вопросом: «Зачем нам вообще разделять нашу платежную систему на отдельные модули? Разве вся платежная система с парой провайдеров не может уместиться в одном модуле в нашем многомодульном приложении?»

Это правильный вопрос

Конкретно в нашем примере вы можете спросить: «Зачем создавать отдельный модуль для каждого платежного провайдера? Разве мы не могли бы просто создать один модуль, например, payment-providers, и добавить в него всех провайдеров?»

Это вполне обоснованный вопрос, поскольку наша мультипровайдера система, как описано выше, уже обеспечивает эффективное разделение обязанностей.

Каждый провайдер реализует свои собственные юзкейсы, а PaymentManager координирует их работу.

В принципе, мы могли бы объединить всю эту конфигурацию в одном модуле, удовлетворив при этом все требования Чистой архитектуры.

Однако, размещение всех провайдеров в одном модуле может стать обременительным для более крупных проектов или команд, которые часто меняют провайдеров.

Выделяя для каждого провайдера его собственный отдельный модуль (например, payment-stripe, payment-paypal и так далее), вы предоставляете каждой реализации полную независимость. Это означает, что ваша команда может обновлять SDK Stripe, не опасаясь конфликтов с кодом PayPal, и наоборот.

Если вам больше не нужен какой‑либо провайдер, вы можете без труда удалить его модуль, не затрагивая остальные. Это, в свою очередь, способствует ускорению сборки, поскольку для изменения одного модуля требуется лишь его перестройка, а не полная перепроверка всего пакета со всеми провайдерами.

Кроме того, управление зависимостями становится проще, так как каждый модуль использует свои версии библиотек. Это предотвращает возможные конфликты версий между провайдерами, которым могут потребоваться конфликтующие библиотеки.

Это также делает ваше приложение более гибким: если для определенного типа продукта или конкретной версии выпуска вам нужен только Stripe, вы можете включить только модуль этого провайдера и пропустить все остальные.

Таким образом, разделение провайдеров на отдельные модули не является обязательным, но оно часто приносит ощутимую пользу, особенно когда ваш проект растет или ваш бизнес активно внедряет различные способы оплаты.

10. Баланс в реальной жизни

Все рассмотренные до сих пор методы позволяют надежно отделить бизнес‑логику от функционала, связанного с пользовательским интерфейсом, и разделить детали слоя данных. Однако важно сбалансировать преимущества дополнительных слоев с затратами на их создание и обслуживание.

Если вы добавите слишком много абстракций слишком рано, вы рискуете усложнить процесс разработки и создать ненужную путаницу. Если же вы полностью избегаете их, вы можете существенно ограничить свои возможности тестирования, повторного использования и параллельной разработки.

Определение золотой середины зависит от таких факторов, как размер команды, масштаб проекта и сложность предметной области.

10.1 Когда использование менеджера обосновано

Использование менеджера (или фасада) может значительно упростить ваш код, особенно если у вас есть несколько связанных юзкейсов в одном домене или если вам нужно организовать несколько действий, таких как выборка, преобразование или логирование.

Однако, для единичных или тривиальных операций использование менеджера может быть излишним. Всегда тщательно оценивайте, действительно ли он приносит пользу, снижая дублирование или сложность.

10.2 Когда меньше значит больше

На ранних стадиях разработки приложения, или если вам требуется лишь быстрая выборка данных или небольшое бизнес‑правило, затрата усилий на выделение отдельного слоя для юзкейсов может не оправдать потенциальной пользы.

Начиная с простых решений, вы избегаете планирования для неопределенного будущего.

Когда вы замечаете, что код вашей ViewModel или репозитория становится слишком длинным или повторяющимся, это может быть признаком того, что пришло время ввести новый юзкейс или уровень абстракции. Такой поэтапный подход позволяет убедиться, что каждая абстракция вводится для решения реальной проблемы, а не просто для гипотетического удобства.

10.3 Когда требуется нечто большее

Если логика вашего домена становится более сложной и включает в себя специальные операции сортировки, фильтрации или преобразования, вы можете разместить этот код в юзкейсе. Это поможет сохранить вашу ViewModel чистой и тестируемой.

Аналогично, если у вас есть несколько взаимодействующих юзкейсов, создание менеджера (или фасада) может упростить ваш слой представления за счет продуманного управления сложными потоками.

Однако убедитесь, что менеджер действительно уменьшает количество проблем. Если он просто пересылает запросы без добавления какой‑либо логики, это может быть излишеством.

  • Именование и ясность: Независимо от того, создаете ли вы юзкейс или менеджер, старайтесь использовать описательные имена. Вместо MemesUseCase можно рассмотреть такие варианты, как SortMemesByDate или FilterMemesByCategory. Простые имена классов, такие как MemeSorter, также будут уместны, если они четко отражают выполняемую функцию.

  • Параллельная разработка: Для больших или быстрорастущих команд разделение кода на юзкейсы или модули помогает каждому разработчику работать над своей частью кода (например, PayPal или Stripe) отдельно, не блокируя другие. Такая структура позволяет минимизировать конфликты слияния и четко распределить ответственность за различные функции.

10.4 Баланс и итерация

Чистая архитектура лучше всего работает как итеративная практика, а не как строгий архитектурный паттерн. Вы можете начать с ViewModelRepository, а затем добавлять юзкейсы или модули по мере необходимости, чтобы справляться с требованиями по мере роста приложения:

  1. Прототип: не усложняйте, если у вас минимум логики.

  2. Определите проблемные области: выделите юзкейс, когда код становится сложным для обслуживания или тестирования.

  3. Добавьте менеджеры: объедините связанные юзкейсы, если это поможет упроститить слой ViewModel.

  4. Рассмотрите возможность разделения на модули: Если ваша команда или кодовая база растет, разделение доменов на отдельные модули может сократить время сборки и обеспечить изоляцию изменений.

Такой подход позволяет вам легко проводить рефакторинг, добавляя или удаляя абстракции по мере развития проекта. Чистая архитектура не должна быть статичной схемой — адаптируйте слои в соответствии с текущими реальными требованиями вашего приложения.

10.5 В заключение о балансе

В целом, Чистая архитектура предлагает ценные инструменты для разделения ответственности, упрощения тестирования и поддержки изменений с течением времени. Однако это не означает, что нужно создавать новый класс или модуль для каждой мелочи.

Постепенно развивая свою архитектуру, добавляя слои и модули только тогда, когда они действительно необходимы для решения реальных задач, вы сможете избежать «ада абстракции» и сохранить свою кодовую базу гибкой и легко читаемой.

11. Заключение

В этой статье мы рассмотрели практические примеры Чистой архитектуры и юзкейсов,

  1. начиная с простой задачи выборки мемов и заканчивая сложными сценариями оплаты с несколькими платежными системами.

  2. Мы показали, как можно структурировать код таким образом, чтобы бизнес‑логика была отделена от фреймворков пользовательского интерфейса, что значительно упрощает тестирование, обслуживание и расширение системы.

  3. Также мы обсудили, как менеджеры (или фасады) могут упростить взаимодействие с различными юзкейсами,

  4. и как разделение кода на модули позволяет изолировать провайдеров или функции. И, наконец, мы подчеркнули важность нахождения правильного баланса в применении этого подхода.

Хотя поддерживать код организованным и тестируемым — это хорошее начинание, слишком быстрое добавление новых слоев или модулей может привести к ненужному усложнению.

Постепенное развитие архитектуры, когда новые элементы добавляются только для решения реальных проблем, помогает поддерживать гибкость и удобство системы.

12. Основные выводы

  • Юзкейсы отделяют вашу бизнес‑логику от пользовательского интерфейса, что способствует повышению тестируемости и наглядности кода.

  • Классы‑менеджеры (или фасады) могут упростить взаимодействие вашего пользовательского интерфейса с несколькими юзкейсами. Определяя интерфейс менеджера, вы гарантируете, что ваш высокоуровневый код зависит от абстракций (принцип инверсии зависимостей), а не от конкретной реализации. Используйте их с умом, когда вам действительно требуется оркестровка или когда у вас много действий в домене, а не в качестве стандартного подхода.

  • Принцип замены Лисков гарантирует, что при тщательном проектировании интерфейса (или набора юзкейсов) можно заменить любого провайдера, имеете ли вы дело со Stripe, PayPal или любым другим взаимозаменяемым набором сервисов.

  • Принцип разделения интерфейсов поддерживается, потому что ни один провайдер не обязан внедрять методы, которые ему не нужны. Каждый провайдер может выбирать соответствующие действия (взимание средств, проверка, возврат средств).

  • Принцип инверсии зависимостей поддерживается на каждом слое (юзкейсы, репозитории, менеджеры и ViewModels), что упрощает тестирование, мокинг и расширение.

  • Модульный подход: многомодульные конфигурации подходят для больших команд или сложных областей, но помните о накладных расходах. Слишком раннее разделение может замедлить развитие небольших проектов.

  • Баланс и постепенное совершенствование: Юзкейсы, менеджеры и модули следует добавлять по мере необходимости, а не на всякий случай. Ваша архитектура должна расти вместе с вашим проектом, оставаясь при этом понятной и легко поддерживаемой.

13. Следующие шаги

  1. Инкрементная абстракция: Проанализируйте свою кодовую базу на предмет областей, где повторяющаяся логика или сложные функции могли бы извлечь пользу от добавления юзкейса или менеджера. Добавляйте слои постепенно, следя за тем, чтобы каждое добавление действительно улучшало ваш проект.

  2. Рефакторинг и тестирование: Пересмотрите существующие функции и подумайте, могут ли юзкейсы или фасады сделать их более тестируемыми и удобочитаемыми. Не забывайте синхронизировать свои тесты с каждым новым слоем, который вы внедряете.

  3. Оцените разделение модулей: Подумайте, являются ли отдельные части вашей системы, такие как «оплата», «заказы», «профиль», достаточно большими и независимыми, чтобы их можно было выделить в отдельные модули. Если несколько разработчиков «наступают друг другу на код», модульный подход может оказаться весьма полезным.

  4. Обработка ошибок: Используйте sealed классы или тип Result для более надежного управления ошибками, особенно в критически важных потоках платежей или других важных частях системы.

  5. Дальнейшие принципы: Изучите разделение интерфейсов более глубоко, если у вас есть провайдеры с неполными наборами функций. Также изучите такие шаблоны, как Стратегия или Посредник, если ваши классы менеджеров начнут разрастаться.

  6. Рост команды и проекта: Постоянно пересматривайте свою архитектуру по мере расширения команды и развития логики в вашей предметной области. Иногда вам может потребоваться более четкое разделение и большее количество модулей, в то время как в других ситуациях простой подход может быть оптимальным решением.

Итерируя эти шаги, вы сможете поддерживая свой код в порядке и сделать его удобным для тестирования, избегая при этом искушения преждевременной абстракции.


Если вы интересуетесь Android‑разработкой и хотите глубже разобраться в практических аспектах создания мобильных приложений, приглашаем вас на серию открытых уроков, где будут разобраны реальные технические кейсы и подходы к архитектуре:

9 июля в 20:00 — Приложение «Фото дня»: разберём структуру, подход к построению UI и взаимодействие с API.
 — 15 июля в 20:00 — Инструменты Android для кроссплатформенной разработки: поговорим о возможностях и ограничениях, подходах к организации общего кода.
 — 23 июля в 20:00 — Сеть и базы данных в кросс‑платформенных приложениях на Kotlin: сосредоточимся на работе с данными, синхронизацией и хранением информации.

Также вы можете подробнее изучить тему в рамках курса «Специализация Android Developer». Он построен на примерах из реальной практики и позволяет сформировать устойчивую архитектуру приложений с соблюдением принципов SOLID и Clean Architecture.

Присоединяйтесь к занятиям — регистрация открыта.

Комментарии (0)