Concevoir une app de tontine : les 5 pièges qui coûtent cher en production
Retour d'expérience sur l'architecture de SmartBudget Africa : gestion de l'argent, connexion instable, mobile money. Ce que j'aurais aimé savoir avant d'écrire la première ligne.
La tontine est un produit d'épargne collective omniprésent en Afrique de l'Ouest et centrale. Le principe est simple : un groupe cotise à intervalle régulier, et à chaque tour un membre encaisse le pot. Modéliser ça dans une application semble trivial pendant deux semaines. Puis un membre paie en retard, un autre quitte le groupe au milieu du cycle, et le solde affiché ne correspond plus à ce qui est réellement en caisse.
Voici les cinq décisions techniques que je referais différemment sur SmartBudget Africa, et pourquoi.
1. Ne jamais stocker l'argent en nombre flottant
C'est le classique, et pourtant il traîne encore dans beaucoup de projets. Un montant en FCFA stocké en DOUBLE finit par produire des écarts de quelques centimes qui s'accumulent sur des milliers de transactions. En production, ça se traduit par un solde de groupe qui ne tombe jamais juste.
// À bannir
private double montant;
// La bonne façon : entier de plus petite unité, ou BigDecimal avec échelle fixe
@Column(nullable = false)
private long montantCentimes; // FCFA n'a pas de décimale, mais garder la marge
// Si BigDecimal, imposer l'échelle dès la construction
private static final int ECHELLE = 2;
public static BigDecimal money(String v) {
return new BigDecimal(v).setScale(ECHELLE, RoundingMode.HALF_EVEN);
}Règle simple : l'argent est un entier, l'arrondi est une décision métier explicite, jamais un effet de bord du langage.
2. Le solde est un calcul, pas une colonne
La tentation est forte de garder une colonne solde_actuel et de faire des UPDATE. C'est rapide à lire, et c'est une bombe à retardement : à la première requête concurrente ou au premier crash entre deux écritures, le solde diverge de l'historique et plus personne ne sait quelle valeur est vraie.
L'alternative est un registre en append-only : chaque mouvement est une ligne immuable, le solde est la somme. On garde une colonne dénormalisée si la performance l'exige, mais elle devient un cache — reconstructible à tout moment depuis le registre, et vérifiable par un job de réconciliation nocturne.
CREATE TABLE mouvement (
id BIGSERIAL PRIMARY KEY,
groupe_id BIGINT NOT NULL REFERENCES groupe(id),
membre_id BIGINT NOT NULL REFERENCES membre(id),
montant BIGINT NOT NULL, -- signé : + cotisation, - versement
type TEXT NOT NULL,
reference TEXT NOT NULL UNIQUE, -- idempotence, voir point 3
cree_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Aucun UPDATE, aucun DELETE. Une erreur se corrige par un mouvement inverse.3. L'idempotence n'est pas optionnelle sur un réseau africain
Dans un contexte où la connexion mobile coupe au milieu d'une requête, l'utilisateur retape. Sans clé d'idempotence, il cotise deux fois. Le callback du fournisseur mobile money, lui, peut arriver trois fois pour la même transaction — c'est le comportement normal d'un webhook, pas un bug.
Chaque écriture qui touche à l'argent doit porter une référence unique fournie par le client, avec une contrainte UNIQUE en base. Un doublon renvoie le résultat de la première exécution, pas une erreur.
@Transactional
public Mouvement cotiser(CotisationCommand cmd) {
return mouvementRepo.findByReference(cmd.reference())
.orElseGet(() -> mouvementRepo.save(Mouvement.cotisation(cmd)));
}
// La contrainte UNIQUE en base reste le garde-fou final :
// entre le findBy et le save, deux threads peuvent passer.
// Attraper DataIntegrityViolationException et relire.4. Concevoir hors-ligne d'abord, pas en rattrapage
Ajouter le mode hors-ligne après coup implique de réécrire la couche de données. Sur mobile, la file d'attente locale des opérations doit exister dès le premier écran : l'utilisateur agit, l'opération part dans une file persistée, la synchronisation se fait quand le réseau revient.
Cela impose une contrainte saine sur le backend : toutes les opérations doivent être rejouables dans le désordre. Ce qui ramène au point 3.
5. Les rôles se vérifient côté serveur, toujours
Masquer un bouton dans l'interface n'est pas un contrôle d'accès. Dans une tontine, le trésorier valide les versements ; si l'autorisation n'est vérifiée que dans le composant React, n'importe quel membre peut appeler l'endpoint directement.
@PreAuthorize("@groupeSecurity.estTresorier(#groupeId, authentication)")
@PostMapping("/groupes/{groupeId}/versements")
public VersementDto valider(@PathVariable Long groupeId, @RequestBody @Valid VersementRequest req) { ... }Ce que j'en retiens
Aucun de ces points n'est une subtilité d'architecte. Ce sont des invariants : argent en entier, historique immuable, écritures idempotentes, hors-ligne natif, autorisation serveur. Les poser avant d'écrire le premier endpoint coûte une journée. Les rattraper après six mois de production coûte une réécriture — et la confiance des utilisateurs, qui elle ne se refactorise pas.