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

Немного о себе

Являюсь лидом мобильной команды разработки в финтех компании Peter Partner. Мы реализовали систему по автоматизации торговли, которая интегрирована с крупными торговыми брокерами. Проект локализован на множество языков и им пользуется свыше 1 млн. человек в странах Азии, Африки и Южной Америки.

Что мы хотели?

Максимально изолированные модули без копипаста.

Что имеем?

  1. KMP приложение для Android/iOS

  2. Ktor+Koin+Serialization

  3. MVI

Немного теории о многомодульности

Кто работал с этим может смело листать к следующему абзацу, кто об этом только слышал то остаемся, будет интересно.

Обычно выделяют 2 группы модулей: Core(базовый функционал не имеющий прямой связи с бизнес логикой приложения) и Feature(фичи приложения). Чаще всего еще есть Utils и что-то с UI (shared, android, ios). Основные группы делят на Api и Impl, то есть интерфейс и реализация. В идеальном мире FeatureApi модули не должны ничего знать друг о друге, но на практике так получается далеко не всегда. И если с этим еще можно жить, то вот делать зависимость одного Impl модуля на другой это совсем плохой тон. Как только вы разрешите себе сделать это хоть раз, то не заметите как ваше приложение стало ужасной паутиной с бесконечными циклическими зависимостями, которые будут решаться тем, что весь код переедет в один большой монолит.

Проблема

Есть модель в одном FeatureImpl модуле, которая будет использоваться в другом модуле. Как пример могу привести подписки. В нашем приложении есть два Feature модуля, которые так или иначе умеют "получать" подписки:

  • FeatureSubscriptionApi - отвечает за весь функционал связанный с подписками. Покупка, ограничения, преимущество разных планов и еще немного всего по мелочи.

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

Далее возникает вопрос, что со всем этим делать и как не ломая зависимости распарсить общую модель.

Решение

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

Интерефейс
interface Parser<JsonObject, CommonModel> {
    fun parse(from: JsonObject): CommonModel
}

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

Пример реализации
@Serializable
class SubscriptionsResImpl(
    val type: String,
    val productId: String? = null,
    val benefits: SubscriptionsBenefitsImpl,
)

@Serializable
class SubscriptionsBenefitsImpl(
    /*какие-то поля*/
)


val commonJsonConfig = Json {
    ignoreUnknownKeys = true
    allowSpecialFloatingPointValues = true
}

class SubscriptionParser : Parser<JsonObject, SubscriptionInfo?> {
    override fun parse(from: JsonObject): SubscriptionInfo? {
        val item = commonJsonConfig.decodeFromJsonElement<SubscriptionsResImpl>(from)
        val type = subscriptionTypeMap[item.type] ?: return null
        return SubscriptionInfo(
            productId = item.productId.orDefault(),
            benefits = item.benefits.let {
                SubscriptionBenefits(
                    /*какие-то поля*/
                )
            },
        )
    }

    companion object {
        val subscriptionTypeMap = SubscriptionType.entries.associateBy {
            when (it) {
                SubscriptionType.None -> "none"
                SubscriptionType.Starter -> "starter"
                SubscriptionType.Premium -> "premium"
                SubscriptionType.Pro -> "pro"
            }
        }
    }
}

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

Как я писал выше, работает это в связке с DI. Тут идея следующая - взять конкретную реализацию и привязать ее к конкретному qualifier. Мы в проекте используем koin. Поэтому и показывать буду на его примере. Кто использует Dagger, пожалуйста, все тоже самое там есть.

DI

Определим какие вообще могут быть парсеры при помощи enum class.

enum class QualifierParser {
        SubscriptionParserQualifier,
        //что-то еще
}

Пропишем все биндинги в KoinModule блоке.

singleOf(::SubscriptionParser) {
        qualifier = named(Parser.QualifierParser.SubscriptionParserQualifier)
        bind<Parser<JsonObject, SubscriptionInfo?>>()
}

Все подготовительные операции готовы. Теперь можем парсить подписки хоть по всему приложению и ничего нам за это не будет.

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

Вызов в FeatureSettingsImpl
@Serializable
data class ProfileResImpl(
    val email: String,
    val id: String,
    val subscription: JsonObject,
)
class ProfileMapper :
    ReqResMapper<ProfileReq, ProfileReqImpl, ProfileResImpl, ProfileRes> {

    override fun fromCommon(from: ProfileReq): ProfileReqImpl =
        ProfileReqImpl()

    override fun toCommon(from: ProfileResImpl): ProfileRes {
        val parser =
            getSingle<Parser<JsonObject, SubscriptionInfo?>>(named(Parser.QualifierParser.SubscriptionParserQualifier))
        return ProfileRes(
            Profile(
                id = from.id,
                email = from.email,
                subscriptionBenefits = parser.parse(from.subscription)!!
            )
        )
    }
}

Тут используется интерфейс ReqResMapper . Функционал у него довольно простой, маппинг моделей между слоями. Его можно легко заменить на ваше решение.

Итог

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

Спасибо всем кто дочитал до конца! Верю, что на одну проблему с зависимостями между модулями у Вас стало меньше. А если у Вас есть другое решение или идеи как улучшить это, то пишите в комментарии, буду рад почитать другие мнения!

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


  1. sheckspir
    18.12.2023 18:18

    Интересно

    Мы на проекте пошли немного другим путём, мы получаем DTO объект только с примитивами, а дальше мы его мапим с помощью подобных интерфейсов для domain слоя

    Схема рабочая :-)


    1. kazachenko_ka Автор
      18.12.2023 18:18

      Спасибо за комментарий, кажется у Вас идея и правда такая же) Значит хорошая идея)