Как добавить новый сценарий в существующее Android-приложение без технического долга

Привет! Меня зовут Ирина, я Android-разработчик в SimbirSoft. Хочу поделиться с вами опытом разработки новых функций и рефакторинга существующих модулей Android-приложения компании «Юрент» — одного из крупнейших в России сервисов аренды электросамокатов (кикшеринга).
Добавление нового сценария (фичи) в мобильное приложение без создания технического долга — это искусство баланса между скоростью выпуска и архитектурной чистотой. С одной стороны, менеджмент требует выпустить новую фичу «еще вчера». С другой – разработчики знают: любой новый экран или бизнес-логика, втиснутые в устаревший код без рефакторинга, завтра превратятся в источник багов и архитектурный хаос. Конечно, совсем без какого-либо техдолга вести разработку не получится, это утопия, но все-таки можно постараться свести его к управляемому техническому долгу. В нашей практике мы выделяем для этого четкий бюджет времени в спринте, чтобы новый код не превратился в «архитектурный бетон», который никто никогда не тронет. В этой статье я покажу, каких принципов разработки мы придерживались, чтобы избегать неуправляемого технического долга, и продемонстрирую эти принципы на примере задачи по добавлению промокнопки на главный экран приложения.
Материал в первую очередь ориентирован на специалистов Mobile Team Lead/ Tech Lead, так как именно им предстоит принимать архитектурные решения и защищать их перед менеджментом.
Управляемый и неуправляемый техдолг
Понятие «управляемый технический долг» (Managed Technical Debt) на практике означает следующее:
1) Технический долг принят осознанно, а не появился из-за чьей-то лени или недосмотра.
2) Технический долг задокументирован. Все знают, почему и где «накостыляли».
3) Есть четкий план погашения техдолга. Формулировка «исправим в следующем спринте» лучше, чем «исправим, когда не останется продуктовых задач», потому что такого момента может и не наступить.
Соответственно, «неуправляемый технический долг» представляет собой ситуации вида:
Сначала разработчики собирались переписать код, но не успели, а потом забыли это сделать.
В коде стоит комментарий вида «// TODO: Переписать» от разработчика, который уже уволился, и никто из нынешних разработчиков не разбирается, как устроен данный код.
Со временем проблемный код превращается в легаси и остается нетронутым годами.
Но как перейти к управляемому техническому долгу на практике? Ведь в момент, когда проджект-менеджер стоит над душой и требует выкатить новую фичу за 2 дня, очень легко забыть про документирование, план и принципы чистого кода.
Далее я покажу, как мы боремся с техдолгом на примере задачи с промокнопкой. Требования следующие:
Промокнопка нужна, чтобы показывать пользователю акции, специальные предложения и другие кампании.
По нажатию на кнопку выполняется переход по диплинку либо открывается ссылка в вебвью внутри приложения или во внешнем браузере.
Требуется обеспечить настройку промокнопки через административную панель без необходимости выпускать новую версию мобильного приложения.
На первый взгляд, кнопка — это «просто UI». Но на деле здесь кроется три архитектурных вызова:
навигация (диплинки/WebView),
взаимодействие с картой (конфликт UI),
конфигурируемость (настройка с сервера).
Если не заложить правильные абстракции сейчас, то через полгода эта кнопка обрастет 15 условиями и превратит реализацию главного экрана в God Object. Покажу, как мы этого избежали. Для начала приведу базовые архитектурные принципы, которые позволяют снизить вероятность возникновения техдолга.
Принцип №1: придерживаемся Clean Architecture

Разделяем код приложения на независимые слои. На уровне бизнес-логики (Domain) новый сценарий не должен знать, где он запускается (iOS/Android) и откуда пришли данные (REST API/БД).
Для нового сценария создаем UseCase или Interactor. Весь бизнес-сценарий пишем как чистый Kotlin класс, который получает интерфейсы для ввода/вывода. В этом слое запрещен импорт Activity, Context или фреймворков БД, HTTP-клиента.
Если потребуется внести изменения в UI или REST API, вы переписываете только адаптеры, а логика сценария (ядро) остается без изменений. Это предотвращает архитектурный долг.
Применим этот принцип к нашей задаче с промокнопкой. Нам нужно, по сути, сделать конвейер: получить конфиг с сервера → запросить конкретные данные по шаблону → закешировать → отдать на UI.
/**
* Реализация интерактора (Use Case из слоя Domain) для получения кнопки промоакции.
*
* Этот класс является частью чистой бизнес-логики (Domain Layer).
* Он не зависит от Android-фреймворка и подчиняется правилу инверсии зависимостей:
* зависит от абстракций репозиториев, а не от конкретных источников данных (сеть, БД).
*/
class PromoButtonInteractorImpl(
private val marketingRepository: MarketingRepository,
private val marketingMapper: MarketingMapper,
private val configRepository: ConfigRepository,
) : PromoButtonInteractor { // Реализует интерфейс, определенный в Domain Layer
override suspend fun getPromoButton(): PromoButton? {
val config = configRepository.getConfig(PromoButtonConfig::class)
if (config.isEnabled.not()) {
return null
}
val response = marketingRepository.getPromoButton(config.templateId)
return marketingMapper.map(response.getOrNull())
}
}Данные, которые нужны новому сценарию, получаются в классе Repository в слое Data. В нашем случае репозиторий отвечает за загрузку данных через REST API, обработку ошибок и кеширование.
/**
* Репозиторий для работы с маркетинговыми данными (промокнопки, баннеры)
*/
class MarketingRepositoryImpl(
private val httpClient: HttpClient,
private val hostResolver: HostResolver,
private val dispatcher: CoroutineDispatcher,
) : MarketingRepository {
/**
* Кеш промокнопок в памяти.
*
* Key: templateId (уникальный идентификатор шаблона)
* Value: PromoButtonResponse (данные промокнопки)
*
* ConcurrentHashMap используется для потокобезопасности
*/
// TODO: Если метрики покажут, что кеш устаревает, добавить TTL
private val cache = ConcurrentHashMap<String, PromoButtonResponse>()
/**
* Получить промокнопку по ID шаблона
* @param templateId Уникальный идентификатор шаблона
* @return Result<PromoButtonResponse> - результат с данными или ошибкой
*/
override suspend fun getPromoButton(
templateId: String
): Result<PromoButtonResponse> {
// Проверяем наличие данных в кеше
cache[templateId]?.let { cachedData ->
return Result.success(cachedData)
}
// Данных в кеше нет - выполняем запрос
return withContext(dispatcher) {
try {
// Выполняем GET-запрос к API
val response = httpClient.get(
"${hostResolver.getBaseUrl()}/api/v1/marketing/template"
) {
url {
// Добавляем query-параметры
parameters.append("template", templateId)
}
}
// Обработка успешного ответа
response.mapToSuccess<PromoButtonResponse>().also { result ->
// Сохраняем данные в кеш
val data = result.getSuccessOrNull()
data?.let { cache[templateId] = data }
}
} catch (t: Throwable) {
// Обработка исключений (сетевая ошибка, таймаут и т.д.)
t.mapToError()
}
}
}Обратите внимание на пару архитектурных принципов, заложенных в этом коде:
• Все зависимости — через интерфейсы. Благодаря этому в unit-тестах мы можем подставить моки и проверить все кейсы (конфиг выключен, API вернул ошибку, кеш сработал).
• Mapper вынесен отдельно. Это позволяет тестировать трансформацию DTO → Domain Model отдельно от логики загрузки, что особенно важно, если бэкенд присылает «грязные» данные.
Итак, мы написали Interactor и Repository. Но где здесь техдолг? Честно признаюсь: кеш в ConcurrentHashMap реализован без TTL (time to live) и без рефреша. Мы приняли это решение, потому что промоакции обновляются не чаще, чем раз в сутки, а пользователь приложения кикшеринга вряд ли будет оставаться на главном экране так долго. Если метрики покажут, что кеш устаревает, мы заменим его на кеш с TTL — это будет локальное изменение в репозитории. Это документированный техдолг: в коде стоит комментарий, а в Jira заведена техническая задача в бэклоге. Приоритет задачи низкий, но если маркетинг начнет менять акции по 10 раз день, мы вспомним о ней и исправим за полчаса, не трогая интерактор и UI.
Принцип №2: придерживаемся иерархии модулей
Например, один из самых распространенных вариантов структуры модулей проекта:
feature — слой UI для фич/экранов/UI
domain — слой бизнес-логики (интеракторы)
data — слой работы с источниками данных (репозитории)
core — ядро. Эти модули содержат базовые компоненты, которые не зависят от бизнес-логики приложения. Например, работа с сетью (KTor/OkHttp), БД, DataStore, кастомные вью, функции-утилиты

Схема зависимостей между модулями на нашем проекте была несколько упрощена. Модуль Feature зависит от Domain, Domain от Data. Допускается использование репозитория (data) во вью-модели (feature) напрямую. Это удобно, когда перед отображением на экране не требуются никакие действия с данными. Такая упрощенная схема позволяет избегать бойлерплейта в написании мапперов, дублирующихся моделей и однострочных интеракторов.
Core не зависит от модулей другого уровня, только от сторонних библиотек.
Каждый модуль делится на подмодули api (набор публичных интерфейсов) и impl (скрытая реализация). Это обеспечивает слабую связанность, упрощает тестирование и позволяет легко заменять реализации без изменения зависящего от них кода. API-модуль должен быть максимально легким: интерфейсы, DTO, простые enum.
Все зависимости, которые нужны интерактору (API, Cache, Analytics), должны передаваться в конструктор без ручного создания экземпляров внутри этих классов, то есть через DI.
В случае с промокнопкой структура Domain и Data модулей следующая:
:domain_promo_button
├── :api
│ ├── PromoButton.kt
│ └── PromoButtonInteractor.kt
└── :impl
├── PromoButtonInteractorImpl.kt
├── MarketingMapper.kt
└── DomainPromoButtonModule.kt
:data_marketing
├── :api
│ ├── PromoButonResponse.kt
│ └── MarketingRepository.kt
└── :impl
├── MarketingRepositoryImpl.kt
└── DataMarketingModule.kt
:feature_marketing
├── :api
│ └── MarketingUiComponents.kt
└── :impl
├── MarketingUiComponentsImpl.kt
├── PromoButtonViewModel.kt
└── FeatureMarketingModule.kt
Вот как грамотное разбиение проекта на модули помогает бороться с техдолгом:
Техдолг изолирован внутри конкретного слабого модуля и не портит остальной код.
Можно проводить рефакторинг постепенно, то есть переписывать один старый модуль в отдельной задаче, не трогая рабочие части программы.
Понятные связи между частями системы не дают коду превратиться в запутанный клубок.
Принцип №3: придерживаемся дизайн-системы
Даже если мы идеально проработали архитектуру, в проекте могут быть проблемы в реализации UI, которые, если их вовремя не решить, приводят к накоплению технического долга. Мы сделали Clean Architecture, но это не спасло от другой проблемы — отсутствия единой дизайн-системы. Это частая проблема в мобильной разработке. Даже идеальная архитектура не спасает, если интерфейс живет своей жизнью.
Однажды к нам поступило замечание, что у всех шторок в приложении разный отступ над горизонтальной полоской dragger. Мы посмотрели код и поняли, что у нас в приложении 34 шторки, и действительно, везде этот отступ может быть разным. А еще есть несколько разных иконок крестика закрытия шторки. И не только это. Нам вместе с дизайнерами предстояло привести к единому виду цвета, типографику, размеры и отступы, иконки, тени, различные UI-компоненты. Но особо трудозатратным на нашем проекте оказалось приведение шторок BottomSheet к единому виду.
Со шторками были следующие проблемы:
1) разный вид шторок: различия в отступах, цветах и размерах dragger, в радиусе скругления углов, разные темы ("DialogFragment.setStyle()")
2) разная реализация открытия/закрытия шторки, что приводило к различной анимации открытия/закрытия и багам в навигации по экранам:
a)
fragmentManager.beginTransaction()
.add(containerViewId, bottomSheet, TAG)
.commit()
b)
val behavior = (dialog as BottomSheetDialog).behavior
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED)
c)
val behavior = BottomSheetBehavior.from(view)
requireActivity().onBackPressedDispatcher.addCallback(owner = viewLifecycleOwner) {
behavior.state = BottomSheetBehavior.STATE_HIDDEN
}
d)
bottomSheet.show(fragmentManager)
e)
bottomSheet.dismiss()Мы сделали два вида шторок:

Первый вид — модальная шторка, которая затемняет задний фон, а по нажатию за пределами шторки закрывается.
Вторая — немодальная, стандартная шторка, которая не затемняет фон и дает возможность пользователю взаимодействовать с контентом за пределами шторки.
Мы сделали универсальный UI-компонент для шторок, универсальную навигацию. Теперь все шторки выглядят и ведут себя одинаково.
Но, к сожалению, эта работа была проделана на той стадии разработки, когда в приложении было уже 34 шторки с разным поведением. Приведу статистику временных затрат по тем задачам, которыми занималась наша команда. В приведенное время входит разработка, тестирование и дизайн ревью. За каждой задачей стоит одна шторка. Мы не только переходили на новый компонент для BottomSheet, но и переводили весь UI содержимого шторки на дизайн-систему.
TASK-1 (30ч) ███████████████
TASK-2 (27ч) ██████████████
TASK-3 (28ч) ██████████████
TASK-4 (50ч) █████████████████████████
TASK-5 (31ч) ████████████████
TASK-6 (30ч) ███████████████
TASK-7 (29ч) ███████████████
TASK-8 (26ч) █████████████
TASK-9 (50ч) █████████████████████████
TASK-10 (27ч) ██████████████
TASK-11 (27ч) ██████████████
TASK-12 (26ч) █████████████
Среднее время выполнения одной задачи: 1d 7h 42m, то есть почти 2 рабочих дня. Умножьте на 34 — получается более 1000 человеко-часов. Именно столько мы заплатили за то, что не договорились с дизайнерами и не проработали дизайн-систему на старте проекта. (На самом деле больше, потому что помимо шторок мы правили и другие экраны :) )
После этого мы внедрили два правила:
Ни одна шторка не попадает в разработку, пока дизайнер не подтвердит, что она использует компоненты из дизайн-системы Figma.
Новые шторки пишутся на основе единого компонента BottomSheet, который принимает параметры (модальная/стандартная шторка, компоуз-функция с контентом, колбэки) и сам настраивает анимацию, dragger и поведение.
Новые шторки пишутся за 2 часа, а не за 2 дня. Теперь этот техдолг не накапливается.
Принцип №4: добавляем новый сценарий под фиче-тогглом с конечным временем жизни
Следующее правило, которого наша команда старается придерживаться, — не вливать новый сценарий в main-ветку без фиче-тоггла. Как правило, мы делаем удаленный флаг. Для конфигурации флагов мы использовали сервис Flagr. Конфигурации задаются через админку. Подойдет и Firebase Remote Config, и другое решение на бэкенде.
Можно использовать локальный флаг во время разработки. Его мы зашиваем в сборку приложения, и даем тестировщикам возможность включить флаг на Debug-экране.
Весь новый код оборачиваем в if (featureFlag.isEnabled).
Вот как фиче-тогглы помогают бороться с долгом: если мы допустили критическую ошибку в продакшене, мы выключаем флаг за 1 минуту. Не делаем hotfix, не откатываем код, а чиним баг в спокойном темпе — ведь именно спешка дает некачественный код.
В случае, если с нашей промокнопкой будет обнаружена проблема, мы установим promoButtonConfig.isEnabled = false для тех версий приложения Android, которые зааффектила проблема.
Но у фиче-тогглов есть обратная сторона: злоупотребление ими — это прямой путь к неуправляемому техдолгу. Вот как это происходит:
1) «Окаменение» кодовой базы. Разработчик добавляет фичу под фиче-тогглом для AB-теста. Эксперимент длится неделю. Целевая метрика не взлетела, но и не упала. Про флаг забыли. А спустя год — в коде десятки флагов, значения которых никогда не меняются. В результате наблюдаем следующее:
когнитивная нагрузка: Новый разработчик тратит часы, пытаясь понять, какая из веток if (featureFlag.isEnabled) реально работает у пользователей;
раздувание сборки: мертвый код все еще компилируется, тянет лишние ресурсы и зависимости, замедляет билд.
2) Комбинаторный взрыв в тестировании. Допустим, у нас есть 4 AB-параметра для промокнопки:
1. Дизайн (текст над картинкой или под картинкой).
2. Расположение (вверху или внизу экрана).
3. Поведение кнопки при действиях пользователя на карте (скрывать кнопку до конца сессии или снова отобразить через несколько секунд).
4. Загрузка данных по новому или старому REST-запросу (разные алгоритмы рекомендаций).
Количество комбинаций поведения кнопки: 2*4 = 16. В такой ситуации QA-специалисты вынуждены проверять доработки промокнопки на 16 разных конфигах!
3) Усложнение DTO и моделей: для поддержки разных фиче-тогглов: модели данных превращаются в свалку полей. Например:
data class PromoButtonResponse(
val imageUrl: String?,
val useLegacyImage: Boolean, // Для AB-теста
val newImageUrl: String?,
...
)В маппере появляется логика if (useLegacyImage) imageUrl else newImageUrl.
AB-тест уже может быть не актуален, но сервер продолжает слать старые поля для поддержки старых сборок.
Чтобы перевести фиче-тогглы в разряд «управляемый долг»:
Заводим задачу на удаление фиче-тоггла в момент его создания.
AB-тесты удаляем вместе с проигравшим вариантом.
Раз в два спринта / раз в квартал проводим ревизию флагов. Если флаг старше 3 месяцев, не используется в эксперименте и фича сейчас не в стадии активной разработки — удаляем его.
Принцип №5: придерживаемся подхода Contract First
Есть еще один источник техдолга, который находится на стыке команд, — это несинхронизированные контракты с бэкендом. Когда мобилка и бэкенд пишут код параллельно, а JSON-схема меняется на лету, рождаются «костыли», которые потом живут годами. Здесь нам помогает подход Contract First — договариваемся о JSON-схеме до написания кода.
Создайте Mapper, который превращает DTO с сервера в доменную модель данных. Если бэкенд изменил поле — вы правите ТОЛЬКО маппер, а не 20 экранов сценария. Это локализует изменения.
Пример: маппинг полей для ссылки по нажатию на промокнопку
@SerializedName("link")
val link: String?,
@SerializedName("linkType")
val linkType: String?linkType определяет, как осуществлять переход: на экран приложения по диплинку link, на вебвью или в браузер.
Если на этапе согласования требований в контракте было значение linkType: webview, бэкенд-разработчик написал web_view, а после кодревью поправил на webView, и разработка мобильного приложения велась параллельно, то обработка нестандартного нейминга может принять такой вид:
return when {
lintType.equals("webview", ignoreCase = true) ||
lintType.equals("web_view", ignoreCase = true) ||
lintType.equals("webView", ignoreCase = true) -> {
PromoButtonLink.WebView(link)
}
...
}Подобные ситуации тянут за собой следующие проблемы:
Никто не знает, как правильно. Новый разработчик видит три варианта и не знает, какой из них актуальный. Тратится время на выяснение.
Могут возникать различия на платформах Android/iOS, например, бэкенд может слать разные поля для платформ со значениями в разном формате. Если на бэкенде решат почистить лишние поля, одна из платформ перестанет работать.
Никто не удаляет старые варианты в коде мобильного приложения из страха, что устаревшие варианты еще могут использоваться. В итоге такой код живет годами.
Решение: команды должны неукоснительно придерживаться контракта, согласованного до начала разработки. Если контракт согласован заранее — мы не гадаем, а просто маппим.
Принцип №6: придерживаемся подхода Ruthless refactoring
«Сжигание» долга в рамках того же тикета — это главный секрет. Когда вы добавляете новый сценарий, вы обязаны зарефакторить старый код, с которым он пересекается.
Допустим, мы добавляем промокнопку на главный экран приложения. При этом видим, что уже есть старый компонент — баннер, который отрисовывается в компоуз-функции главного экрана и дергает API напрямую из вьюмодели главного экрана.
В этом случае, мы ни в коем случае не добавляем новый сценарий аналогичным костылем.
Мы создаем отдельный компонент для промокнопки с отдельной вьюмоделью, репозиторием MarketingRepository, и не будем засорять вьюмодель и компоуз-функцию главного экрана. И мы переписываем баннер на отдельный компонент с использованием MarketingRepository в этом же пулл-реквесте.
Если на это нет времени — не вводите новую фичу. Технический долг копится именно тогда, когда новый код пишут поверх старого, грязного, боясь его тронуть.
Чек-лист перед отправкой в Review (PR)
За годы мы выработали чек-лист, который гарантирует, что новый сценарий не оставит после себя хвостов. Если все пункты зеленые — долг минимален.
Если все пункты зеленые — долг минимален:
✅ У нового сценария есть фиче-флаг.
✅ Бизнес-логика отделена от View (во View только рендеринг).
✅ Нет обращения к БД/API напрямую из UI-слоя (есть Repository).
✅ Модули feature - domain - data. Три кита каждой новой фичи.
✅ Все строки/ресурсы локализованы и вынесены в ресурсные файлы (нет хардкода).
✅ В верстке придерживались дизайн-системы.
✅ Соблюден контракт с бэкендом.
✅ Внесены изменения в смежные сценарии, удалены старые фиче-флаги.
И последнее: Если менеджер просит сдать фичу за 2 дня, а рефакторинг занимает 3 — договоритесь сначала сделать рабочий прототип с костылями, выкатите под флагом, но сразу после выкатки создайте тикет «Технический долг: переписать сценарий Х на чистую архитектуру» и поставьте его в следующий спринт. Главное — не делайте вид, что долга нет. Признайте его и сразу запланируйте погашение.
Заключение
В этой статье я разобрала набор ключевых практик, которые помогают нам добавлять новые сценарии без снежного кома технического долга. Но если посмотреть на них внимательно, станет очевидно: каждый пункт больше говорит про людей и коммуникацию, чем про код.
Clean Architecture и Refactoring — это про TechTalk внутри команды android-разработчиков. Если кто-то увидит возможность улучшить архитектуру или отрефакторить какой-то модуль, мы всегда можем это обсудить на регулярных встречах.
Дизайн-система — невозможна без диалога с дизайнерами. Мы договариваемся о единых компонентах в Figma, проводим Design Review.
Фиче-тогглы — это про договоренность с продактом. Вместе мы задаем время на мониторинг метрик, а потом удаляем код, который не взлетел.
Contract First— это про встречу с бэкенд-разработчиками до начала разработки фичи.
Технический долг — это социальная проблема, замаскированная под код. Когда разработчик пишет костыль, он чаще всего не ленится, а просто не знает, с кем договориться и как аргументировать изменения. Поэтому задача техлида не ограничивается написанием кода или ревью, а заключается в выстраивании мостов с менеджерами, дизайнерами, бэкендом и командой мобильной разработки. Чем раньше вы начнете разговаривать с коллегами из соседних команд, тем меньше костылей будет в вашем коде через год. И тогда ваш проект останется живым, гибким и не превратится в «архитектурную помойку», которую страшно трогать.
Удачи в ваших рефакторингах и честных разговорах!
Спасибо за внимание!
Больше авторских материалов для Mobile-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.