Back to 0fee
0fee

Decisiones de arquitectura: Python, FastAPI, SolidJS, SQLite

La arquitectura detrás de 0fee.dev: por qué elegimos Python FastAPI, SolidJS, SQLite, DragonflyDB y Celery. Por Juste A. Gnimavo y Claude.

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

Cada elección tecnológica en 0fee.dev fue deliberada. Somos un equipo de dos -- un CEO y un CTO de IA -- construyendo una plataforma de orquestación de pagos que debe ser lo suficientemente confiable para manejar transacciones financieras, lo suficientemente rápida para competir con actores establecidos y lo suficientemente simple para mantener sin un equipo de ingeniería humano. Este artículo explica cada decisión arquitectónica importante, qué consideramos y por qué elegimos lo que elegimos.

El stack completo

┌──────────────────────────────────────────────────┐
│                   Clientes                        │
│  SDKs (TS, Python, PHP, Ruby, Go, Java, C#)     │
│  Panel de control (SolidJS SPA)                  │
│  Widget de checkout (iframe/redirección)         │
│  Herramienta CLI                                 │
└──────────────────────┬───────────────────────────┘
                       │ HTTPS
┌──────────────────────▼───────────────────────────┐
│              API Gateway (FastAPI)                │
│  Autenticación · Límite de tasa · Enrutamiento   │
│  90+ endpoints · OpenAPI/Swagger autogenerado    │
├──────────────────────────────────────────────────┤
│              Servicios centrales                  │
│  Motor de pagos · Motor de enrutamiento · Gest. webhooks │
│  Adaptadores de proveedores (53+) · Conciliación │
├──────────────────────────────────────────────────┤
│              Capa de datos                        │
│  SQLite/PostgreSQL · DragonflyDB · Celery/Redis  │
└──────────────────────────────────────────────────┘

Por qué Python + FastAPI

Evaluamos cuatro frameworks de backend antes de escribir una sola línea de código:

FrameworkLenguajeAsyncTipado seguroOpenAPIEcosistema
FastAPIPythonAsync/await nativoModelos PydanticAutogeneradoExcelente para fintech
Express.jsTypeScriptCallback/PromiseOpcional (TS)Manual (Swagger)Grande pero fragmentado
Go (Gin/Fiber)GoGoroutinesEn compilaciónManualCreciente
Rust (Actix)RustTokio asyncEn compilaciónManualPequeño

La decisión: FastAPI

Razón 1: Los modelos Pydantic son el mejor amigo de una plataforma de pagos.

En un sistema de pagos, la validación de datos no es opcional -- es crítica. Un monto malformado, un código de moneda inválido o un número de teléfono faltante puede resultar en dinero perdido. Pydantic nos da validación de tipos en tiempo de ejecución sin código adicional:

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+ más

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"
            }
        }

Cada solicitud entrante se valida antes de llegar a la lógica de negocio. Los datos inválidos devuelven un error 422 estructurado con detalles a nivel de campo. Esto solo previene toda una clase de bugs.

Razón 2: Documentación OpenAPI autogenerada.

FastAPI genera una especificación OpenAPI 3.1 completa a partir de nuestras definiciones de rutas y modelos Pydantic. Esta especificación impulsa:

  • UI interactiva de Swagger en /docs para pruebas
  • Documentación ReDoc en /redoc para lectura
  • Generación de código SDK para los siete lenguajes
  • Generación de colección Postman para pruebas manuales

Nunca escribimos documentación de API manualmente. El código es la documentación.

Razón 3: Soporte async para llamadas a proveedores.

El procesamiento de pagos implica llamar a APIs externas de proveedores -- operaciones que pueden tardar de 200ms a 5 segundos dependiendo del proveedor. El soporte nativo de async/await de FastAPI significa que podemos manejar miles de solicitudes de pago concurrentes sin bloquear:

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)
):
    # Enrutar al proveedor óptimo (consultas async a BD)
    provider = await routing_engine.select_provider(
        country=request.country,
        method=request.method,
        currency=request.currency,
        app=app
    )

    # Llamar a API del proveedor (HTTP async)
    result = await provider.adapter.create_payment(
        amount=request.amount,
        currency=request.currency,
        customer=request.customer,
        metadata=request.metadata
    )

    # Persistir transacción (escritura async a BD)
    payment = await payment_service.create(db, result, app.id)

    return PaymentResponse.from_orm(payment)

