Blazor Web Apps: Arquitectura real, decisiones y trade-offs

Si has seguido esta serie, ya sabes qué son las Blazor Web Apps, dominas los Render Modes y entiendes a la perfección cómo lidiar con el ciclo de vida y el prerendering (puedes repasar el último artículo aquí). Llegados a este punto, tu aplicación funciona… pero, ¿está realmente lista para producción?

En cuanto intentas estructurar algo serio, aparece el segundo gran susto de la serie:

«Tengo tres proyectos, dos implementaciones del mismo servicio y no sé dónde va cada cosa.»

En esta entrega vamos a dejar la teoría a un lado para hablar de arquitectura real. Vamos a estructurar una aplicación que usa el modo Interactive Auto combinando lo mejor del servidor y WebAssembly, manteniendo el código limpio, modular y seguro.

TL;DR – Checklist rápida

Si ya tienes experiencia con Blazor y quieres saber si este artículo te aporta algo, esta es la esencia:

  • Si tienes lógica compartida –> Crea un proyecto App.Shared con modelos e interfaces. Nunca en Server ni en Client.
  • Si un servicio usa DbContext –> Regístralo solo en el servidor. En el cliente, su gemelo llama a la API.
  • Si el cliente WASM necesita datos –> Expón un Minimal API en el servidor. Configura BaseAddress en el cliente.
  • Si necesitas autenticación –> Cookie HttpOnly en el servidor + PersistentComponentState para transferir los claims a WASM.
  • Si algo no encaja en un componente –> No lo fuerces. Minimal APIs, Background Services y controladores conviven bajo el mismo techo.

Si alguno de estos puntos te genera dudas, sigue leyendo.

1. Organización de proyectos: El límite entre servidor y cliente

Cuando creas una Blazor Web App con soporte para WebAssembly, la plantilla genera dos proyectos: Server y Client.

Debes crear un tercer proyecto (Class Library) llamado App.Shared. Aquí vivirán:

El error número uno es mezclar responsabilidades. Si tienes un componente Analytics.razor que corre en modo Auto (primero en servidor, luego en WASM), ¿dónde colocas los modelos de datos y las interfaces de servicio que usa?

La regla de oro: el proyecto Shared

Debes crear un tercer proyecto (Class Library) llamado App.Shared. Aquí vivirán:

  • Modelos de dominio (TaskItem, UserDTO)
  • Contratos de servicios (ITaskService)

Tanto el servidor como el cliente referenciarán a Shared. Así garantizas que el cliente WebAssembly no conozca tus configuraciones de Entity Framework, pero comparta exactamente los mismos modelos.

graph TD
    Shared["App.Shared\n(Modelos e Interfaces)"]
    Server["App\n(Servidor)"]
    Client["App.Client\n(WebAssembly)"]

    Server -.->|"referencia"| Shared
    Client -.->|"referencia"| Shared
    Server -->|"aloja"| Client
    

Regla de oro: Si un archivo lo necesitan tanto el servidor como el cliente, pertenece a Shared. Si solo lo necesita uno de los dos, pertenece a ese proyecto y a ningún otro.

2. Inyección de dependencias híbrida (Servicios Cross-Mode)

Este es el patrón más importante de las Blazor Web Apps. Si tu componente usa @inject ITaskService TaskService y corre en el servidor, debería ir directo a la base de datos (latencia cero). Pero si corre en WebAssembly, ¡no puede hacer consultas a SQL! Debe hacer una llamada HTTP a tu API.

La solución es elegante: dos implementaciones de la misma interfaz, una por entorno.

En ServerTaskService (Proyecto Servidor):

public class ServerTaskService : ITaskService
{
    // Inyecta DbContext aquí.
    public async Task<List<TaskItem>> GetTasksAsync() =>
        await _db.Tasks.ToListAsync();
}

En ClientTaskService (Proyecto Cliente):

public class ClientTaskService : ITaskService
{
    // Inyecta HttpClient aquí.
    public async Task<List<TaskItem>> GetTasksAsync() =>
        await _http.GetFromJsonAsync<List<TaskItem>>("api/tasks");
}

Al registrar cada uno en su respectivo Program.cs, el componente UI será completamente agnóstico al entorno. Blazor inyectará el correcto sin que el componente sepa nada:

// En Program.cs del servidor
builder.Services.AddScoped<ITaskService, ServerTaskService>();

// En Program.cs del cliente
builder.Services.AddScoped<ITaskService, ClientTaskService>();

Trade-off: Este patrón implica mantener dos implementaciones sincronizadas. Si cambias la firma de ITaskService, debes actualizar ambas. Es el precio de la flexibilidad cross-mode.

3. El patrón Backend-For-Frontend (BFF)

Para que el ClientTaskService pueda obtener datos, el servidor debe exponer una API. No uses controladores pesados si no los necesitas. Las Minimal APIs son las mejores amigas de Blazor Web Apps:

// En Program.cs del servidor
app.MapGet("/api/tasks", async (ITaskService taskService) =>
    await taskService.GetTasksAsync());

Esto crea una fachada segura (BFF) donde tu servidor actúa de puente exclusivo para tu propio cliente WASM, simplificando el manejo de CORS y seguridad.

La pieza que une todo es la configuración del HttpClient en el Program.cs del proyecto cliente:

// En Program.cs del cliente
builder.Services.AddScoped(sp => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

Al usar builder.HostEnvironment.BaseAddress, el cliente siempre apunta al servidor que lo originó, sin importar si estamos en desarrollo o producción. Sin esta línea, el ClientTaskService no sabría a dónde llamar.

4. Autenticación: El monstruo final de lo híbrido

La autenticación es donde los proyectos híbridos suelen romperse. Si usas JWT, ¿dónde lo guardas en WASM? (Pista: localStorage no es seguro). Si usas Cookies en el servidor, ¿cómo se entera WASM?

La arquitectura moderna en Blazor Web Apps recomienda Autenticación por Cookies en el Servidor con Transferencia de Estado a WASM. Este flujo se divide en tres actos:

  1. Login en SSR puro: El usuario se autentica contra un endpoint tradicional que establece una Cookie HttpOnly.
  2. Transferencia de estado: El servidor serializa los claims del usuario en el HTML inicial usando PersistentComponentState.
  3. Reconstrucción en WASM: El cliente WebAssembly lee ese estado y «despierta» la sesión sin necesidad de llamar a una API externa.

Con esto consigues la seguridad implacable de las cookies gestionadas por el servidor, junto con la velocidad de ejecución asíncrona de WebAssembly.

%%{init: { 'sequence': { 'showSequenceNumbers': true } } }%%
sequenceDiagram
    participant B as Navegador
    participant S as Servidor (SSR)
    participant W as WebAssembly (Cliente)

    Note over B: El usuario inicia sesión
    activate B
    B->>S: Login Form (POST)
    activate S
    
    deactivate B
    S-->>B: Set-Cookie (HttpOnly)
    Note right of S: Crea la cookie de sesión
    S->>S: Serializar User a JSON
    S->>W: Render con PersistentComponentState
    activate W
    Note left of W: Recibe el estado persistido

    W->>W: Leer JSON y reconstruir Claims
    Note over W: La sesión ya está activa
sin llamadas de API adicionales deactivate S Note over W: Wasm toma el control de la UI deactivate W

La pieza clave en el cliente es el PersistentAuthenticationStateProvider. Se encarga de «recoger» el estado que el servidor dejó preparado en el HTML:

public class PersistentAuthenticationStateProvider(PersistentComponentState state)
    : AuthenticationStateProvider
{
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        // Si no hay estado serializado, devolvemos un usuario anónimo
        if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo))
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, userInfo.UserId),
            new Claim(ClaimTypes.Name, userInfo.Name)
        };

        return new AuthenticationState(
            new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies")));
    }
}

Regla de oro: Nunca guardes tokens de autenticación en localStorage desde WASM. Si el servidor ya gestionó la cookie HttpOnly, deja que él sea el guardián. WASM solo necesita saber quién es el usuario, no cómo autenticarlo.

5. Cuándo NO usar Blazor (Y por qué está bien)

Blazor es increíble para la UI, pero no todo tiene que ser un componente. Intentar meterlo todo en Razor es uno de los errores más comunes al llegar a producción:

  • Procesamiento pesado: Si necesitas exportar un Excel de 50MB o procesar imágenes, usa un Minimal API o un Background Service. El ciclo de vida de un componente Blazor no está diseñado para eso.
  • WebHooks y APIs externas: Para integraciones como Stripe o Twilio, usa controladores estándar o Minimal APIs. Evitas el overhead del ciclo de vida de los componentes y el código queda mucho más limpio.
  • Landing pages de marketing: Para la página de inicio pública donde el SEO y el tiempo de carga inicial son críticos, un HTML estático o SSR tradicional suele ser más eficiente que cargar el runtime de Blazor.

En una Blazor Web App conviven Minimal APIs, MVC y SignalR bajo el mismo techo. El mejor arquitecto no es el que usa su herramienta favorita para todo, sino el que sabe cuándo no usarla.

6. Reglas Prácticas Finales

Para que no tengas que pensar en esto todos los días, aquí la checklist completa (también disponible al inicio del artículo para referencia rápida):

  • Si tienes lógica compartida –> Crea un proyecto App.Shared con modelos e interfaces. Nunca en Server ni en Client.
  • Si un servicio usa DbContext –> Regístralo solo en el servidor. En el cliente, su gemelo llama a la API.
  • Si el cliente WASM necesita datos –> Expón un Minimal API en el servidor. Configura BaseAddress en el cliente.
  • Si necesitas autenticación –> Cookie HttpOnly en el servidor + PersistentComponentState para transferir los claims a WASM.
  • Si algo no encaja en un componente –> No lo fuerces. Minimal APIs, Background Services y controladores conviven bajo el mismo techo.

Con todo esto, ya tienes la arquitectura para que tu aplicación escale, sea segura y no se convierta en un monolito de componentes. Pero ahora toca el paso final…

En el próximo y último artículo, veremos «Despliegue y Optimización»: cómo preparar nuestra aplicación para el mundo real, configurando CI/CD y optimizando el tamaño del payload de WebAssembly para una experiencia de usuario instantánea.


Todos los ejemplos de este artículo están extraídos de la Task Manager App, la aplicación de referencia que acompaña esta serie. Si quieres explorar el código completo, ver cómo encajan las piezas o simplemente tener un punto de partida para tus propios experimentos, el repositorio está disponible en GitHub: github.com/jure-ve/TaskManagerApp

Tema Relacionado:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.