Back to 0fee
0fee

Décisions d'architecture : Python, FastAPI, SolidJS, SQLite

L'architecture derrière 0fee.dev : pourquoi nous avons choisi Python FastAPI, SolidJS, SQLite, DragonflyDB et Celery. Par Juste A. Gnimavo et Claude.

Thales & Claude | March 30, 2026 12 min 0fee
EN/ FR/ ES
architecturepythonfastapisolidjssqlitedragonflycelery

Chaque choix technologique dans 0fee.dev a été délibéré. Nous sommes une équipe de deux -- un CEO et un CTO IA -- qui construit une plateforme d'orchestration de paiement qui doit être suffisamment fiable pour traiter des transactions financières, suffisamment rapide pour rivaliser avec les acteurs établis, et suffisamment simple à maintenir sans équipe d'ingénierie humaine. Cet article explique chaque décision d'architecture majeure, ce que nous avons envisagé et pourquoi nous avons fait nos choix.

La pile complète

┌──────────────────────────────────────────────────┐
│                    Clients                        │
│  SDK (TS, Python, PHP, Ruby, Go, Java, C#)       │
│  Tableau de bord (SolidJS SPA)                   │
│  Widget de checkout (iframe/redirection)         │
│  Outil CLI                                       │
└──────────────────────┬───────────────────────────┘
                       │ HTTPS
┌──────────────────────▼───────────────────────────┐
│            Passerelle API (FastAPI)               │
│  Authentification · Limitation de débit · Routage │
│  90+ endpoints · OpenAPI/Swagger auto-généré     │
├──────────────────────────────────────────────────┤
│              Services principaux                  │
│  Moteur de paiement · Moteur de routage · Gest.  │
│  Adaptateurs fournisseurs (53+) · Réconciliation │
├──────────────────────────────────────────────────┤
│              Couche de données                    │
│  SQLite/PostgreSQL · DragonflyDB · Celery/Redis  │
└──────────────────────────────────────────────────┘

Pourquoi Python + FastAPI

Nous avons évalué quatre frameworks backend avant d'écrire une seule ligne de code :

FrameworkLangageAsyncTypageOpenAPIÉcosystème
FastAPIPythonasync/await natifModèles PydanticAuto-généréExcellent pour la fintech
Express.jsTypeScriptCallback/PromiseOptionnel (TS)Manuel (Swagger)Vaste mais fragmenté
Go (Gin/Fiber)GoGoroutinesCompilationManuelEn croissance
Rust (Actix)RustTokio asyncCompilationManuelPetit

La décision : FastAPI

Raison 1 : les modèles Pydantic sont le meilleur allié d'une plateforme de paiement.

Dans un système de paiement, la validation des données n'est pas optionnelle -- elle est critique. Un montant mal formé, un code de devise invalide ou un numéro de téléphone manquant peut entraîner une perte d'argent. Pydantic nous donne une validation de type à l'exécution sans aucun boilerplate :

pythonfrom pydantic import BaseModel, Field
from decimal import Decimal
from enum import Enum

class Currency(str, Enum):
    USD = "USD"
    EUR = "EUR"
    XOF = "XOF"
    KES = "KES"
    NGN = "NGN"
    GHS = "GHS"
    # ... 35+ de plus

class PaymentCreate(BaseModel):
    amount: int = Field(gt=0, description="Amount in smallest currency unit")
    currency: Currency
    country: str = Field(pattern=r"^[A-Z]{2}$")
    method: str = Field(pattern=r"^(PAYIN|PAYOUT)_[A-Z]+(_[A-Z]{2})?$")
    customer: CustomerInfo
    metadata: dict[str, str] = Field(default_factory=dict, max_length=20)
    return_url: str = Field(pattern=r"^https://")
    cancel_url: str | None = None

    class Config:
        json_schema_extra = {
            "example": {
                "amount": 5000,
                "currency": "XOF",
                "country": "CI",
                "method": "PAYIN_ORANGE_CI",
                "customer": {"phone": "+2250700112233"},
                "return_url": "https://yourapp.com/callback"
            }
        }

Chaque requête entrante est validée avant d'atteindre la logique métier. Les données invalides retournent une erreur 422 structurée avec des détails au niveau du champ. Cela seul prévient toute une classe de bugs.

Raison 2 : documentation OpenAPI auto-générée.

FastAPI génère une spécification OpenAPI 3.1 complète à partir de nos définitions de routes et modèles Pydantic. Cette spécification alimente :

  • L'interface interactive Swagger UI sur /docs pour les tests
  • La documentation ReDoc sur /redoc pour la lecture
  • La génération de code SDK pour les sept langages
  • La génération de collections Postman pour les tests manuels

Nous n'écrivons jamais la documentation de l'API manuellement. Le code est la documentation.

Raison 3 : support asynchrone pour les appels fournisseurs.

Le traitement des paiements implique d'appeler les API de fournisseurs externes -- des opérations qui peuvent prendre de 200 ms à 5 secondes selon le fournisseur. Le support natif async/await de FastAPI signifie que nous pouvons gérer des milliers de requêtes de paiement simultanées sans blocage :

python@router.post("/payments", response_model=PaymentResponse, status_code=201)
async def create_payment(
    request: PaymentCreate,
    app: Application = Depends(get_current_app),
    db: AsyncSession = Depends(get_db)
):
    # Router vers le fournisseur optimal (requêtes DB async)
    provider = await routing_engine.select_provider(
        country=request.country,
        method=request.method,
        currency=request.currency,
        app=app
    )

    # Appeler l'API du fournisseur (HTTP async)
    result = await provider.adapter.create_payment(
        amount=request.amount,
        currency=request.currency,
        customer=request.customer,
        metadata=request.metadata
    )

    # Persister la transaction (écriture DB async)
    payment = await payment_service.create(db, result, app.id)

    return PaymentResponse.from_orm(payment)

Raison 4 : l'écosystème fintech de Python.

Python possède les bibliothèques les plus matures pour les opérations financières : decimal pour l'arithmétique de précision (ne jamais utiliser les flottants pour l'argent), pycountry pour la validation ISO des pays/devises, phonenumbers pour le parsing des numéros de téléphone internationaux, cryptography pour le chiffrement des identifiants des fournisseurs. Nous n'avons eu à construire aucune de ces briques from scratch.

