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:
| Framework | Lenguaje | Async | Tipado seguro | OpenAPI | Ecosistema |
|---|---|---|---|---|---|
| FastAPI | Python | Async/await nativo | Modelos Pydantic | Autogenerado | Excelente para fintech |
| Express.js | TypeScript | Callback/Promise | Opcional (TS) | Manual (Swagger) | Grande pero fragmentado |
| Go (Gin/Fiber) | Go | Goroutines | En compilación | Manual | Creciente |
| Rust (Actix) | Rust | Tokio async | En compilación | Manual | Pequeñ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
/docspara pruebas - Documentación ReDoc en
/redocpara 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ística | SQLite | PostgreSQL |
|---|---|---|
| Tiempo de configuración | Cero (archivo único) | 15-30 minutos |
| Configuración | Ninguna | Ajuste requerido |
| Respaldo | Copiar un archivo | pg_dump + restore |
| Lecturas en modo WAL | Concurrentes | Concurrentes |
| Escrituras/segundo | ~1.000 (modo WAL) | ~10.000+ |
| Despliegue | Sin proceso separado | Servidor 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:
| Framework | Tamaño del bundle | Reactividad | Curva de aprendizaje | Ecosistema |
|---|---|---|---|---|
| SolidJS | ~7 KB | Granular fina, sin DOM virtual | Moderada | Creciente |
| React | ~40 KB | Diffing de DOM virtual | Baja (extendido) | Masivo |
| Svelte 5 | ~5 KB (compilado) | Basada en compilador | Baja | Creciente |
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:
| Tarea | Disparador | SLA |
|---|---|---|
| Entrega de webhook | Cambio de estado del pago | < 5 segundos |
| Reintento de webhook (backoff exponencial) | Entrega anterior falló | 5s, 30s, 5m, 30m, 2h, 24h |
| Conciliación de pagos | Programa diario | Una vez al día |
| Cálculo de liquidación | Liquidación del proveedor recibida | < 1 hora |
| Verificación de salud del proveedor | Periódico | Cada 60 segundos |
| Alertas de rotación de credenciales | Expiración próxima | Verificació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:
- 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.
- 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.
- 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.