wandres.dev
SWIFT EN EL SERVIDOR · Vapor y Hummingbird

Vapor y Hummingbird: elegir framework con criterio

Los dos frameworks vivos del servidor en Swift comparados sin fanatismo: enrutado y extracción de parámetros, la cadena de middleware y su orden, el acceso a datos con `Fluent` frente a controladores directos, el coste de las baterías incluidas y una regla de decisión que aguanta una revisión de arquitectura.

⏱ 21 min

En 2026 el servidor en Swift se reduce a dos nombres. Vapor es el veterano: casi una década de historia, un ecosistema propio de paquetes, un ORM completo, autenticación, colas, plantillas y una comunidad que ha resuelto ya el problema que tú tienes. Hummingbird es la reacción: una capa fina sobre SwiftNIO, sin dependencias que no hayas pedido, diseñada desde su versión 2 alrededor de la concurrencia estructurada y de los tipos no copiables, que asume que preferirías elegir tú el cliente de base de datos. No son competidores en el sentido habitual —comparten motor, comparten personas y comparten la mitad de las bibliotecas— sino dos respuestas distintas a una pregunta antigua: cuánta opinión debe tener un framework.

🎯 Al terminar esta lección sabrás
  • Escribir el mismo servicio en ambos frameworks y reconocer qué diferencias son de sintaxis y cuáles de arquitectura.
  • Razonar sobre la cadena de middleware: orden, envoltura, y por qué la autenticación y el registro no van en el mismo sitio.
  • Contrastar el acceso a datos con un ORM completo frente a controladores directos, con sus costes respectivos.
  • Aplicar una regla de decisión explícita y defenderla ante restricciones de equipo, plazo y despliegue.

Dos filosofías sobre el mismo motor

La diferencia se ve mejor en el arranque que en cualquier tabla comparativa.

// Vapor: la aplicacion es un objeto grande con servicios dentro
let app = try await Application.make(.detect())
app.databases.use(.postgres(configuration: config), as: .psql)
app.migrations.add(CrearReserva())
try await app.execute()
// Hummingbird: compones el router, luego la aplicacion, luego la sirves
let router = Router()
router.middlewares.add(LogRequestsMiddleware(.info))
router.get("salud") { _, _ in "ok" }

let app = Application(router: router, configuration: .init(address: .hostname("0.0.0.0", port: 8080)))
try await app.runService()

En Vapor la Application es un contenedor de servicios: bases de datos, colas, clientes, sesiones y cachés cuelgan de ella y se acceden por propiedades. Es cómodo y hace que un proyecto arranque en minutos; el precio es un objeto ambiental grande que resulta incómodo de aislar en pruebas y que arrastra dependencias transitivas aunque no uses la mitad. En Hummingbird no hay contenedor: hay un enrutador genérico sobre un tipo de contexto que tú defines, y todo lo que quieras disponible en un manejador tiene que llegar por ese contexto o por captura explícita. Es más ceremonia por adelantado y menos magia que desentrañar después.

El segundo eje es el rendimiento, y aquí conviene ser sobrio. Hummingbird es medible pero moderadamente más rápido y más ligero en memoria —del orden de un 10 a un 30 por ciento según la carga—, porque hace menos cosas por petición. Esa diferencia rara vez decide un proyecto: la base de datos suele dominar. Lo que sí decide proyectos es la memoria residente en despliegues muy densos y el tiempo de compilación, donde el árbol de dependencias más pequeño se nota todos los días.

Rutas, contexto y extracción

Las rutas se parecen mucho más de lo que las diferencia.

// Vapor
app.get("reservas", ":id") { req async throws -> Reserva in
    guard let id = req.parameters.get("id", as: UUID.self) else {
        throw Abort(.badRequest, reason: "id no valido")
    }
    return try await servicio.buscar(id)
}

app.post("reservas") { req async throws -> Response in
    let entrada = try req.content.decode(NuevaReserva.self)
    let creada = try await servicio.crear(entrada)
    return try await creada.encodeResponse(status: .created, for: req)
}
// Hummingbird
router.get("reservas/:id") { _, contexto async throws -> Reserva in
    let id = try contexto.parameters.require("id", as: UUID.self)
    return try await servicio.buscar(id)
}

router.post("reservas") { peticion, contexto async throws -> EditedResponse<Reserva> in
    let entrada = try await peticion.decode(as: NuevaReserva.self, context: contexto)
    return EditedResponse(status: .created, response: try await servicio.crear(entrada))
}

La asimetría relevante está en el contexto. En Vapor, Request es a la vez la petición y la puerta a todo lo demás: base de datos, cliente HTTP, registro, usuario autenticado. En Hummingbird, el contexto es un tipo genérico que tú declaras, así que puedes añadirle campos tipados —el identificador de traza, el usuario, el tenant— y el compilador garantiza que están presentes donde los usas, en lugar de que aparezcan como un opcional que alguien rellenó en un middleware anterior y esperas que siga ahí.

💡
El contexto tipado paga solo en proyectos grandes

En un servicio de diez rutas, un contexto genérico propio es ceremonia. En uno de doscientas rutas con varios equipos tocándolo, es la diferencia entre un fallo de compilación y un nil inesperado en producción a las dos de la tarde de un viernes.

Middleware: orden, envoltura y responsabilidad

Un middleware es una función que recibe la petición y la siguiente etapa, y devuelve una respuesta. Su forma es la misma en ambos frameworks porque es la forma canónica del patrón.

struct MedirTiempo: RouterMiddleware {
    func handle(_ peticion: Request, context: Contexto,
                next: (Request, Contexto) async throws -> Response) async throws -> Response {
        let inicio = ContinuousClock.now
        defer { context.logger.info("duracion \(ContinuousClock.now - inicio)") }
        return try await next(peticion, context)
    }
}

Lo que importa no es la sintaxis sino la disciplina del orden, porque un middleware envuelve a los siguientes y por tanto ve tanto la petición de ida como la respuesta de vuelta. La secuencia sensata es casi siempre la misma: primero lo que debe observar absolutamente todo, incluidos los fallos, es decir el registro y las métricas; después lo que rechaza pronto y barato, como los límites de tamaño o el control de tasa; después la traducción de errores a respuestas; después la autenticación; y solo entonces la autorización y la lógica.

flowchart TB
A[Peticion] --> B[Registro y metricas]
B --> C[Limites y control de tasa]
C --> D[Traduccion de errores]
D --> E[Autenticacion]
E --> F[Autorizacion]
F --> G[Manejador de ruta]
G --> H[Respuesta sube por la misma cadena]
H --> B
style B fill:#89b4fa,color:#11111b
style G fill:#a6e3a1,color:#11111b

Poner el registro por dentro del manejador de errores es un error clásico: cuando algo falla de verdad, la excepción se convierte en respuesta antes de que nadie la anote, y el incidente aparece en los paneles como un simple código 500 sin rastro. Poner la autenticación antes del control de tasa es otro: obligas a validar una firma criptográfica a un atacante que solo quería consumirte CPU.

Datos y la regla de decisión

Vapor trae Fluent, un ORM con modelos, migraciones, relaciones y controladores para PostgreSQL, MySQL y SQLite. Resuelve el noventa por ciento de un CRUD en muy pocas líneas y su coste aparece en el diez por ciento restante: consultas complejas que hay que escribir en SQL de todos modos, un salto de abstracción que oculta el número real de viajes a la base de datos, y la clase de problema donde una relación cargada perezosamente dentro de un bucle produce doscientas consultas donde debía haber una.

final class Reserva: Model, @unchecked Sendable {
    static let schema = "reservas"
    @ID(key: .id) var id: UUID?
    @Field(key: "plazas") var plazas: Int
    @Parent(key: "sala_id") var sala: Sala
}

let proximas = try await Reserva.query(on: db)
    .filter(\.$inicio > .now)
    .with(\.$sala)
    .sort(\.$inicio)
    .limit(50)
    .all()

Hummingbird no trae nada de esto y espera que uses directamente PostgresNIO, MongoKitten o el cliente que corresponda, escribiendo tus consultas. Es más trabajo inicial, control total sobre el SQL emitido y ninguna sorpresa de rendimiento escondida bajo una capa.

🏗️

Elige Vapor cuando

Necesitas ir rápido, el equipo es pequeño, el dominio es CRUD con autenticación y prefieres respuestas ya escritas por otros.

🪶

Elige Hummingbird cuando

La densidad de memoria importa, quieres control del SQL, el tiempo de compilación duele o el servicio es una pieza estrecha.

🔁

No es irreversible

Ambos corren sobre SwiftNIO y comparten bibliotecas. Si tu lógica vive en tipos propios y no en manejadores, migrar es un fin de semana.

Un framework es una apuesta sobre qué problemas vas a tener

La discusión entre baterías incluidas y núcleo mínimo se repite en todos los ecosistemas —Rails contra Sinatra, Spring contra Javalin, Django contra Flask, Nest contra Fastify— y se resuelve siempre igual de mal porque se plantea como una cuestión de gusto cuando es una cuestión de información. Un framework con opinión te entrega, gratis, las decisiones que otros ya tomaron bien: cómo estructurar migraciones, cómo firmar sesiones, cómo paginar, dónde poner el middleware de errores. Ese regalo tiene un valor enorme mientras tu problema esté dentro del espacio que el framework anticipó, y se convierte en deuda en el momento exacto en que sales de él, porque entonces no solo tienes que resolver tu problema sino además desmontar la solución que te venía puesta. Un núcleo mínimo hace lo contrario: te cobra por adelantado en decisiones que tendrás que tomar tú, muchas de las cuales tomarás peor que la comunidad de Vapor, y a cambio no te cobra nada después. Por eso la pregunta útil al elegir no es cuál es mejor, sino cuánta certeza tienes sobre la forma final de tu sistema: si el dominio es conocido y convencional, la opinión ajena es capital gratis y rechazarla es orgullo caro; si el dominio es raro, si el perfil de despliegue es extremo o si el sistema va a vivir diez años cambiando de forma, cada decisión que no tomaste tú es una decisión que tendrás que revertir sin entenderla. Y hay un corolario que vale para cualquier framework de cualquier lenguaje: la mejor defensa contra haberte equivocado en esta elección es que tu lógica de negocio no sepa qué framework la está sirviendo. Si tus reglas viven en tipos que solo dependen del modelo de dominio, y los manejadores de ruta son diez líneas de traducción entre HTTP y esos tipos, entonces el framework es una capa reemplazable y esta discusión pierde casi toda su gravedad. Si tus reglas viven dentro de las clausuras de las rutas, has casado con él.

⚔️ El mismo servicio, dos veces
  1. Implementa el mismo recurso con cuatro rutas en Vapor y en Hummingbird, y compara líneas de código, dependencias y tiempo de compilación en frío.
  2. Mide el RSS de ambos binarios en reposo y bajo mil peticiones por segundo.
  3. Escribe un middleware de traza que propague un identificador y colócalo mal a propósito; observa qué información pierdes.
  4. Provoca el problema de las consultas en bucle con Fluent y arréglalo con carga anticipada, contando las consultas emitidas.
  5. Extrae la lógica de negocio a un paquete sin dependencias del framework y comprueba cuánto tardas en cambiar de uno a otro.