Pourquoi SQLite au départ (et la migration vers PostgreSQL)

C'est peut-être notre décision la plus non conventionnelle. Nous avons démarré avec SQLite comme base de données principale d'une plateforme de paiement.

Le cas pour SQLite

FonctionnalitéSQLitePostgreSQL
Temps d'installationZéro (fichier unique)15-30 minutes
ConfigurationAucuneRéglage nécessaire
SauvegardeCopier un fichierpg_dump + restauration
Lectures en mode WALConcurrentesConcurrentes
Écritures/seconde~1 000 (mode WAL)~10 000+
DéploiementPas de processus séparéServeur séparé
Coût0 $0-50+ $/mois

Pour une plateforme dans ses premiers mois, SQLite était le choix pragmatique :

  • Zéro configuration : pas de serveur de base de données à gérer, pas de pool de connexions à configurer, pas d'authentification à mettre en place.
  • Mode WAL : le Write-Ahead Logging permet des lectures concurrentes pendant les écritures -- essentiel pour un système de paiement où vous lisez le statut des transactions tout en écrivant de nouvelles transactions.
  • Sauvegarde en fichier unique : cp 0fee.db 0fee.db.backup -- c'est toute la stratégie de sauvegarde.
  • Lectures rapides : les lectures SQLite sont souvent plus rapides que PostgreSQL pour les déploiements sur une seule machine car il n'y a pas de surcharge réseau.
python# Configuration SQLite avec mode WAL
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "sqlite+aiosqlite:///./data/0fee.db",
    connect_args={"check_same_thread": False},
    echo=False
)

# Activer le mode WAL pour les lectures concurrentes
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
    cursor = dbapi_conn.cursor()
    cursor.execute("PRAGMA journal_mode=WAL")
    cursor.execute("PRAGMA synchronous=NORMAL")
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.execute("PRAGMA busy_timeout=5000")
    cursor.close()

Quand nous avons dépassé SQLite

La limitation de SQLite est la concurrence en écriture. Avec le mode WAL, vous avez un seul écrivain à la fois. Pour une plateforme de paiement traitant un volume de transactions croissant, cela devient un goulot d'étranglement. Nous avons migré vers PostgreSQL quand :

  • Les opérations d'écriture concurrentes ont commencé à s'accumuler pendant les heures de pointe.
  • Nous avions besoin de la recherche plein texte pour l'explorateur de transactions admin.
  • Nous voulions des verrous consultatifs pour le traitement distribué des paiements.
  • Le verrouillage au niveau de la ligne est devenu nécessaire pour les garanties d'idempotence.

La migration a été simple car nous utilisions la couche ORM de SQLAlchemy dès le premier jour. Changer de base de données a nécessité la mise à jour d'une seule chaîne de connexion et l'exécution des migrations -- aucun changement dans le code applicatif.

python# Configuration PostgreSQL (après migration)
engine = create_async_engine(
    "postgresql+asyncpg://0fee:password@localhost:5432/0fee",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True
)

