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 :
| Framework | Langage | Async | Typage | OpenAPI | Écosystème |
|---|---|---|---|---|---|
| FastAPI | Python | async/await natif | Modèles Pydantic | Auto-généré | Excellent pour la fintech |
| Express.js | TypeScript | Callback/Promise | Optionnel (TS) | Manuel (Swagger) | Vaste mais fragmenté |
| Go (Gin/Fiber) | Go | Goroutines | Compilation | Manuel | En croissance |
| Rust (Actix) | Rust | Tokio async | Compilation | Manuel | Petit |
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
/docspour les tests - La documentation ReDoc sur
/redocpour 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é | SQLite | PostgreSQL |
|---|---|---|
| Temps d'installation | Zéro (fichier unique) | 15-30 minutes |
| Configuration | Aucune | Réglage nécessaire |
| Sauvegarde | Copier un fichier | pg_dump + restauration |
| Lectures en mode WAL | Concurrentes | Concurrentes |
| Écritures/seconde | ~1 000 (mode WAL) | ~10 000+ |
| Déploiement | Pas de processus séparé | Serveur séparé |
| Coût | 0 $ | 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 :
| Framework | Taille du bundle | Réactivité | Courbe d'apprentissage | Écosystème |
|---|---|---|---|---|
| SolidJS | ~7 Ko | Fine, sans DOM virtuel | Modérée | En croissance |
| React | ~40 Ko | Diffing du DOM virtuel | Faible (répandu) | Massif |
| Svelte 5 | ~5 Ko (compilé) | Basée sur le compilateur | Faible | En 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âche | Déclencheur | SLA |
|---|---|---|
| Livraison de webhooks | Changement de statut de paiement | < 5 secondes |
| Réessai de webhook (backoff exponentiel) | Livraison précédente échouée | 5s, 30s, 5m, 30m, 2h, 24h |
| Réconciliation des paiements | Planification quotidienne | Une fois par jour |
| Calcul des règlements | Règlement fournisseur reçu | < 1 heure |
| Vérification de santé des fournisseurs | Périodique | Toutes les 60 secondes |
| Alertes de rotation des identifiants | Approche de l'expiration | Vé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 :
- 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.
- 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.
- 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.