← Volver al blog

Cloudflare Workers: serverless sin vendor lock-in

2026-06-17

Cloudflare Workers: entendiendo el verdadero alcance del "vendor lock-in"

El discurso repetido en la industria dice que adoptar plataformas serverless como Cloudflare Workers te encadena irremediablemente a un proveedor. La realidad es más matizada. El vendor lock-in existe, pero no donde habitualmente se busca. Este artículo descompone los riesgos reales, las estrategias prácticas de mitigación y te muestra cómo aprovechar Workers manteniendo la libertad de movimiento que necesita tu arquitectura.

Qué es realmente el lock-in en serverless

El vendor lock-in no es binario. No se trata de estar "atrapado" o "libre", sino de identificar dónde acumulás deuda de acoplamiento. En el ecosistema serverless, existen al menos cuatro capas distintas:

La confusión habitual es tratar todas estas capas como un bloque indivisible. No lo son. Cloudflare Workers, en particular, presenta un perfil de riesgo asimétrico que conviene analizar con precisión.

Por qué Workers es diferente: el aislamiento de V8

Cloudflare Workers no ejecuta contenedores ni máquinas virtuales tradicionales. Corre tu código dentro de aislamientos de V8 (V8 isolates), el mismo motor JavaScript que impulsa Chrome. Esta elección arquitectónica tiene consecuencias directas sobre el lock-in.

Primera consecuencia positiva: tu código es JavaScript/WebAssembly estándar. No hay runtime propietario que emular. Un Worker que responde HTTP no depende de APIs mágicas para funcionar:

// worker.ts - código ejecutable en cualquier entorno estandarizado
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    
    if (url.pathname === "/api/health") {
      return new Response(JSON.stringify({ status: "ok" }), {
        headers: { "Content-Type": "application/json" }
      });
    }
    
    return new Response("Not Found", { status: 404 });
  }
} satisfies ExportedHandler;

Este mismo código, con mínimas o ninguna modificación, puede ejecutarse en Deno, Node.js con un adaptador compatible, o en entornos que implementen el WinterCG (Web-interoperable Runtimes Community Group). Cloudflare es miembro activo de este estándar emergente.

La especificación fetch handler que usás en Workers no es invento de Cloudflare. Deriva del Service Workers API, especificación del W3C. Tu inversión en aprender este patrón es transferible.

Dónde sí existe acoplamiento: las APIs propietarias

El verdadero lock-in se concentra en los bindings específicos. Cuando tu código llama a env.MY_KV_NAMESPACE.get("clave") o crea una instancia de DurableObjectState, estás introduciendo acoplamiento directo a infraestructura de Cloudflare.

Analicemos un ejemplo típico con acoplamiento explícito:

// ❌ Acoplamiento directo: imposible migrar sin reescribir
export default {
  async fetch(request, env, ctx): Promise<Response> {
    // Binding directo a Cloudflare KV
    const config = await env.CONFIG_KV.get("app-settings", "json");
    
    // Binding directo a D1 (SQLite serverless de Cloudflare)
    const db = env.DATABASE;
    const result = await db.prepare("SELECT * FROM users WHERE id = ?")
      .bind(123)
      .first();
    
    return Response.json({ config, user: result });
  }
} satisfies ExportedHandler;

Este código es funcional, pero cada línea que interactúa con env es deuda de migración. El problema no es usar estas herramientas: son excelentes. El problema es no abstraer su acceso.

Estrategia de abstracción: el patrón puerto-adaptador

La solución probada es aplicar arquitectura hexagonal (ports and adapters) a tu código serverless. Definís contratos, implementás adaptadores específicos por proveedor.

// ports/ConfigStore.ts - contrato puro, cero dependencias de infraestructura
export interface ConfigStore {
  get<T>(key: string): Promise<T | null>;
  put<T>(key: string, value: T, options?: { ttlSeconds?: number }): Promise<void>;
}

