Search
Write a publication
Pull to refresh
3
1
Некрутов Эдуард @Not_coolEd

НеКрутой Архитектор

Send message

Вот я тоже самое сделал, когда пытался. Но меня не устраивает, что makeReservation имеет прямой доступ к DatabaseContext и может с ним взаимодействовать, хотя его использование, это часть реализации tableRepository о которой нам знать совершенно не нужно.  

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

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

Можешь, пожалуйста, сделать пример на котлин, с прокидкой 3 влоденных зависимостей?

Типа вызвать useCase, тот repository, тот dataSource, тот саму базу. Просто посмотреть как это в коде выглядит, а то я пытался пример сделать и в чем-то запутался.

Разделение на api/impl часто встречается в более крупных и долгоживущих проектах, ты прав. Оно дает как минимум 2 важные вещи.
1. Ускорение сборки (при правильном проектировании зависимостей), что критически важно для больших проектов
2. Возможность подмены реализации при сборке, что так же часто встречается в проектах, пусть и в нескольких ключевых модулях, а не во всех подряд

У меня в проекте Hilt и он позволяет создавать di-модули в impl gradle-модулях, тем самым полностью избавляя от необходимости самостоятельно дирижировать зависимостями. А так же дает возможность создать bind gradle-модуль, который бы собирал группы impl модулей, избавляя от необходимости app модулю зависеть от них всех, достаточно только прописать зависимость на bind модуль.
Конечно, это кодогенерация, которая сильно тормозит сборку, но это все еще просто код, который так же можно написать руками, сделав свой manualDI. И да, в таком случае app модуль так же станет глобальным дирижером. Я не против этого как концепции, скорее пытаюсь понять зачем это делать вручную, если можно этого не делать с помощью ооп либ. (Конечно если не стоит острого вопроса времени сборки)

Но вот что еще дает избавление от ручных пробросов зависимостей, так это навигация без использования mediator или app модуля, для разруливания переходов.
В presentation-api модуле отписывается объект Screen, который выступает в роли ключа экрана и публичный интерфейс этого экрана, у которого есть метод для вызова ui.
В presentation-impl делается фабрика создания этого экрана с параметрами из Screen и биндится в di с ключом Screen::class.
И тогда навигатор, сможет при вызове open(Screen) получить из di нужную фабрику, создать экран и получить его ui.
Для этого достаточно, чтобы любой presentation-impl зависел от нашего presentation-api, чтобы иметь доступ к ключу Screen.
И получаем, что любой экран может открывать любой другой экран на прямую, зная только его минимальный публичный интерфейс, а app ничего не знает про навигацию и переходы.


Но мне все же интересно разобраться в данном подходе и кажется я что-то в нем понял, но потом сломался)
Основная идея проброса зависимостей, это композиция контекста через делегаты?
В api модуле будет интерфейс контекста
В impl его реализация с зависимостями на другие api модули
В app собираем нужный контекст через делегаты и передаем на вход функции.

Но я так и не понял как отвязать зависимости друг от друга.
В твоем примере через ReservationContext мы получаем доступ к интерфейсу TableRepository и вызываем его. Но NetworkTableRepository должен же еще иметь зависимости на сеть или базу.
Для аналитики, нужны зависимости на конкретные сервисы аналитики.

// Тут всё осталось без изменений
  CoroutineScope(Dispatchers.IO).launch {
    isLoading = true
    delay(1000) //Имитация загрузки
    reservationContext.makeReservation(request)
    isLoading = false
  }

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

Короче я так и не смог понять как это завести с разбиением логики по слоям, на подобии клина.

Или может в этом подходе подразумевается, что по слоям не нужно бить, типа это история из ооп?

Но как передать зависимость в NetworkTableRepository , чтобы к ней не было доступа у вызывающего makeReservation?

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

Однако, у меня все еще остаются вопросы касаемо DI.

fun buildReservationContext(...)

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

В модуле :app создадим реализации интерфейсов

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

Соответственно buildReservationContext должен находиться не ниже app(или модуля с реализациями) чтобы иметь к ним доступ.

