Від MVP до екосистеми: як еволюціонує архітектура Android-продукту, коли команда і масштаб ростуть

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Мене звати Олександр Гріндій, я Android Team Lead в United Tech, спеціаліст з комерційним досвідом від невеликих стартапів до великих продуктових команд. У цій статті я хочу показати не «правильну архітектуру за підручником», а реальну еволюцію Android-продукту: від MVP і моноліту до модульної архітектури й далі до екосистемного підходу. Ця історія про масштаб і біль, який з’являється разом із ростом команди, функціоналу та амбіцій продукту. Стаття буде корисною Android-розробникам, техлідам і менеджерам мобільних команд, які працюють із продуктовими застосунками та стикаються з ростом системи від MVP і моноліту до модульності й масштабування на рівні кількох команд. Тобто для кожного, хто вже відчув, як моноліт починає гальмувати команду, а архітектурні рішення перестають відповідати масштабу продукту.

MVP: швидкість важливіша за архітектуру

Більшість великих продуктів починаються з великих амбіцій та «костилів». Проблематика створення проєкту, коли він лише зароджується, а сама компанія на початковому етапі у тому, що є лише ідея, яку потрібно швидко перевірити. Тут головний Time-to-Market. Немає часу на ідеальну архітектуру, немає чіткої дизайн-системи, немає навіть повного розуміння, що саме з цього виросте, тільки частина MVP. Завдання розробнику звучить приблизно так: «Давай швидко зробимо, заллємо в стор і подивимось, чи вистрелить».
На цьому етапі архітектура — це інструмент швидкості, а не довгострокової стабільності. Якщо завтра ідея не спрацює, то код можна видалити. Тому рішення часто приймаються максимально прагматично.

І це нормально. Проблеми починаються не тут.

Монолітна архітектура: перший виток еволюції

Коли MVP отримує перші результати, з’являється розвиток. Функціонал розширюється, додаються нові екрани, бізнес починає вкладатися в напрям. У цей момент найчастіше формується монолітна архітектура.

Один розробник або невелика команда підтримують увесь код в одному проєкті. Зазвичай це щось на базі Clean Architecture, SOLID, або їх спрощених версій.

Моноліт має очевидні плюси:

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

Бізнес задоволений, команда додає функціонал, продукт розвивається. На цьому етапі моноліт є абсолютно робочим рішенням. Але масштаб має властивість накопичуватись.

Точка перелому: коли моноліт стає «доміно»

Після росту команди і функціоналу з’являються системні проблеми.

Перша — складність підтримки. Код написаний у різний час, під різні дедлайни, з різними компромісами. Зв’язність компонентів зростає. Будь-яка зміна в одній частині системи починає створювати каскадні побічні ефекти. Рефакторинг стає ризикованим.

Друга — build time. У найгіршому випадку в моїй практиці повна перезбірка проєкту на потужному MacBook Pro займала близько 35 хвилин. Це означає, що кожна суттєва зміна — це мінімум пів години очікування. У масштабі команди це перетворюється на десятки втрачених годин щотижня.

Третя — тестування. Коли код тісно пов’язаний, інтеграція unit-тестів стає складною. Зв’язність між шарами не дозволяє ізолювати бізнес-логіку. Намір «давайте додамо тести» розбивається об реальність архітектурних компромісів, зроблених на старті.

Четверта — паралельна розробка. Команда росте, з’являються нові розробники, а code review починає створювати bottleneck. Лід фізично не встигає апрувити всі зміни, паралельні фічі конфліктують. Виникають ситуації, коли код вливається в гілку розробки швидше, ніж команда встигає узгодити архітектурні рішення.

Моноліт не «поганий». Він просто перестає відповідати масштабу.

Реальний факап моноліту: нехтування масштабом

Передумови

Коли продукт лише починав масштабуватися, у команді співіснували різні підходи до проєктування. Бізнес ще не розумів, чи це вже фінальна модель продукту, чи лише стартова фаза. У таких умовах команда свідомо обрала шлях мінімальної кількості абстракцій і спрощеної структури. Ми зменшували кількість шарів і допускали змішування логіки, щоб пришвидшити розробку.

Практичний кейс

Щоб скоротити час реалізації й уникнути зайвого шаблонного коду, приймали рішення на кшталт:

  • використовувати Repository безпосередньо у ViewModel
  • не вводити окремі абстракції для роботи з різними джерелами даних (Local / Remote)

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

Проблема проявилася пізніше.

Коли фіча потребувала масштабування, бізнес сформулював нову вимогу: один і той самий функціонал мав працювати в кількох режимах — залежно від умов, конфігурації або A/B тестів. У цей момент стало очевидно: через відсутність ізоляції шарів і абстракцій зміни неможливо внести точково. Будь-яка модифікація тягнула за собою рефакторинг значної частини коду. Те, що раніше економило час, почало його системно з’їдати.

Рішення

Команда відклала аргумент «нам потрібно швидше» і повернулася до фундаментального питання: як зробити систему гнучкою до змін?

Ми перейшли до більш повної реалізації Clean Architecture з чітким розділенням шарів:

  • Presentation
  • Domain
    Data

Ключовою стала поява абстракцій на рівні бізнес-логіки.

Наприклад:

interface IsUserSufficientAgeUseCase {
    operator fun invoke(): Boolean
}

internal class IsUserSufficientAgeUseCaseImpl1(
    private val userRepository: UserRepository
) : IsUserSufficientAgeUseCase {
    override fun invoke(): Boolean {
        return userRepository.getUserAge() >= 18
    }
}

internal class IsUserSufficientAgeUseCaseImpl2(
    private val userRepository: UserRepository
) : IsUserSufficientAgeUseCase {
    override fun invoke(): Boolean {
        return userRepository.getUserAge() >= 22
    }
}

Маємо один інтерфейс use case та кілька реалізацій із різними правилами.

Для A/B тесту або зміни бізнес-логіки достатньо підключити іншу реалізацію через DI, не торкаючись UI чи інших шарів.

Таким чином:

  • зміни стали локальними
  • тестування стало ізольованим
  • вартість модифікацій суттєво зменшилась

Приклад / рекомендації Clean Architecture:
 developer.android.com/...​hitecture/recommendations

Висновок

Ми пришвидшили старт розробки, пожертвувавши архітектурною гнучкістю.
Поки вимоги були простими — це працювало. Коли продукт почав масштабуватися, відсутність абстракцій перетворила кожну зміну на дорогий рефакторинг. Перехід до чіткої багатошарової архітектури не зробив розробку швидшою миттєво. Але він знизив вартість змін у довгостроковій перспективі, що для зростаючого продукту важливіше за короткострокову швидкість.

Модульна архітектура: контрольоване табулювання маси

Отож у певний момент стає очевидно, що постійний рефакторинг починає з’їдати значну частину часу команди. З’являється потреба в ізоляції, контрольованому масштабуванні та розподілі відповідальності. Перехід на модульну архітектуру зазвичай починається з feature-based модуляризації. Кожна фіча отримує окремий модуль із власною структурою та мінімальним набором залежностей. Спільний функціонал виноситься в core/common модулі для перевикористання.
Це дає кілька важливих ефектів. Наприклад, ізоляцію. Фіча більше не містить коду інших фіч і має чітку зону відповідальності. А ще — прозорі залежності. Граф залежностей стає зрозумілішим, а архітектурні порушення видимими. Ще один ефект — ownership. За кожним модулем закріплюється відповідальний розробник або невелика група, і з’являється експертиза по конкретних частинах системи. Сюди ж оптимізація збірки. Повну перезбірку, яка раніше займала близько 30–35 хвилин, вдалося скоротити більш ніж удвічі, приблизно до 10 хвилин. Додатково з’явилась ефективна інкрементальна збірка: при роботі в окремому модулі перезбирається лише необхідна частина проєкту, що займає лічені секунди. Для великого Android-проєкту це відчутний приріст продуктивності.

Схема модульного проєкту *

* Високорівнева схема для загального розуміння

На схемі можна побачити зв’язки фіч, спільні частини та мікроструктуру в кожній фічі окремо. Головна перевага в тому, що внутрішня екосистема фічі може мати власну структуру або UI-архітектуру, але видавати той самий необхідний результат для функціонування — завдяки продуманій, заздалегідь закладеній модульній архітектурі.

Найпоширеніші хибні уявлення про перехід на модульну архітектуру

Перехід на модулі часто супроводжується завищеними очікуваннями.

Перший міф: «розіб’ємо на модулі й усе стане швидше». Насправді на початку збірка може навіть сповільнитись, якщо не оптимізувати залежності та не продумати структуру. Другий міф: «модульність = миттєвий рефакторинг моноліту». Це поступовий процес, який потребує плану. Якщо робити це хаотично, можна отримати систему складнішу, ніж початковий моноліт. Третій — «кожна фіча має бути окремим модулем». Модулі — це логічні частини системи. Не всі з них відповідають фічам. Частина — це інфраструктурні або бізнес-шари.

Модульність — не чарівна кнопка. Вона просто переносить контроль на новий рівень

Кількість модулів не вирішує проблему, а може й погіршити.

Як ми починали прискорювати наш build time у 2.5 рази.

Коли команда досягає лімітів моноліту, описаних раніше, перехід на модульну архітектуру не завжди стартує як фінальний, продуманий варіант, який можна просто «взяти й використовувати». Саме тут ми й зіштовхнулися з тим, що, окрім появи нових модулів, потрібно ще вміти правильно їх проєктувати та готувати.

Як команда зіштовхнулася з проблемою build time

Мета перших модулів була проста: розділити проєкт на найменші частини, які майже не пов’язані між собою. Зазвичай це закінчується появою «God-модулів» із назвами на кшталт utils, common, core.
Під час переходу такі модулі справді виконували свою функцію й дозволяли поступово «розрізати» моноліт. Спершу команда не хотіла надмірно заглиблюватися в ідеальну модульну структуру, бо витрачати багато часу без відчутного профіту здавалося марнотратством.
З чим ми зіштовхнулися? Все вірно, з ще гіршою ситуацією, ніж у моноліті. Величезні модулі з великою кількістю логіки та не надто оптимізованими зв’язками зробили cold build ще довшим, ніж раніше.

Вирішення

Проаналізувавши можливості та ресурси, ми зрозуміли, що проблема може бути не в «залізі» (ноутбуках) чи CI/CD (орендованих машинах для збірки), а в самому проєкті. Зв’язки між модулями були неоптимальні й змушували нас витрачати час на послідовну збірку.

Перші дії та рекомендації:

Обережніше з api — наша перша практична рекомендація: намагайтеся за будь-яку ціну уникати підключення модулів через api, якщо в цьому немає явної потреби.

Замість:

api(projects.features.profile.api)

використовуйте:

implementation(projects.features.profile.api)

Коли ви починаєте активно перевикористовувати модулі, api — це не просто «зручний інструмент, щоб не копіювати залежності». Він нав’язує транзитивні залежності іншим модулям навіть тоді, коли вони їм не потрібні. Наприклад, я хочу підключити модуль у свою фічу, і цей модуль має залежності на profile. Але у моєму конкретному модулі ці залежності не використовуються. Якщо я підключу його через api, Gradle «протягне» ці залежності далі автоматично. У результаті це може дати:

  • зайві залежності в графі,
  • довшу компіляцію,
  • підключення інструментів/код-частин, які не потрібні.

Глибший аналіз: Gradle Build Scan

Якщо йти далі, дуже допомагає інструмент Gradle Build Scan:
docs.gradle.org/...​nt/userguide/inspect.html

Він дає багато можливостей, щоб зрозуміти, як саме працює ваш проєкт. У нашому випадку ми використали timeline та деталізацію по окремих кроках (steps) збірки, і швидко виявили, де саме треба покращувати структуру та зв’язки проєкту. Утиліта не лише показує, що/коли/як виконувалося, а й візуалізує це у вигляді таймлайнів. Це допомагає чітко побачити:

  • де збірка йде паралельно,
  • а де вона вимушено стає послідовною і починає «буксувати».

Висновок

  • Модульність сама по собі не гарантує швидшу збірку: «God-модулі» на кшталт utils/common/core легко перетворюють проєкт на «моноліт із модулів».
  • Якщо зв’язки між модулями не оптимальні, збірка стає більш послідовною, а cold build — довшим, ніж у моноліті.
  • Один із частих прискорювачів — обережне використання api: воно може «протягувати» зайві залежності й збільшувати час компіляції.
  • Для пошуку вузьких місць варто використати Gradle Build Scan та його timeline, щоб побачити, де збірка паралелиться, а де «буксує».

Це не всі рекомендації та підходи, які можна використати, але були частиною, яка допомогла нам із командою досягти результату: зменшити час компіляції нашого проєкту майже у 2,5 раза.

Екосистемна архітектура: масштаб на рівні команд

Екосистемний підхід — це не просто «більше модулів». Це розподіл продукту на підпроєкти, які розробляються автономними командами.

Кожна команда має:

  • свого техлідa;
  • продакт-менеджера;
  • дизайнерів;
  • QA;
  • власний внутрішній флоу.

З технічної точки зору повноцінний продукт починає складатися з окремих маленьких підпроєктів та кодових баз, які інтегруються за визначеними контрактами.

У результаті:

  • окрема команда працює в межах свого скоупу;
  • build time для підпроєкту знову вимірюється секундами;
  • контроль над розробниками стає локальним, а не централізованим;
  • масштабування команди стає керованим.

По суті, ми повертаємося до умов «маленької команди», але вже в межах великого продукту.

Реальний кейс: перші проблеми екосистемної архітектури

Практичний кейс

Коли команда виросла майже до 30 розробників, стало очевидно, що монолітна кодова база починає гальмувати розвиток продукту.

Ми перейшли з моноліту до модульності та далі до екосистемного підходу, де великі фічі виділялися в окремі проєкти з власними репозиторіями.

Інструменти на старті

Ми почали з того, що вже мали, — шляхом найменшого опору. Для хостингу наших «мініпроєктів» ми використали GitLab і його механізм публікації AAR artifacts. Це не ідеальне рішення, але на той момент воно добре вкладалося в нашу готову інфраструктуру та дозволяло стартувати міграції без суттєвого навантаження на DevOps-команду.

Офіційні приклади, де можна подивитися підхід:
gitlab.com/...​xamples/android/artifacts

publishing {
    publications {
        aar(MavenPublication) {
            groupId 'com.example'
            artifactId 'library'
            version '0.1'
            artifact("$buildDir/outputs/aar/library-release.aar")
        }
    }
    repositories {
        maven {
            name "gitlab-maven"
            url "https://gitlab.com/api/v4/projects/{PROJECT_ID}/packages/maven"

            credentials(HttpHeaderCredentials) {
                name = "Job-Token"
                value = System.getenv('CI_JOB_TOKEN')
            }
            authentication {
                header(HttpHeaderAuthentication)
            }
        }
    }
}

Все, що вам потрібно на першому кроці — це описати готовий шаблон з документації: вказати дані проєкту та хост. І у вас уже майже готова база для екосистеми. Наступний крок — суміжна робота з DevOps-командою (або самостійно, якщо такої ролі в команді немає). Розгортання CI/CD для публікації нової бібліотеки може освоїти й розробник. Головне, щоб була мета та мотивація.

Перша перемога — і швидка «реальність»

На цьому етапі ми вже мали першу фічу, винесену в окремий проєкт: із власним середовищем для запуску та тестування. Перший результат виглядав дуже оптимістично. Якщо раніше повна збірка застосунку займала близько 30 хвилин +, то під час роботи з окремою фічею результат можна було отримати приблизно за 15 секунд. Але вже через кілька днів почали з’являтися нові проблеми.

Перші проблеми, з якими ми зіштовхнулися

1. Інтеграційні контракти — Коли фіча стає окремим проєктом, важливо чітко визначити, як вона інтегрується в основний застосунок: які дані приймає, як ініціалізується, які залежності використовує. На старті ми недооцінили це. Команди почали буквально «читати код одна одної», щоб зрозуміти, як правильно підключити фічу. Рішенням стало введення чітких інтеграційних контрактів і документації, де описано всі вимоги для інтеграції.

2. Версійність — Ще одна проблема з’явилася під час оновлення фіч. Без зрозумілого версіонування будь-який реліз міг містити breaking changes, які ламали інтеграцію. Тому кожен проєкт перейшов на нормальну систему версійності та release notes.

3. Дублювання спільного коду — Коли наступні команди почали виносити свої фічі, стало очевидно, що вони копіюють частину коду, який уже використовувався іншими. Це призводило до появи дублікатів і різної поведінки однакової логіки.

Ми вирішили цю проблему так само, як і на рівні модульної архітектури — виділили спільний core із перевикористовуваними компонентами. У результаті на налагодження контрактів між командами пішло приблизно два місяці.

Головний висновок: екосистема — це не просто «розпиляти проєкт». Це насамперед домовитися про правила взаємодії між командами.

Схема екосистемної архітектури *

* Високорівнева схема для загального розуміння

Коли не варто еволюціонувати далі

Екосистемна архітектура має сенс лише тоді, коли:

  • продукт має стабільну бізнес-модель;
  • команда зріла процесно;
  • є DevOps-підтримка;
  • масштаб виправдовує складність.

Якщо продукт ще шукає ринок або команда не має стабільних процесів, така еволюція створить більше хаосу, ніж користі.

Висновок

Архітектура — це не самоціль і не тренд. Це інструмент зняття конкретного болю на конкретному етапі розвитку продукту. MVP вимагає швидкості, моноліт вимагає простоти й мінімального оверхеду, модульність — контрольованого масштабування, а екосистема — організаційної зрілості й роботи з великими командами.
Найбільша помилка — впроваджувати складні архітектурні рішення раніше, ніж цього потребує масштаб. Але не менш небезпечно тримати моноліт у момент, коли кожна нова кнопка коштує тижнів нервів і нескінченних збірок.
Еволюція архітектури має бути поступовою й відповідати реальним потребам продукту та команди. Інакше замість розвитку ми отримуємо просто новий рівень складності.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дуже подобається думка про те, що архітектура має допомагати, а не бути самоціллю. Багато розробників, особливо зі Східної Європи, звикли будувати архітектуру за підручником — чисто, красиво, масштабно, і пиляти абстракції заради абстракцій, щоб можна було робити інтерфейс поки робиш інтерфейс.

У прикладі з IsUserSufficientAgeUseCase показано ідеальний кейс, коли інтерфейс справді потрібен, але в більшості випадків, думаю, розумніше дотримуватися принципу YAGNI — робити тільки те, що необхідно, зважаючи на архітектурну модель, прийняту в команді.
І чим простіша ця модель, тим легше буде її дотримуватися.

З точки зору бізнесу, хорошим рішенням буде відокремлення продукту від інфраструктури (якщо, звісно, є ресурси 😅).
Тобто проблемами зі збіркою, лінт-правилами, контрактами між командами, загальноприйнятим фреймворком (якщо він є), має займатися окрема команда, яка фокусується лише на цьому скоупі й не має жодного стосунку до продуктових фіч.
Їхнє завдання — створити таку систему, за якої в проєкт буде вбудована дубина, що б’є розробників по руках, якщо вони пхають api(...) туди, куди не треба.

Загалом, стаття дуже крута і показує реальну проблему та реальне рішення. Сумно лише, що не все залежить від розробників, інколи бізнес просто дає тобі виделку і змушує нею щось чистити.

res.cloudinary.com/...​/hcl2phdtl18mvijkcj1c.jpg

Підписатись на коментарі