// ports/Database.ts
export interface Database {
  query<T>(sql: string, params: unknown[]): Promise<T[]>;
  queryOne<T>(sql: string, params: unknown[]): Promise<T | null>;
}

// domain/UserService.ts - tu lógica de negocio, completamente desacoplada
import { ConfigStore, Database } from "../ports";

export class UserService {
  constructor(
    private config: ConfigStore,
    private db: Database,
    private cache: CachePort // idem para otros concerns
  ) {}
  
  async getUserWithPermissions(id: number) {
    const featureFlags = await this.config.get<string[]>("feature-flags");
    const [user] = await this.db.query<UserRow>(
      "SELECT id, email, role FROM users WHERE id = ?",
      [id]
    );
    
    if (!user) return null;
    
    return {
      ...user,
      permissions: this.calculatePermissions(user.role, featureFlags ?? [])
    };
  }
  
  private calculatePermissions(role: string, flags: string[]): string[] {
    // lógica de dominio pura, sin side effects
    const base = role === "admin" ? ["read", "write", "delete"] : ["read"];
    return flags.includes("beta-dashboard") 
      ? [...base, "beta-access"] 
      : base;
  }
}

Ahora los adaptadores específicos encapsulan el acoplamiento:

// adapters/CloudflareKVStore.ts
import { ConfigStore } from "../ports";

export class CloudflareKVStore implements ConfigStore {
  constructor(private binding: KVNamespace) {}
  
  async get<T>(key: string): Promise<T | null> {
    return this.binding.get(key, "json");
  }
  
  async put<T>(key: string, value: T, options?: { ttlSeconds?: number }): Promise<void> {
    await this.binding.put(key, JSON.stringify(value), {
      expirationTtl: options?.ttlSeconds
    });
  }
}

// adapters/CloudflareD1Database.ts
import { Database } from "../ports";

export class CloudflareD1Database implements Database {
  constructor(private binding: D1Database) {}
  
  async query<T>(sql: string, params: unknown[]): Promise<T[]> {
    const result = await this.binding.prepare(sql).bind(...params).all();
    return result.results as T[];
  }
  
  async queryOne<T>(sql: string, params: unknown[]): Promise<T | null> {
    const result = await this.binding.prepare(sql).bind(...params).first();
    return result as T | null;
  }
}

La composición ocurre en el punto de entrada, no dispersa por todo el código:

// worker.ts - único archivo que conoce a Cloudflare
import { UserService } from "./domain/UserService";
import { CloudflareKVStore } from "./adapters/CloudflareKVStore";
import { CloudflareD1Database } from "./adapters/CloudflareD1Database";

export default {
  async fetch(request, env, ctx): Promise<Response> {
    const configStore = new CloudflareKVStore(env.CONFIG_KV);
    const database = new CloudflareD1Database(env.DATABASE);
    
    const userService = new UserService(configStore, database, /* cache */);
    
    // routing minimalista o con itty-router, hono, etc.
    const url = new URL(request.url);
    
    if (url.pathname.startsWith("/users/")) {
      const id = parseInt(url.pathname.split("/")[2]);
      const user = await userService.getUserWithPermissions(id);
      return user 
        ? Response.json(user) 
        : new Response("Not found", { status: 404 });
    }
    
    return new Response("Not found", { status: 404 });
  }
} satisfies ExportedHandler;

Migración real: ¿qué cambiaría en AWS Lambda?

Imaginá que necesitás migrar a AWS. Los adaptadores nuevos implementan los mismos contratos:

// adapters/AWSSSMConfigStore.ts
import { SSMClient, GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { ConfigStore } from "../ports";

export class AWSSSMConfigStore implements ConfigStore {
  private client = new SSMClient({});
  
  async get<T>(key: string): Promise<T | null> {
    try {
      const response = await this.client.send(new GetParameterCommand({
        Name: `/myapp/${key}`,
        WithDecryption: true
      }));
      return response.Parameter?.Value 
        ? JSON.parse(response.Parameter.Value) 
        : null;
    } catch (e) {
      if ((e as Error).name === "ParameterNotFound") return null;
      throw e;
    }
  }
  
  async put<T>(key: string, value: T, options?: { ttlSeconds?: number }): Promise<void> {
    await this.client.send(new PutParameterCommand({
      Name: `/myapp/${key}`,
      Value: JSON.stringify(value),
      Type: "SecureString",
      Overwrite: true,
      // SSM no tiene TTL nativo; requeriría TTL en el valor o DynamoDB TTL
    }));
  }
}

// adapters/AWSRDSDataService.ts - o Aurora Serverless v2, o DynamoDB según necesidad
import { RDSDataClient, ExecuteStatementCommand } from "@aws-sdk/client-rds-data";
import { Database } from "../ports";

export class AWSRDSDataService implements Database {
  constructor(
    private client: RDSDataClient,
    private resourceArn: string,
    private secretArn: string,
    private database: string
  ) {}
  
  async query<T>(sql: string, params: unknown[]): Promise<T[]> {
    // mapeo de params a formatos de RDS Data Service...
    // implementación omitida por brevedad
    throw new Error("Implementar mapeo de parámetros");
  }
  
  async queryOne<T>(sql: string, params: unknown[]): Promise<T | null> {
    const results = await this.query<T>(sql, params);
    return results[0] ?? null;
  }
}

El UserService, la lógica de negocio, sus tests unitarios, sus invariantes: todo permanece intacto. Cambiás los adaptadores, no el dominio. Este es el principio de superficie de acoplamiento controlada.

Herramientas que reducen fricción: Wrangler y el ecosistema abierto

Cloudflare mantiene Wrangler, su CLI de despliegue, como open source. El formato wrangler.toml o wrangler.json es declarativo y documentado. Más relevante: el ecosistema de frameworks compatibles crece en dirección de estándares.

Hono, framework web que funciona en Workers, Deno, Node.js, Bun y Lambda:

// app.ts - compatible con múltiples runtimes
import { Hono } from "hono";

const app = new Hono();

app.get("/api/health", (c) => c.json({ status: "ok", runtime: "any" }));
app.get("/users/:id", async (c) => {
  const id = c.req.param("id");
  // tu UserService inyectado según adaptador del entorno
  return c.json({ id });
});

export default app;

Hono abstrae las diferencias de Request/Response entre plataformas. El mismo router, mismos middlewares, múltiples destinos de despliegue.

El argumento menos discutido: lock-in de habilidades y comunidad

Un lock-in frecuentemente ignorado es el humano. Tu equipo aprende patrones, construye muscle memory, desarrolla intuición sobre debugging y observabilidad. Cloudflare Workers, al usar estándares web (Fetch API, Streams, WebCrypto), amortiza esa inversión en conocimiento.

Contrastá con AWS Lambda: Node.js 18 vs 20 vs 22 con diferencias en runtime, TypedArray behaviors, o el SDK v3 que rompe compatibilidad con v2. La superficie de aprendizaje "no transferible" es mayor en ecosistemas más cerrados.

Decisiones pragmáticas: cuándo aceptar acoplamiento

No toda abstracción vale su costo. Aceptar acoplamiento deliberado es válido cuando:

En estos casos, documentá explícitamente: "Acoplamiento aceptado: Durable Objects para sessions. Costo estimado de migración: reimplementación con Redis + Lua o sistema propio".

Veredicto final

Cloudflare Workers no elimina el vendor lock-in por arte de magia. Lo reubica hacia capas más manejables. El runtime V8 estándar y la adhesión progresiva a WinterCG mitigan el lock-in de ejecución. El verdadero trabajo de arquitectura está en aislar las APIs propietarias mediante contratos explícitos.

No es "serverless sin vendor lock-in". Es serverless con lock-in controlado, auditado y migrable. La diferencia no es semántica: es la diferencia entre una decisión informada y una sorpresa arquitectónica de dos años de trabajo.

Tu código, tus contratos, tu estrategia de abstracción. El proveedor es infraestructura, no dueño del diseño.