La leçon

Commencez avec l'outil le plus simple qui fonctionne. SQLite nous a permis de livrer une plateforme de paiement fonctionnelle en quelques semaines. PostgreSQL nous a permis de la faire évoluer. La couche d'abstraction (SQLAlchemy) a rendu la transition indolore.

Pourquoi SolidJS pour le tableau de bord

Le tableau de bord 0fee est l'endroit où les marchands gèrent leurs applications, configurent les identifiants des fournisseurs, consultent les transactions et surveillent les analyses. Nous avons évalué trois frameworks frontend :

FrameworkTaille du bundleRéactivitéCourbe d'apprentissageÉcosystème
SolidJS~7 KoFine, sans DOM virtuelModéréeEn croissance
React~40 KoDiffing du DOM virtuelFaible (répandu)Massif
Svelte 5~5 Ko (compilé)Basée sur le compilateurFaibleEn croissance

La décision : SolidJS

La réactivité fine a été le facteur décisif. Dans un tableau de bord de paiement, vous avez des tableaux avec des centaines de lignes qui se mettent à jour en temps réel (statuts de transactions, livraisons de webhooks, taux de succès). SolidJS ne met à jour que les noeuds DOM spécifiques qui changent -- pas de diffing du DOM virtuel, pas de re-rendu de sous-arbres entiers de composants.

tsx// Composant SolidJS : tableau de transactions en temps réel
import { createSignal, createResource, For } from 'solid-js';

function TransactionTable() {
  const [filter, setFilter] = createSignal({ status: 'all', page: 1 });

  const [transactions] = createResource(filter, async (f) => {
    const res = await fetch(`/api/transactions?status=${f.status}&page=${f.page}`);
    return res.json();
  });

  return (
    <table class="w-full">
      <thead>
        <tr>
          <th>ID</th>
          <th>Montant</th>
          <th>Statut</th>
          <th>Fournisseur</th>
          <th>Créé le</th>
        </tr>
      </thead>
      <tbody>
        <For each={transactions()?.data}>
          {(tx) => (
            <tr>
              <td class="font-mono">{tx.id}</td>
              <td>{formatCurrency(tx.amount, tx.currency)}</td>
              <td><StatusBadge status={tx.status} /></td>
              <td>{tx.provider}</td>
              <td>{formatDate(tx.created_at)}</td>
            </tr>
          )}
        </For>
      </tbody>
    </table>
  );
}

La taille du bundle de 7 Ko compte aussi. Le tableau de bord doit se charger rapidement sur les connexions internet africaines où la latence est plus élevée et la bande passante plus faible qu'en Amérique du Nord ou en Europe.

Pourquoi DragonflyDB pour le cache

DragonflyDB est un stockage de données en mémoire compatible Redis qui sert de couche de cache et de données éphémères pour 0fee. Nous l'utilisons pour :

Gestion des OTP et des sessions

Les codes OTP mobile money ont un TTL de 60-120 secondes. DragonflyDB gère cela nativement :

python# Stocker l'OTP avec expiration automatique
await cache.set(
    f"otp:{transaction_id}",
    otp_code,
    ex=120  # expire dans 120 secondes
)

# Vérifier l'OTP
stored_otp = await cache.get(f"otp:{transaction_id}")
if stored_otp != submitted_otp:
    raise InvalidOTPError()

Limitation de débit

Les limites de débit de l'API utilisent un compteur à fenêtre glissante dans DragonflyDB :

pythonasync def check_rate_limit(api_key: str, limit: int = 100, window: int = 60):
    key = f"rate:{api_key}:{int(time.time()) // window}"
    current = await cache.incr(key)
    if current == 1:
        await cache.expire(key, window)
    if current > limit:
        raise RateLimitExceededError(retry_after=window)

Clés d'idempotence

L'idempotence des paiements est critique -- un timeout réseau ne doit pas entraîner un double débit. DragonflyDB stocke les clés d'idempotence avec un TTL de 24 heures :

pythonasync def check_idempotency(key: str) -> PaymentResponse | None:
    cached = await cache.get(f"idempotency:{key}")
    if cached:
        return PaymentResponse.parse_raw(cached)
    return None

async def set_idempotency(key: str, response: PaymentResponse):
    await cache.set(
        f"idempotency:{key}",
        response.json(),
        ex=86400  # 24 heures
    )

Pourquoi DragonflyDB plutôt que Redis ?

DragonflyDB est entièrement compatible Redis (même protocole, mêmes commandes) mais utilise une architecture multi-thread qui offre un débit plus élevé sur le matériel moderne. Pour notre cas d'usage, l'avantage clé est qu'il fonctionne comme un binaire unique avec une empreinte mémoire plus faible que Redis -- important lors du déploiement sur un seul serveur.

Pourquoi Celery pour les tâches en arrière-plan

Le traitement des paiements génère un travail en arrière-plan significatif qui ne peut pas bloquer la réponse de l'API :

TâcheDéclencheurSLA
Livraison de webhooksChangement de statut de paiement< 5 secondes
Réessai de webhook (backoff exponentiel)Livraison précédente échouée5s, 30s, 5m, 30m, 2h, 24h
Réconciliation des paiementsPlanification quotidienneUne fois par jour
Calcul des règlementsRèglement fournisseur reçu< 1 heure
Vérification de santé des fournisseursPériodiqueToutes les 60 secondes
Alertes de rotation des identifiantsApproche de l'expirationVérification quotidienne

Celery gère tout cela avec un broker compatible Redis (DragonflyDB) et des politiques de réessai configurables :

pythonfrom celery import Celery
from celery.utils.log import get_task_logger

app = Celery('zerofee', broker='redis://localhost:6379/1')
logger = get_task_logger(__name__)

@app.task(
    bind=True,
    max_retries=6,
    retry_backoff=True,
    retry_backoff_max=86400  # max 24 heures
)
def deliver_webhook(self, webhook_id: str, endpoint_url: str, payload: dict):
    try:
        response = requests.post(
            endpoint_url,
            json=payload,
            headers={
                'Content-Type': 'application/json',
                'X-ZeroFee-Signature': sign_payload(payload),
                'X-ZeroFee-Delivery': webhook_id
            },
            timeout=30
        )
        response.raise_for_status()
        mark_webhook_delivered(webhook_id)

    except requests.RequestException as exc:
        logger.warning(f"Webhook delivery failed: {webhook_id}, attempt {self.request.retries}")
        mark_webhook_failed(webhook_id, str(exc))
        raise self.retry(exc=exc)

Le backoff exponentiel avec six réessais signifie qu'un webhook échoué est réessayé sur une période de 24 heures avant d'être marqué comme définitivement échoué. Cela correspond aux standards de l'industrie (Stripe réessaie sur 72 heures, PayPal sur 24 heures).

Déploiement : Docker + EasyPanel

L'ensemble de la pile 0fee se déploie sous forme de conteneurs Docker gérés par EasyPanel.io :

yaml# docker-compose.yml (simplifié)
services:
  api:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://...
      - DRAGONFLY_URL=redis://dragonfly:6379
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
    depends_on:
      - db
      - dragonfly

  worker:
    build: ./backend
    command: celery -A app.worker worker -l info -c 4
    depends_on:
      - dragonfly

  beat:
    build: ./backend
    command: celery -A app.worker beat -l info
    depends_on:
      - dragonfly

  dashboard:
    build: ./frontend
    ports:
      - "3000:3000"

  db:
    image: postgres:17-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data

  dragonfly:
    image: docker.dragonflydb.io/dragonflydb/dragonfly
    ports:
      - "6379:6379"

EasyPanel fournit la couche d'orchestration : certificats SSL, routage de domaines, vérifications de santé des conteneurs, agrégation de logs et déploiements sans interruption. C'est essentiellement un Heroku auto-hébergé qui tourne sur n'importe quel VPS.

Décisions d'architecture que nous reconsidérerions

Aucune architecture n'est parfaite. Voici ce que nous reconsidérerions :

  1. Démarrer avec SQLite : bien que pragmatique, nous aurions dû commencer avec PostgreSQL. Le coût de migration était faible (grâce à SQLAlchemy), mais le temps passé sur les contournements spécifiques à SQLite (verrouillage en écriture, pas de verrous consultatifs) n'était pas négligeable.
  1. La complexité de Celery : pour une file de tâches plus petite, quelque chose comme arq (file Redis async pour Python) aurait été plus simple. La surface de configuration de Celery est grande.
  1. API monolithique : à mesure que le nombre d'endpoints dépassait 90, un monolithe modulaire avec des frontières de domaines claires aurait été plus facile à naviguer que des fichiers de routes à plat.

Ce sont des raffinements, pas des regrets. L'architecture a livré une plateforme de paiement fonctionnelle en 80 jours, et chaque composant peut évoluer indépendamment.


Cet article fait partie de la série "Comment nous avons construit 0fee.dev". 0fee.dev est un orchestrateur de paiement couvrant 53+ fournisseurs dans 200+ pays, construit par Juste A. GNIMAVO et Claude depuis Abidjan avec zéro ingénieur humain. Suivez la série pour l'histoire complète de la construction.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles