Créer une app Android e-commerce avec l’architecture hexagonale (Partie 2 - Exemple)

Nous avons réalisé l’application e-commerce d’une enseigne de grands magasins. Nous avons utilisé l’architecture hexagonale. Cet article est la suite d’un autre qui présente le contexte et les choix du projet. Vous pouvez le retrouver ici.

Exemple de projet utilisant l’architecture hexagonale

Nous avons développé un projet exemple pour illustrer notre propos. Il ne s’agit pas d’une application e-commerce. En revanche elle reprend la principale contrainte que nous avions : gérer un backend REST et un autre GraphQL. Nous avons utilisé l’API rick et morty.

Fonctionnalités et architecture

L’application cible présente une vue master / detail :

  1. La vue Master
    a. Affiche la liste des personnages de la série rick et morty
    b. Les données sont récupérées en GraphQL
  2. La vue de détail
    a. S’ouvre au clic sur un élément de la liste
    b. Les données sont récupérées en REST

diagrammes-master-detail.drawio

Structure du projet

structure

On reprend le découpage énoncé précédemment :

  • commons_android contient le code partagé qui utilise le SDK Android. Par exemple : la définition des styles
  • commons_io contient les utilitaires utilisés pour faire de l’I/O. Par exemple : les wrappers de coroutines
  • commons_lang contient les utilitaires qui viennent enrichir Kotlin. Par exemple : les wrappers génériques de résultats
  • core_characters contient tout le code non UI utilisé pour la récupération des personnages
  • feature_home correspond à la vue master
  • feature_characterdetail correspond à la vue detail

Focus sur le premier besoin : récupérer la liste des personnages

Le flux de récupération de données est le suivant :

diagrammes-flow.drawio

Le déclenchement de la récupération des personnages part du HomeFragment. Il appelle son ViewModel :

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    viewModel.fetchCharacters()
}

fetchCharacters déclenche une coroutine qui est liée au cycle de vie du ViewModel (viewModelScope) :

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val charactersInputAdapter: CharactersInputAdapter,
) : ViewModel() {
    var characters = mutableStateOf<Resource<List<Character>>>(
        Resource.Idle()
    )
        private set

    fun fetchCharacters() = viewModelScope.launch {
        characters.value = charactersInputAdapter.getCharacters()
    }
}

La coroutine demande au CharactersInputAdapter, injecté par constructeur, de récupérer les données voulues. À réception, elles sont déjà encapsulées dans une Resource. Cette classe indique le statut de la requête (Idle / Loading / Success / Error). Puis, le résultat est stocké dans characters. C’est une structure de données spéciale, qui est observée par la vue (le fragment). Quand characters change, elle le détecte et se met à jour. C’est une alternative au LiveData quand on utilise Jetpack Compose.
Il est important de faire le minimum de traitement dans une coroutine lancée dans le viewModelScope. En effet, elles sont dispatchées par défaut sur le Main Thread.

Nous faisons donc en sorte que CharactersInputAdapter exécute ses fonctions sur un worker thread :

class CharactersInputAdapter @Inject constructor(
    override val adapterScope: InputAdapterScope,
    private val getCharactersUseCase: GetCharactersUseCase,
) : InputAdapter {

    suspend fun getCharacters() = withContext(adapterScope.coroutineContext) {
        getCharactersUseCase.getCharacters()
    }
}

On retrouve ici InputAdapterScope, qui wrap un CoroutineContext. C’est en l’utilisant qu’on détermine le thread utilisé par la coroutine.

getCharacters déclenche une nouvelle coroutine via withContext. Elle est exécutée sur le contexte fourni. On fait appel à GetCharactersUseCase. À ce moment, nous sommes prêts à entrer dans l’hexagone :

diagrammes-archi-hexa-ici.drawio

Dans notre application e-commerce, l’input adapter avait pour rôle :

  • de combiner les retours des UseCases
  • d’éventuellement créer des structures de données pour la vue
    On peut imaginer les UseCases comme les endpoints REST d’un serveur. L’Input Adapter comme un client qui les consomme.

Ici, nous voulions extraire un maximum de logique du ViewModel. En effet, il implémentait déjà les mécanismes de Mutual Exclusion et gérait l’état de la vue.

GetCharactersUseCase est une interface :

interface GetCharactersUseCase {
    suspend fun getCharacters(): Resource<List<Character>>
}

Elle retourne une liste de Character (structure métier) :

data class Character(val id: CharacterId, val name: CharacterName)

Elle est implémentée par un service situé dans l’hexagone :

class CharactersService @Inject constructor(
    private val getCharactersPort: GetCharactersPort,
) : GetCharactersUseCase {
    override suspend fun getCharacters(): Resource<List<Character>> {
        return wrapInResource {
            getCharactersPort.getCharacters()
        }
    }
}

C’est ici que l’on encapsule la donnée dans une Resource.wrapInResource est une fonction personnalisée. Elle renvoie une Resource.Error en cas d’exception dans getCharactersPort.getCharacters(). Elle retourne Resource.Success sinon.

En faisant appel à ce port, on s’apprête à sortir de l’hexagone :

diagrammes-archi-hexa-ici-2.drawio

Enfin, un OutputAdapter GraphQL implémente GetCharactersPort :

class GraphQLCharactersOutputAdapter @Inject constructor(
    override val adapterScopeMain: OutputAdapterScopeMain,
    override val adapterScopeWorker: OutputAdapterScopeWorker,
    private val apolloClient: ApolloClient,
    private val getCharactersQueryFactory: GetCharactersQueryFactory
): OutputAdapter, GetCharactersPort {
    override suspend fun getCharacters() = withContext(adapterScopeWorker.coroutineContext) {
        apolloClient
            .query(getCharactersQueryFactory.create())
            .execute()
            .getDataOrThrow()
            .characters
            ?.results
            ?.filterNotNull()
            ?.map(GetCharactersQuery.Result::mapToCharacter)
            ?: emptyList()
    }
}

Nous avons utilisé Apollo en tant que client GraphQL. Nous avons aussi créé un OutputAdapter, qui cette fois propose un adapterScopeWorker ainsi qu’un adapterScopeMain. C’est parce que certaines opérations ont besoin du Main Thread, comme celles qui utilisent le Context.
Nous avons aussi créé GetCharactersQueryFactory, une Factory de requêtes GraphQL ; toujours dans le but d’inverser les dépendances et de faciliter les tests.

Concrètement, Apollo exécute la requête. La réponse a son propre modèle : GetCharactersQuery.Result. Il est transformé en un Character (du domaine métier) via mapToCharacter.

Focus sur le second besoin : récupérer le détail d’un personnage

L’étape précédente montre que les dépendances au SDK Android et aux SDKs tiers sont localisées. Les responsabilités sont bien séparées, et le découplage est fort. Pour l’implémentation du second besoin, nous allons juste nous concentrer sur l’output adapter. La logique est exactement la même pour toutes les étapes précédentes. C’est l’une des forces de cette architecture.

Voyons plutôt le port à implémenter par l’Output Adapter :

interface GetCharacterDetailPort {
    suspend fun getCharacterDetailWith(characterId: CharacterId): CharacterDetail
}

Cette fois, la structure métier est un CharacterDetail. Nous changeons aussi le fonctionnement pour consommer du REST :

class RESTCharactersOutputAdapter @Inject constructor(
    override val adapterScopeMain: OutputAdapterScopeMain,
    override val adapterScopeWorker: OutputAdapterScopeWorker,
    private val restCharactersApi: RESTCharactersApi,
) : OutputAdapter, GetCharacterDetailPort {
    override suspend fun getCharacterDetailWith(characterId: CharacterId) =
        withContext(adapterScopeWorker.coroutineContext) {
            restCharactersApi
                .getCharacterWith(characterId.toString())
                .toCharacterDetail()
        }
}

On retrouve la même structure de départ de l’Output Adapter. Cette fois cependant on utilise une RESTCharactersApi. Elle est définie pour Retrofit, notre client HTTP pour le REST :

interface RESTCharactersApi {
    @GET("character/{characterId}")
    suspend fun getCharacterWith(@Path("characterId") characterId: String): RESTCharacter
}

L’endpoint consommé retourne un RESTCharacter, transformé ensuite en CharacterDetail via la méthode correspondante :

@Serializable
data class RESTCharacter(
    @SerialName("id")
    val id: Int,
    @SerialName("name")
    val name: String,
    @SerialName("status")
    val status: String,
) {
    fun toCharacterDetail(): CharacterDetail {
        return CharacterDetail(
            id = CharacterId(id.toString()),
            name = CharacterName(name),
            status = CharacterStatus.from(status),
        )
    }
}

Aller plus loin

Ces exemples ne présentaient volontairement que du code non-UI. Référez-vous au projet GitHub pour voir le reste.
En revanche, vous avez normalement remarqué que :
Il est facile de développer sans base de données ni serveur si ce n’est pas prêt. Il suffit de mock dans l’Output Adapter.
Les changements d’un côté ou de l’autre de l’hexagone n’impactent pas l’ensemble de l’application.

Bilan

Découplage

Le découplage est très fort. La structure du projet est claire et homogène. Les responsabilités sont clairement définies. Ce sont des points clés pour un projet qui doit passer à l’échelle et impliquer plusieurs développeurs.

Tests

Le point précédent implique aussi des tests facilités du ViewModel jusqu’au domain. Pour ces parties, tout est testable unitairement. Pour le reste, UI et Output Adapter, cela passe souvent par des tests d’intégration.

Globalement, nous avons senti qu’il était bien plus facile de tester dans ce contexte par rapport à un MVVM + repository simple.

Parallélisation du travail

La structuration v2 et dans une plus grande mesure la séparation en de nombreux modules a rendu une parallélisation du travail efficace. Nous étions au maximum 4 développeurs sur Android et nous avions rarement des conflits / interdépendances.

Limitations

Cette architecture et notre implémentation ne sont pas parfaites. Tout d’abord, elle produit beaucoup de fichiers. C’est une conséquence du découplage fort dans le projet. Ensuite, les différentes couches d’adapters peuvent produire une impression de perte de temps. Parfois, des traitements simples produisent des passe-plats entre les couches. Le choix de Jetpack Navigation est aussi discutable. Peut-être qu’un module common pour encapsuler une navigation custom aurait été un choix judicieux. La courbe d’apprentissage est aussi un frein potentiel pour son adoption par l’équipe. Enfin, Dagger passe par des annotations et de la génération de code pour construire son graphe de dépendances. Cela ralentit fortement le build.

Conclusion

Parmi le large éventail d’architectures utilisées sur Android, nous nous sommes dirigés vers une approche moins conventionnelle : l’architecture hexagonale. Malgré des facteurs limitants, ce projet a été un succès. Entre autres, nous avons remonté les notes du store à >= 4,3 / 5. Depuis, on nous demande régulièrement s’il est pertinent d’utiliser systématiquement l’architecture hexagonale. Cette même question revient souvent dans les autres communautés (back, front, etc.). Il faut garder en tête qu’une architecture est un outil. Il faut choisir ce qui est en adéquation avec le contexte du projet : sa durée de vie, sa complexité, son évolutivité, les compétences de l'équipe, etc. Votre approche sera plus ou moins facilitée par les compétences et la versatilité de votre équipe.