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

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

Что входит в зону ответственности UseCase?

UseCase (сценарий/вариант использования, юзкейс) отвечает за инкапсуляцию бизнес-логики для одной многократно используемой задачи, которую должна выполнять система.

Разобьем это определение на части и рассмотрим детально каждую из них.

1. Бизнес-логика фокусируется на том, что нужно достичь команде разработчиков, когда они описывают задачу. Показателем того, что юзкейс следует бизнес-логике, будет возможность использовать его на другой платформе, например iOS.

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

  • начать транзакцию;
  • отправить платеж;
  • завершить транзакцию;
  • при необходимости обработать все ошибки.
class SendPayment(private val repo: PaymentRepo) {

    suspend operator fun invoke(
        amount: Double,
        checkId: String,
    ): Boolean {
        val transactionId = repo.startTransaction(params.checkId)
        repo.sendPayment(
            amount = params.amount,
            checkId = params.checkId,
            transactionId = transactionId
        )
        return repo.finalizeTransaction(transactionId)
    }
}

2. В юзкейсе должна заключаться только одна задача — в основном одна публичная функция.

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

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

// Не используйте обобщенные юзкейсы для разных задач.
// Функциональность трудно обнаружить, если разработчик 
// не читал юзкейс, что очень сложно в условиях большой кодовой базы.
class GalleryUseCase @Inject constructor(
    /*...*/
) {

    fun saveImage(file: File)

    fun downloadFileWithSave(/*...*/)
    
    fun downloadImage(/*...*/): Image

    fun getChatImageUrl(messageID: String)
}

// У каждого юзкейса должна быть только одна ответственность
class SaveImageUseCase @Inject constructor(
    /*...*/
) {
    operator fun invoke(file: File): Single<Boolean>
    
    // Перегрузка подходит для тех же зон ответственности юзкейсов, 
    // но с другим набором параметров.
    operator fun invoke(path: String): Single<Boolean>
}

class GetChatImageUrlByMessageIdUseCase() {
    operator fun invoke(messageID: String): Url {...}
}

Примечание: перегрузка — вещь условная, это нужно будет обсудить с командой.

Другим решением является создание юзкейса для каждого параметра, например GetUser, GetUserByUserId, GetUserByUsername и т. д.

Но думаю, что перегрузка имеет больше смысла, потому что очень сложно давать имена при наличии 4-5 параметров.

Именование  

Именование класса UseCase простое: глагол в настоящем времени + существительное/что (опционально) + UseCase.

Примеры: FormatDateUseCase, GetChatUserProfileUseCase, RemoveDetektRulesUseCase и т. д.

Имена функций могут быть как с использованием оператора invoke, так и обычными именами функций.

class SendPaymentUseCase(private val repo: PaymentRepo) {

    // использование функции оператора
    suspend operator fun invoke(): Boolean {}

    // обычные имена 
    suspend fun send():  Boolean {}
}


// --------------Использование--------------------

class HomeViewModel(): ... {

    fun startPayment(...) {
      sendPaymentUseCase() // использование invoke
      sendPaymentUseCase.send() использование обычных функций 
    }
}

На мой взгляд, применение оператора invoke лучше, поскольку:

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

Безопасность потоков 

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

// Добавление больших списков и операций сортировки может утяжелить работу, 
// поэтому должно выполняться на другом потоке.
class AUseCase @Inject constructor() {
suspend operator fun invoke(): List<String> {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
return list.sorted()
}
}

// Делайте так:
class AUseCase @Inject constructor(
// или диспетчер по умолчанию
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
list.sorted()
}
}

// Не переключайте контекст, когда вы не выполняете тяжелую операцию или просто вызываете репозиторий,
// поскольку функции репозитория должны быть безопасными для основного потока.
class AUseCase @Inject constructor(
private val repository: ChannelsRepository,
// или диспетчер по умолчанию
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
repository.getSomething()
}
}

Красные флаги при использовании юзкейсов

1. Наличие любого класса, не относящегося к домену, в качестве входного, выходного или в теле юзкейса, например ui-модели, data-модели, связанные с Android импорты или мапперы ui-домена/data-домена.

Почему? Это нарушает ответственность юзкейса и делает его менее пригодным для повторного использования.

Пример 1. Сопоставление данных и домена — это ответственность репозитория, а сопоставление ui-домена — это ответственность ViewModel/презентатора.

Пример 2. Возвращение ui-модели, специфичной для экрана, сделает юзкейс пригодным только для этого экрана.

Пример 3. Принятие решения о фактическом сообщении об ошибке в юзкейсе. Это ответственность представления. Юзкейс должен возвращать только ошибку, а не отображаемое сообщение.

// Не используйте классы/импорты, имеющие отношение к Android 
class AddToContactsUseCase @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    operator fun invoke(
        name: String?,
        phoneNumber: String?,
    ) {
        context.addToContacts(
            name = name,
            phoneNumber = phoneNumber,
        )
}

2. Наличие более одной публичной функции, если это не перегрузка для одной и той же ответственности.

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

3. Наличие не общих бизнес-правил, определенных в юзкейсе, обычно означающее определенную логику для экрана.

Почему? Это делает юзкейс непригодным для повторного использования для любого другого экрана/юзкейса.

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

// Не делайте так:
class PerformeSomethingUseCase @Inject constructor() {
    val list = mutableListOf<String>()
    suspend operator fun invoke(): List<String> {
        repeat(1000) {
            list.add("Something $it")
        }
        return list.sorted()
    }
}

5. Обобщенное наименование юзкейса, например LivestreamUseCase, UserUseCase, GalleryUseCase и т. д.

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

Общие вопросы

1) Следует ли использовать абстракцию в юзкейсах?

Во многих статьях и руководствах приводятся примеры реализации юзкейсов с абстракцией. Вот один из таких примеров:

interface GetSomethingUseCase {
    suspend operator fun invoke(): List<String>
}

class GetSomethingUseCaseImpl(
    private val repository: ChannelsRepository,
) : GetSomethingUseCase {
    override suspend operator fun invoke(): List<String> = repository.getSomething()
}

// Затем привяжите эту реализацию к интерфейсу с помощью инъекции зависимостей

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

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

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

2) Что делать с бесполезными юзкейсами?

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

class GetSomethingUseCase @Inject constructor(
   private val repository: ChannelsRepository,
) {
    suspend operator fun invoke(): List<String> = repository.getSomething()
}

Возникает “правомерный” вопрос: не лучше ли вместо этого использовать функцию репозитория в ViewModel?

Что ж, Google согласен с вами в решении этого вопроса. Вот плюсы (+) и минусы (-) постоянного доступа к слою данных с помощью юзкейсов.

  • Многие юзкейсы добавляют сложности без особой выгоды (жирный -).
  • Постоянное использование юзкейсов защитит код от будущих изменений (+). Например, если отправка платежа потребует еще одного шага, придется создать новый юзкейс, а затем рефакторить каждую ViewModel, которая использовала эту функцию в репозитории, чтобы использовать вместо нее юзкейс.
  • Постоянное использование юзкейсов будет действовать как документация и поможет лучше понять, какие задачи выполняет проект (+). Например, новый разработчик может понять, чем занимается проект, просто прочитав названия юзкейсов.
  • Постоянное использование юзкейсов поможет обеспечить последовательность действий и снизить усталость от принятия решений (+).

Решение зависит от вас и вашей команды и может варьироваться в зависимости от размера проекта.

3) Допустимы ли юзкейсы в другом юзкейсе?

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи ilyas ipek: UseCase Red Flags and Best Practices in Clean Architecture

Предыдущая статьяРеализация подсказок с помощью Modifier в Jetpack Compose