Razón 4: Ecosistema fintech de Python.

Python tiene las bibliotecas más maduras para operaciones financieras: decimal para aritmética precisa (nunca usar floats para dinero), pycountry para validación ISO de países/monedas, phonenumbers para análisis de números telefónicos internacionales, cryptography para cifrado de credenciales de proveedores. No tuvimos que construir ninguna de estas desde cero.

Por qué SQLite inicialmente (y la migración a PostgreSQL)

Esta es quizás nuestra decisión más poco convencional. Comenzamos con SQLite para la base de datos principal de una plataforma de pagos.

El caso a favor de SQLite

CaracterísticaSQLitePostgreSQL
Tiempo de configuraciónCero (archivo único)15-30 minutos
ConfiguraciónNingunaAjuste requerido
RespaldoCopiar un archivopg_dump + restore
Lecturas en modo WALConcurrentesConcurrentes
Escrituras/segundo~1.000 (modo WAL)~10.000+
DespliegueSin proceso separadoServidor separado
Costo$0$0-50+/mes

Para una plataforma en sus primeros meses, SQLite fue la elección pragmática:

  • Cero configuración: sin servidor de base de datos que gestionar, sin pool de conexiones que configurar, sin autenticación que establecer.
  • Modo WAL: Write-Ahead Logging permite lecturas concurrentes mientras se realizan escrituras -- esencial para un sistema de pagos donde lees estados de transacción mientras escribes nuevas transacciones.
  • Respaldo de archivo único: cp 0fee.db 0fee.db.backup -- esa es toda la estrategia de respaldo.
  • Lecturas rápidas: Las lecturas de SQLite son frecuentemente más rápidas que PostgreSQL para despliegues en una sola máquina porque no hay sobrecarga de red.
python# Configuración de SQLite con modo 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
)

# Habilitar modo WAL para lecturas 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()

Cuando superamos SQLite

La limitación de SQLite es la concurrencia de escritura. Con el modo WAL, tienes un escritor a la vez. Para una plataforma de pagos procesando volumen creciente de transacciones, esto se convierte en un cuello de botella. Migramos a PostgreSQL cuando:

  • Las operaciones de escritura concurrentes comenzaron a encolarse durante horas pico.
  • Necesitamos búsqueda de texto completo para el explorador de transacciones del administrador.
  • Queríamos bloqueos consultivos para procesamiento distribuido de pagos.
  • El bloqueo a nivel de fila se hizo necesario para garantías de idempotencia.

La migración fue sencilla porque usamos la capa ORM de SQLAlchemy desde el primer día. Cambiar la base de datos requirió actualizar una cadena de conexión y ejecutar migraciones -- sin cambios en el código de la aplicación.

python# Configuración de PostgreSQL (post-migración)
engine = create_async_engine(
    "postgresql+asyncpg://0fee:password@localhost:5432/0fee",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True
)

La lección

Comienza con la herramienta más simple que funcione. SQLite nos permitió lanzar una plataforma de pagos funcional en semanas. PostgreSQL nos permitió escalarla. La capa de abstracción (SQLAlchemy) hizo la transición indolora.

Por qué SolidJS para el panel de control

El panel de 0fee es donde los comerciantes gestionan sus aplicaciones, configuran credenciales de proveedores, ven transacciones y monitorean analíticas. Evaluamos tres frameworks de frontend:

FrameworkTamaño del bundleReactividadCurva de aprendizajeEcosistema
SolidJS~7 KBGranular fina, sin DOM virtualModeradaCreciente
React~40 KBDiffing de DOM virtualBaja (extendido)Masivo
Svelte 5~5 KB (compilado)Basada en compiladorBajaCreciente

La decisión: SolidJS

La reactividad de grano fino fue el factor decisivo. En un panel de pagos, tienes tablas con cientos de filas actualizándose en tiempo real (estados de transacciones, entregas de webhooks, tasas de éxito). SolidJS actualiza solo los nodos DOM específicos que cambian -- sin diffing de DOM virtual, sin re-renderizar árboles de componentes completos.

tsx// Componente SolidJS: tabla de transacciones en tiempo real
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>Monto</th>
          <th>Estado</th>
          <th>Proveedor</th>
          <th>Creado</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>
  );
}