В свою очередь ReservationScreen так же должен находиться в app, чтобы иметь доступ к buildReservationContext. И как в таком случае мы сможем выделить этот Screen полностью в отдельный модуль с ui, чтобы при этом зависеть только от интерфейсов и не превратить app в глобального дирижёра зависимостями?

И второй вопрос, имеется ли в таком подходе возможность разделения модулей на api impl? Или может такое разделение считается избыточным и ненужным в таком подходе работы с зависимостями?

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

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

Связку xml + код (activity/fragment/view) можно сделать различными способами.

Как прямое управление xml = mvp
val textView = findById... и другие способы получить саму View
textView.text = "Hello World"

Как привязку данных к xml = mvvm
val text : LiveData
val binding: TextBinding = DataBindingUtil.setContentView(this, R.layout.text)
text = "Hello World"

И в таком максимально прямом подходе, можно сказать, что Activity это Presenter/ViewModel в понятии шаблона. И это дает законные основания из далее следующих классов, которые с такого ракурса будут являться частью Model, делать огромные вундервафли которые и в сеть сходят и про базу данных знают.

НЕ НУЖНО ТАК ДЕЛАТЬ.

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

И если вы делаете большие и уродливые Presenter/ViewModel, просто вспоминайте, что вы сейчас попутали понятия и используете Activity как сущность ViewModel, а класс ViewModel как часть Model.

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

Любая дополнительная логика превратит ваш клаcc ViewModel в часть Model.

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

Это сильно зависит с какой стороны смотреть и к какой части статьи комментарий.

В самом конце речь идет про сопоставление сущностей верстки с сущностями кода чернз привязку с помощью data binding, и с этой точки зрения они являются vm из шаблона.

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

Пс. Во ViewModel от гугл так же можно сохранять состояние и это тогда тоже контроллер? Или о каком вообще контроллере идет речь?

Когда-нибудь и до elm с udf доберусь. Пока копаю немного в другую сторону. Но за идею спасибо)

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

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

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

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

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

Привет)
Спасибо за статью.
Хороший материал про Immutable State и правильную работу с ним, про FSM, важность обработки ошибок и знакомство с принципами либы Arrow.

Жду следующих статей, хочется уже посмотреть и возможно похоливарить про функциональщину)

По этой статье есть несколько комментариев:

  1. Также эту задачу можно решить с помощью data class. Он изначально иммутабельный
    data class не является immutable сам по себе, мы все так же можем указать в нем var поля

  2. Для User и RegistrationError можно сделать общий sealed interface ValidateResult, и заменить им Either.
    Я это к тому, что Either это же просто способ правильной работы с ошибками логики, вместо исключений.
    Какое его отношение к фп? Может исторически подход чисто из фп вышел или есть какая-то другая связь?

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

Ждем след статью, когда уже появится чистый фп, на сколько это возможно в обертке из ооп и с разбором DI и теорией категорий)

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

Да, я об этом.

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

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

Ждем след статей)

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

Да Dependency Inversion разворачивает зависимость.

Как пример View и Presenter. Когда Presenter зависит от View, это зависимость от центра, что является плохим решением. В таком случае применяют инверсию, создавая интерфейс для View. И этот интерфейс, по своей сути, остается частью Presenter (связан с ним), что дает нам правильно направление от View к центру.
View -> (interface View <- Presenter)

Я же имел ввиду именно Dependency Injection.

Когда класс А создает класс B внутри себя, он напрямую зависит от класса B.
Если класс А будет получать класс B в конструктор, то он все еще зависит от класса В, что уже является иньекцией, но не дает разрыва реализаций. Создание интерфейса для класса B как раз и создает разрыв, чтобы зависимость была на абстракцию. Направление зависимостей в этом смысле вообще не важно.

client: FoodMenuClient = FoodMenuClient.live()

И вот ключевой момент в этом подходе будет в том, что класс А, знает о месте где можно создать класс B.
Да, само создание вынесено, в функцию live, но этот live должен знать про реализацию, что является неявной транзитивной зависимостью класса A на реализацию класса B.
Чтобы полностью избавиться от такой транзитивной зависимости, создание зависимости должно быть вне класса A.

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

Возможно, то о чем я говорю, кажется не важным в рамках одного модуля. Это так. Проблема возникнет в будущем, когда потребуется вынести FoodMenuClient в отдельный модуль и дополнительно разделить его на api/impl. В этом случае придется переписывать код, чтобы избавиться от неявной транзитивной зависимости.

Сразу стало очевидно, что формат небольших постов не зашел для Хабра.

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

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

1) Dependency Injection Free
Философия этого подхода говорит об осознанном отказе от DI библиотек в пользу ручного управления зависимостями. То есть она не отменяет DI и не отказывается от него.
Суть DI заключается в разрыве прямой связи между реализациями сущностей, что достигается прокидыванием зависимостей в объект извне самого объекта.
Твой пример:

class FoodMenuViewModel(
  private val client: FoodMenuClient = FoodMenuClient.live(),
  private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
) : ViewModel() {

val client: FoodMenuClient = FoodMenuClient.live()
client не прокидывается извне, а создается именно внутри самого объекта.
Да, его можно заменить для тестирования. И для проектов с конечным жизненным циклом (зарелизил и забыл), вполне себе отличный вариант.

Однако будет сильно проблематично разнести интерфейс FoodMenuClient и его реализацию по api/impl модулям. Для этого придется избавиться от дефолтного создания и передавать зависимость извне, что приведет к большому и сложному созданию графа зависимостей, вместо таких дефолтных значений.
Этим пунктом просто хочу предостеречь от использования такого подхода в больших приложениях, т.к. с ростом кодовой базы, вам скорее всего придется прийти к много-модульности и столкнуться с проблемой организации DI.

2) Функции в конструктор класса

class FoodMenuClient(
    val fetchFoods: (
        categoryId: String,
        callback: (Loadable<List<Food>, Error>) -> Unit
    ) -> Unit,
    val createFood: (
        food: FoodDTO,
        categoryId: String,
        callback: (Loadable<Food, Error>) -> Unit
    ) -> Unit,
) {
  companion object {
	@JvmStatic
	fun live() = FoodMenuClient(
	  fetchFoods = {},
	  createFood = {},
    )
  }
}

В данном случае class FoodMenuClient заменяет собой интерфейс, просто перечисляя список доступных функций внутри конструктора, а его реализацией является этот код FoodMenuClient(fetchFoods = {}, createFood = {})

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

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

Для наглядности вот сразу тот же пример через интерфейс:
(Лучше не использовать анонимный объект, а дать реализации свое имя, чтобы его было видно в логах)

interface FoodMenuClient {
  
    fun fetchFoods(
        categoryId: String,
        callback: (Loadable<List<Food>, Error>) -> Unit
    )

    fun createFood(
        food: FoodDTO,
        categoryId: String,
        callback: (Loadable<Food, Error>) -> Unit
    )
) {
    companion object {
    	@JvmStatic
    	fun live() = object : FoodMenuClient {
            override fun fetchFoods(
                categoryId: String,
                callback: (Loadable<List<Food>, Error>) -> Unit
            ) {}

            override fun createFood(
                food: FoodDTO,
                categoryId: String,
                callback: (Loadable<Food, Error>) -> Unit
            ) {}
        }
    }
}

3) Callback
Вероятно ты использовал его чисто для статьи, но тем, кто только учится, лучше сразу подсветить, что callback: (Loadable<Food, Error>) -> Unit стоит заменить на возвращаемый Flow<Loadable<Food, Error>>

4) MVI, UDF и потоки данных

  val foods get() = _foods.asStateFlow()
  private val _foods = MutableStateFlow<List<Food>?>(null)

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

Лучше использовать .stateIn(...) и выстраивать не прерываемые потоки.
Как пример, если бы client.fetchFoods возвращал Flow:

class FoodMenuViewModel(
  val foodMenuListState: LazyListState,
  private val client: FoodMenuClient = FoodMenuClient.live(),
  private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
) : ViewModel() {

  private val selectedCategoryId = MutableSharedFlow<String>(replay = 1)

  // на Ui when по Loadable, чтобы отобразить соответствующее значение
  val fetchFoodState = selectedCategoryId
    .flatMapLatest { id -> client.fetchFoods(id) }
    .onEach {
      if (it is Loadable.Success) foodMenuListState.animateScrollToItem(0)
      it.failure?.let { /* Обработка ошибки логгирование */ }
    }
    .stateIn(viewModelScope + dispatchers.io(), SharingStarted.Eagerly, Loadable.Idle)

  fun onSelectedCategory(categoryId: String) {
    selectedCategoryId.tryEmit(categoryId)
  }
}

На UI по хорошему отдавать вообще только один готовый стейт, который собирается подобным образом, с использованием combine, transform, map, flatMap, fold и т.д.

Надеюсь коммент будет полезен)

И все же я никак не могу найти время на реализацию демо проекта. Все время про это вспоминаю. Много изменений в жизни, которые совершенно не оставляют на это времени. Возможно когда-то, но не сейчас...

Обработка по сообщению это очень странное решение. И опасное, потому что текста ошибок могут меняться как угодно. Это не часть интерфейса взаимодействия с библиотекой.

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

Приемлемый вариант, это одно кастомное исключение, которое внутри содержит код ошибки. И по этому коду внешние разработчики должны обрабатывать эти исключения. А коды должны быть зафиксированы в документации, чтобы внешние разработчики их хорошо понимали. Как пример, коды REST ответов. 404 это код ошибки, который знают все. Но ни кто не завязывается на текст Not found.

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

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

Хотелось бы верить, что у человечества были рациональные причины, но верится с трудом) Можете загуглить первые чемоданы от луи виттон, они так и просят приделать к ним не менее маленькие колеса)

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

Много вопросов в одном комменте) Давайте по порядку)

Entity

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

Я предлагаю оперировать только понятием структура данных, которая при этом не является ядром системы. Структуры данных изменчивы и не постоянны. А вот правила работы со структурами, являются более стабильными частями, которые меняются реже. Наглядным примером является мак. Была одна система с сущностями в виде бигмака и прочего. Теперь это другая система с теми же сущностями и правилами, но структуры данных оказались изменены. Терминалы, стойка выдачи заказа, кухня и прочее никак не изменились. Только бигмака больше нету. Есть что-то похожее, с измененным названием и составом. Но правила работы с "этим" не изменились. Поэтому entity не является ядром системы и системы не стоит проектировать вокруг них.

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

Слабый архитектурный контроль

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

Ультимативные заявления

Я рад, что они царапают глаза) Именно этого эффекта я и пытался добиться)
Они являются ультимативными, чтобы уменьшить возможность для споров без аргументов. А если у вас есть весомые аргументы того, что что-то можно сделать по другому, в каких-то случаях, то вы уже являетесь опытным проектировщиком, который в состоянии сделать хорошую архитектуру для этих конкретных случаев. И тут я уже буду рад выслушать эти аргументы, вместе спроецировать их на абстрактную систему, которая опишет большинство случаев, и внести правки в статью и схему. Так что если есть такие аргументы и предложения по улучшению, буду очень раз их услышать или даже созвониться и обсудить)

Разбиение на модули

К сожалению, я полностью с вами согласен в этом вопросе. Обычно говорят как правильно разбить, а не о том зачем. Если вдуматься в вопрос "зачем", то мы получим примерно такой ответ: "выделение частей программы в отдельные самостоятельные части, для облегчения восприятия кода и улучшения контроля над сложностью". Отличная формулировка благих целей. Вот только: "Для моего маленького проекта, это совсем не нужно, поэтому я буду делать все в монолите. Я же не бигтех)"

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

Чтобы пропустить этот этап "разбиения по контексту хоть как-нибудь", я и описываю сразу следующую цель модуляризации, а именно сокращение нагрузки на сборщик.

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

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

Мне не были знакомы принципы FSD, так что предки разные) Постараюсь подробнее почитать и найти сходства, а пока отвечу так.

Основное заимствование для статьи это шаблон mvvm и принцип разделения на слои из чистой архитектуры. При этом он дополнительно переосмыслен во фрактальное представление зон ответственности. Все остальное описано и спроектировано без дополнительных источников или ссылок на что-либо)

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

1

Information

Rating
2,375-th
Location
Москва, Москва и Московская обл., Россия
Date of birth
Registered
Activity

Specialization

Mobile Application Developer, Software Architect
Lead
Java
Kotlin
Android development
Clean Architecture
DDD
Designing application architecture
OOP
JavaScript
C#
Scrum