Skip to content

App architecture

Vadym Yaroshchuk edited this page Jul 24, 2023 · 5 revisions

App Architecture Documentation

Introduction

This document serves as a comprehensive guide to understanding the architecture of our application. The architecture is designed with a focus on maintainability, modularity, and extensibility. We follow the domain-driven design (DDD) approach to ensure that our codebase remains clean and organized. Additionally, we employ the Model-View-Intent (MVI) pattern for managing UI state and handling events. This document will explain the different components of our architecture, their interactions, and the rationale behind their design choices.

Domain-Driven Design (DDD)

Domain-Driven Design is an architectural principle that guides the organization of our codebase around the core domain logic. It emphasizes clear boundaries between different modules and enforces proper validation of value objects. The key aspects of our DDD approach are as follows:

1. Value Object Validation

All value objects are properly validated within the SDK. Additionally, we have separate logic called 'XValidator' (where X is the name of the entity) that reuses the SDK logic for validation. This ensures that our data is always consistent and valid.

2. Feature-Based Modules

Our codebase is divided into feature-based modules. Each feature represents a specific functionality and is independent in all aspects, except for reusing the SDK to avoid boilerplate code. Presently, we have the following features: 'authorization', 'users', 'files', and 'timers'.

Note
We also have common feature that stands for reusing things like 'Strings' and 'AuthorizationFailureMiddleware'. We don't need to copy them for all modules as it's duplication for nothing.

3. Module Structure

Each feature-based module is further divided into smaller modules, following clean architecture rules. The modules include:

  • domain: Contains the business logic and domain models for the feature.
  • data: Handles data-related operations, such as fetching data from APIs or databases.
  • dependencies: Defines Dependency Injection (DI) using Koin for the feature.
  • presentation: Implements the UI using Jetbrains Compose and follows the Model-View-Intent (MVI) pattern for managing UI state and handling events.

4. Repository Contracts

Features that require functionality from other features should declare repository contracts in their respective modules. This way, we avoid direct dependencies on modules providing the desired functionality. We emphasize writing contracts for the required execution rather than duplicating entire repositories.

5. Adapters

To adapt the functionality defined by contracts from other features, we create 'Adapters.' These adapters utilize DI to use the functionality from the desired feature. The goal is to minimize direct dependencies between modules while maintaining the flexibility to adapt features as needed. Adapters are placed in a dedicated module group called 'adapters' within the submodule that calls the feature to be adapted.

import io.timemates.app.feature.users.repositories.UsersRepository as OriginalUsersRepository
import io.timemates.app.feature.timers.repositories.UsersRepository // contract from timers feature

class UsersRepositoryAdapter(private val repo: OriginalUsersRepository) : UsersRepository {...}

Note
Note that we still shouldn't be dependent on specific realization of repository. Only on contract from desired module.

Model-View-Intent (MVI) Architecture

Our UI architecture follows the Model-View-Intent (MVI) pattern, which helps in managing the state of the UI, handling events, and producing effects. The key components of our MVI architecture are as follows:

1. StateMachine Interface

The StateMachine interface represents the MVI architecture and provides methods to manage the UI state, handle events, and emit UI effects.

interface StateMachine<TState : UiState, TEvent : UiEvent, TEffect : UiEffect> : StateStore<TState> {
    val state: StateFlow<TState>
    val effects: ReceiveChannel<TEffect> // effects from Reducer
    fun dispatchEvent(event: TEvent) // events from UI
}

2. Middleware Interface

The Middleware interface represents a component in the MVI architecture responsible for intercepting effects (usually, we use it also to save consistency of screen state when we make something asynchronously as Reducer can have old state to the moment when our asynchronous operation is finished) and performing side effects based on those effects.

interface Middleware<TState : UiState, TEffect : UiEffect> {
    fun onEffect(effect: TEffect, store: StateStore<TState>): TState
}

Async operations example

When we have async operation, we should consider possibility of state inconsistency, so let's consider next example:

class StartAuthorizationReducer(
    private val validateEmail: EmailAddressValidator,
    private val authorizeByEmail: AuthorizeByEmailUseCase,
    private val coroutineScope: CoroutineScope,
) : Reducer<State, Event, Effect> {
    override fun reduce(state: State, event: Event, sendEffect: (Effect) -> Unit): State {
        return when (event) {
            // ...
            Event.OnStartClick -> when (validateEmail.validate(state.email)) {
                EmailAddressValidator.Result.PatternDoesNotMatch ->
                    state.copy(isEmailInvalid = true)
            // ...
                }
            }
        }
    }

    private fun authorizeWithEmail(
        email: String,
        sendEffect: (Effect) -> Unit
    ) {
        coroutineScope.launch {
            when (val result = authorizeByEmail.execute(EmailAddress.createOrThrow(email))) {
                is AuthorizeByEmailUseCase.Result.Success ->
                    sendEffect(Effect.NavigateToConfirmation(result.verificationHash))

                AuthorizeByEmailUseCase.Result.TooManyRequests ->
                    sendEffect(Effect.TooManyAttempts)

                is AuthorizeByEmailUseCase.Result.Failure ->
                    sendEffect(Effect.Failure(result.throwable))
            }
        }
    }
}

For it we're declaring next middleware:

class StartAuthorizationMiddleware : Middleware<State, Effect> {
    override fun onEffect(effect: Effect, store: StateStore<State>): State {
        return when (effect) {
            is Effect.Failure, Effect.TooManyAttempts ->
                store.state.value.copy(isLoading = false)

            else -> store.state.value
        }
    }
}

We consider Middleware as dedicated separated logic to handle correctness of state.

Note
StateStore – is the interface that consists only with one StateFlow that represents current state in reactive way.

3. Reducer Interface

The Reducer interface is responsible for updating the state based on events and triggering effects.

interface Reducer<TState, TEvent, TEffect> {
    fun reduce(
        state: TState,
        event: TEvent,
        sendEffect: (TEffect) -> Unit
    ): TState
}

4. StateStore Interface

The StateStore interface represents a state store that holds the current state of the UI.

interface StateStore<TState> {
    val state: StateFlow<TState>
}

AbstractStateMachine

To simplify the declaration of StateMachine, we provide an AbstractStateMachine abstract class that reduces boilerplate code. Here's an example of how to use it:

class ConfirmAuthorizationStateMachine(
    reducer: ConfirmAuthorizationsReducer,
    middleware: ConfirmAuthorizationMiddleware,
) : AbstractStateMachine<State, Event, Effect>(
    reducer = reducer,
    middlewares = listOf(middleware),
) {
    // ...
}

Navigation

Navigation in our application is implemented in a dedicated module called 'navigation'. This module is dependent on all features that need to be navigable. We use the 'decompose' library under the hood to handle navigation.

Other Modules

In addition to the core features and MVI architecture, our application consists of several other modules that play crucial roles in the overall architecture. These modules are:

1. style-system

The style-system module is responsible for managing design components and app theming using Jetbrains Compose. It ensures consistency in the UI across the entire application. The module contains reusable UI components and styles that can be easily integrated into different features' presentation modules.

Note
There's also preview submodule that stands for checking how components look without running app directly.

2. foundation

The foundation module-group comprises small libraries that solve specific problems. For example, the 'random' module within foundation provides random string generation functionality. These small libraries act as building blocks that can be used across different features, promoting code reusability and maintainability.

3. platforms

The platforms module-group represents the final point of initializing the app on a specific platform, such as Android (using Activities) or JVM (using the main function). This module-group contains platform-specific implementations and acts as an entry point for running the application on different platforms. It helps keep the core logic independent of platform-specific details.

Useful links

Compose

Modularization