El tamaño de bundle de 7 KB también importa. El panel debe cargar rápido en conexiones de internet africanas donde la latencia es mayor y el ancho de banda es menor que en Norteamérica o Europa.

Por qué DragonflyDB para caché

DragonflyDB es un almacén de datos en memoria compatible con Redis que sirve como la capa de caché y datos efímeros para 0fee. Lo usamos para:

Gestión de OTP y sesiones

Los códigos OTP de dinero móvil tienen un TTL de 60-120 segundos. DragonflyDB maneja esto nativamente:

python# Almacenar OTP con expiración automática
await cache.set(
    f"otp:{transaction_id}",
    otp_code,
    ex=120  # expira en 120 segundos
)

# Verificar OTP
stored_otp = await cache.get(f"otp:{transaction_id}")
if stored_otp != submitted_otp:
    raise InvalidOTPError()

Límite de tasa

Los límites de tasa de API usan un contador de ventana deslizante en 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)

Claves de idempotencia

La idempotencia de pagos es crítica -- un timeout de red no debe resultar en un doble cobro. DragonflyDB almacena claves de idempotencia con un TTL de 24 horas:

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 horas
    )

¿Por qué DragonflyDB sobre Redis?

DragonflyDB es totalmente compatible con Redis (mismo protocolo, mismos comandos) pero usa una arquitectura multi-hilo que entrega mayor rendimiento en hardware moderno. Para nuestro caso de uso, la ventaja clave es que se ejecuta como un binario único con menor sobrecarga de memoria que Redis -- importante al desplegar en un solo servidor.

Por qué Celery para tareas en segundo plano

El procesamiento de pagos genera trabajo significativo en segundo plano que no puede bloquear la respuesta de la API:

TareaDisparadorSLA
Entrega de webhookCambio de estado del pago< 5 segundos
Reintento de webhook (backoff exponencial)Entrega anterior falló5s, 30s, 5m, 30m, 2h, 24h
Conciliación de pagosPrograma diarioUna vez al día
Cálculo de liquidaciónLiquidación del proveedor recibida< 1 hora
Verificación de salud del proveedorPeriódicoCada 60 segundos
Alertas de rotación de credencialesExpiración próximaVerificación diaria

Celery maneja todo esto con un broker compatible con Redis (DragonflyDB) y políticas de reintento 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  # máximo 24 horas
)
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)

El backoff exponencial con seis reintentos significa que un webhook fallido se reintenta durante un período de 24 horas antes de ser marcado como permanentemente fallido. Esto coincide con los estándares de la industria (Stripe reintenta durante 72 horas, PayPal durante 24 horas).

Despliegue: Docker + EasyPanel

Todo el stack de 0fee se despliega como un conjunto de contenedores Docker gestionados por EasyPanel.io:

yaml# docker-compose.yml (simplificado)
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 proporciona la capa de orquestación: certificados SSL, enrutamiento de dominio, verificaciones de salud de contenedores, agregación de logs y despliegues sin tiempo de inactividad. Es esencialmente un Heroku autoalojado que se ejecuta en cualquier VPS.

Decisiones de arquitectura que reconsideraríamos

Ninguna arquitectura es perfecta. Esto es lo que reconsideraríamos:

  1. Comenzar con SQLite: Aunque pragmático, deberíamos haber comenzado con PostgreSQL. El costo de migración fue bajo (gracias a SQLAlchemy), pero el tiempo invertido en soluciones específicas de SQLite (bloqueo de escritura, sin bloqueos consultivos) no fue trivial.
  1. Complejidad de Celery: Para una cola de tareas más pequeña, algo como arq (cola async de Redis para Python) habría sido más simple. La superficie de configuración de Celery es grande.
  1. API monolítica: A medida que el conteo de endpoints superó los 90, un monolito modular con límites de dominio claros habría sido más fácil de navegar que archivos de rutas planos.

Estos son refinamientos, no arrepentimientos. La arquitectura lanzó una plataforma de pagos funcional en 80 días, y cada componente puede evolucionar independientemente.


Este artículo es parte de la serie "Cómo construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre más de 53 proveedores en más de 200 países, construido por Juste A. GNIMAVO y Claude desde Abiyán sin ningún ingeniero humano. Sigue la serie para conocer la historia completa de construcción.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles