Tu API de Flask ha crecido: así se reorganiza sin reescribirla

Tu app.py crece, empieza a doler, y hay una forma de arreglarlo sin tirar nada a la basura

Si acabas de terminar el primer tutorial de Python backend, tienes un app.py con un CRUD de tareas que funciona. También tiene un TODO ahí en medio, sin resolver a propósito: "validar que 'titulo' llega en el JSON antes de usarlo". Si mandas un POST sin ese campo, Flask no te devuelve un error controlado — revienta con un 500 y una traza que nadie que consuma tu API debería ver.

Ese TODO es el punto de partida de este tutorial. Y en el camino para resolverlo bien, vamos a toparnos con otro problema que también tiene un nombre y una solución concreta: qué pasa cuando ese app.py deja de caber en un solo archivo.

Por qué app.py deja de ser suficiente

En el tutorial 1, meter todo en app.py era lo correcto — con cuatro rutas, separar código en varios archivos habría sido complicar algo simple sin necesidad. El problema no aparece por tener pocas rutas. Aparece cuando el proyecto crece de verdad, y aparece de tres formas muy concretas.

1. Imports circulares en cuanto separas rutas

Imagina que decides mover las rutas de tareas a un archivo aparte, tareas.py, para que app.py no acumule cien rutas. Necesitas el decorador @app.route, así que tareas.py importa app desde app.py:

Python — tareas.py intentando importar app
# tareas.py
from app import app  # necesita "app" para poder usar @app.route

@app.route('/api/tareas')
def obtener_tareas():
    pass

Pero app.py, para que esas rutas de tareas.py se registren, necesita importar tareas.py también:

Python — app.py importando tareas.py de vuelta
# app.py
from flask import Flask

app = Flask(__name__)

import tareas  # tareas.py a su vez importa "app" desde aquí -> ciclo

app.py importa tareas.py, y tareas.py importa app.py. Python no sabe por dónde empezar, y el resultado suele ser un ImportError: cannot import name 'app' from partially initialized module que no dice gran cosa sobre la causa real.

2. No puedes testear con una configuración distinta

Con app = Flask(__name__) a nivel de módulo, la aplicación se crea y se configura en el momento en que Python importa el archivo — incluida la URL de la base de datos de desarrollo. No hay ningún punto en el que puedas decirle "esta vez, arráncate apuntando a una base de datos de test" sin andar mutando variables globales después de crear la app, lo cual es frágil y da pie a que un test deje "manchada" la configuración para el siguiente.

3. No puedes crear más de una instancia

Como app es un único objeto a nivel de módulo, todo lo que importe app.py comparte exactamente esa misma instancia. Si un test necesita una aplicación "limpia", sin el estado que dejó el test anterior, no hay forma de pedir una nueva — solo existe una.

⚠️ Nota importante:

Estos tres problemas no son un fallo del tutorial 1. Son la señal de que un proyecto ha crecido lo suficiente como para necesitar otra estructura — la misma señal que hace que casi cualquier proyecto Flask real, tarde o temprano, migre a lo que vamos a ver ahora.

Application factory: crear la app dentro de una función

La solución a los tres problemas es la misma: en vez de crear app a nivel de módulo, la creas dentro de una función que se puede llamar tantas veces como haga falta, con la configuración que le pases cada vez. A esa función se la llama application factory.

Vamos a reorganizar el proyecto del tutorial 1 como un paquete de Python. La estructura pasa de un único archivo a esto:

Estructura de carpetas del proyecto
mi_proyecto/
├── flaskr/
│   ├── __init__.py       # aquí vive create_app()
│   ├── modelos.py         # el modelo Tarea, igual que en el tutorial 1
│   ├── esquemas.py         # los modelos Pydantic, nuevos en este tutorial
│   └── tareas.py           # el blueprint con las rutas de tareas
├── .env
└── pyproject.toml

El archivo flaskr/__init__.py contiene la factory. Fíjate en que no hay ninguna ruta ni ningún app = Flask(__name__) suelto — todo vive dentro de la función:

Python — flaskr/__init__.py
# flaskr/__init__.py
import os
from dotenv import load_dotenv
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

load_dotenv()

db = SQLAlchemy()  # sin vincular a ninguna app todavía


def create_app(test_config=None):
    app = Flask(__name__)

    if test_config is None:
        app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
    else:
        # en los tests le pasas otra config, sin tocar nada global
        app.config.update(test_config)

    db.init_app(app)

    from .tareas import bp as tareas_bp
    app.register_blueprint(tareas_bp)

    return app

Los tres problemas de antes desaparecen con esto: el import de tareas pasa dentro de la función, así que ya no hay ciclo — Python primero define create_app por completo, y solo cuando alguien la llama entra a buscar tareas.py. Para testear con otra base de datos, llamas create_app({'SQLALCHEMY_DATABASE_URI': '...test...'}). Y como create_app() es una función normal, puedes llamarla las veces que quieras — cada llamada te da una instancia nueva e independiente.

💡 Por qué db = SQLAlchemy() va fuera de la función:

El objeto db se crea sin vincular a ninguna app (a nivel de módulo, eso sí es seguro porque no depende de configuración), y luego db.init_app(app) lo conecta con la instancia concreta que se está creando en esa llamada a create_app(). Así db puede importarse desde cualquier otro archivo del paquete (como tareas.py) sin arrastrar el problema de imports circulares.

El modelo, con el estilo moderno de SQLAlchemy

Aprovechando que estamos reorganizando el proyecto, este es un buen momento para escribir el modelo Tarea con la sintaxis moderna de SQLAlchemy 2.0 (Mapped y mapped_column), en vez del estilo db.Column que viste en el tutorial 1.

Ojo: esto no significa que el tutorial 1 estuviera mal — db.Column sigue funcionando perfectamente y lo vas a ver en muchísimo código real. Es una cuestión de estilo: la sintaxis con Mapped[] añade comprobación de tipos, que encaja mejor con lo que vamos a hacer a continuación con Pydantic.

Python — flaskr/modelos.py
# flaskr/modelos.py
from datetime import datetime, timezone
from sqlalchemy.orm import Mapped, mapped_column

from . import db


class Tarea(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    titulo: Mapped[str] = mapped_column(db.String(200))
    descripcion: Mapped[str | None] = mapped_column(db.Text, default='')
    completada: Mapped[bool] = mapped_column(default=False)
    fecha_creacion: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc)
    )

Mismos campos que la Tarea del tutorial 1 — no cambia el dato que se guarda, cambia cómo se declara. Un detalle que sí es importante corregir: datetime.utcnow(), que verás en muchísimos tutoriales antiguos, está deprecado desde Python 3.12. La forma correcta hoy es datetime.now(timezone.utc), que es explícito sobre que la fecha lleva zona horaria UTC en vez de dejarlo implícito.

Pydantic no sustituye a SQLAlchemy

Antes de ver código, esto hay que dejarlo claro porque es fácil confundirse viniendo del tutorial 1: Pydantic y SQLAlchemy resuelven problemas distintos, y los vas a usar los dos a la vez, no uno en vez del otro.

💡 Dos capas, dos trabajos:

  • SQLAlchemy = persistencia. Cómo se guarda un dato en PostgreSQL, cómo se consulta, cómo se relaciona con otras tablas.
  • Pydantic = validación y forma de los datos. Comprobar que el JSON que llega en una petición tiene los campos correctos, del tipo correcto, antes de que ese dato llegue siquiera a tocar la base de datos.

El modelo Tarea(db.Model) que acabas de ver no desaparece ni se toca. Lo que añadimos es un modelo Pydantic aparte, TareaCreate, que describe cómo tiene que ser el JSON de entrada del POST. Los dos conviven en la misma ruta:

Python — flaskr/esquemas.py
# flaskr/esquemas.py
from pydantic import BaseModel, Field


class TareaCreate(BaseModel):
    titulo: str = Field(min_length=1, max_length=200)
    descripcion: str = ''

titulo es obligatorio (Pydantic lanza un error si no llega) y no puede estar vacío (min_length=1). descripcion es opcional y, si no llega, vale cadena vacía. Esto es exactamente lo que el TODO del tutorial 1 pedía resolver — solo que ahora la validación no es un if escrito a mano, es una definición declarativa que Pydantic comprueba por ti.

El blueprint de tareas, con validación integrada

Ahora sí, las rutas. Van en su propio archivo dentro del paquete, registradas como blueprint:

Python — flaskr/tareas.py
# flaskr/tareas.py
from flask import Blueprint, jsonify, request
from pydantic import ValidationError

from . import db
from .modelos import Tarea
from .esquemas import TareaCreate

bp = Blueprint('tareas', __name__, url_prefix='/api/tareas')


@bp.route('')
def obtener_tareas():
    tareas = db.session.execute(
        db.select(Tarea).order_by(Tarea.fecha_creacion.desc())
    ).scalars().all()

    return jsonify([{
        'id': t.id,
        'titulo': t.titulo,
        'descripcion': t.descripcion,
        'completada': t.completada,
        'fecha_creacion': t.fecha_creacion.isoformat()
    } for t in tareas])


@bp.route('', methods=['POST'])
def crear_tarea():
    try:
        datos = TareaCreate.model_validate(request.get_json(silent=True) or {})
    except ValidationError as e:
        return jsonify({'error': e.errors()}), 400

    nueva = Tarea(titulo=datos.titulo, descripcion=datos.descripcion)
    db.session.add(nueva)
    db.session.commit()

    return jsonify({'mensaje': 'Tarea creada correctamente', 'id': nueva.id}), 201

El TODO del tutorial 1 queda resuelto en tres líneas: si el POST llega sin titulo, o con un titulo vacío, TareaCreate.model_validate(...) lanza ValidationError antes de que nueva = Tarea(...) llegue a ejecutarse. La API responde con un 400 y el detalle exacto del error, no con un 500 y una traza de Python.

❌ Un detalle que se te puede escapar:

request.get_json(silent=True) devuelve None si el body no es JSON válido, en vez de lanzar una excepción por su cuenta. Por eso el código hace ... or {} — si no hubiera body o no fuera JSON, Pydantic recibe un diccionario vacío y falla de forma controlada (porque falta titulo), en vez de que el error explote antes incluso de llegar a la validación.

Fíjate también en db.session.execute(db.select(Tarea)...) en la ruta GET — es el estilo moderno de SQLAlchemy 2.0 en vez de Tarea.query.all() del tutorial 1. Ambos hacen lo mismo; este es el que verás recomendado en proyectos nuevos.

Registrar el blueprint y crear las tablas

Para que esto arranque, hay que registrar el blueprint dentro de create_app() (ya lo viste en el primer bloque de código) y tener una forma de crear las tablas sin hacerlo a mano cada vez. En el tutorial 1 eso pasaba dentro de if __name__ == '__main__':; con application factory, ese bloque ya no existe, así que se resuelve con un comando de Flask:

Python — comando CLI para inicializar la base de datos
# Dentro de create_app(), antes del return app:

@app.cli.command('init-db')
def init_db_command():
    """Crea las tablas en la base de datos."""
    with app.app_context():
        db.create_all()
    print('Base de datos inicializada.')

Con esto, desde la terminal ejecutas flask --app flaskr init-db una vez, y las tablas quedan creadas. Para arrancar el servidor en desarrollo:

Terminal — arrancar el servidor
flask --app flaskr run --debug

El --app flaskr le dice a Flask que busque create_app() dentro del paquete flaskr — ya no hay un app.py con la instancia creada de antemano al que apuntar directamente.

⚠️ Si necesitas distinguir entornos, no uses FLASK_ENV:

FLASK_ENV se eliminó en Flask 3.0 — si lo ves en un tutorial antiguo, está desactualizado. Para modo debug usa el flag --debug como arriba, o la variable FLASK_DEBUG=1. Para distinguir configuración de test de la de desarrollo, usa el parámetro test_config de la factory que ya viste, no una variable de entorno de "modo".

Qué toca ahora

Con esta estructura ya tienes dónde meter autenticación cuando la necesites: un blueprint auth.py más, registrado igual que tareas.py, sin tocar el resto del proyecto. Eso — login, sesiones, y proteger rutas para que solo el autor de una tarea pueda editarla o borrarla — es contenido suficiente para un tutorial aparte, así que se queda fuera de este.

Si quieres repasar por qué db.session.execute(db.select(...)) genera el SQL que genera, SQL básico: INSERT, SELECT, UPDATE y DELETE te da la base para leerlo con soltura.