blazor-servicios-datos-arquitectura

Servicios de datos en Blazor: separar lógica e interfaz

  • 5 min

Un servicio de datos es una clase que encapsula la obtención y modificación de datos fuera de los componentes visuales.

Como podemos escribir C# directamente en el bloque @code, es muy fácil caer en la trampa de meter consultas a base de datos, lógica de cálculo de impuestos y validaciones complejas dentro del mismo archivo .razor que pinta un botón.

Esto viola el principio de Separación de Responsabilidades (SoC).

  • Los componentes deben encargarse de Presentar datos y capturar eventos.
  • Los servicios deben encargarse de Obtener y Procesar esos datos.

Vamos a extraer esa lógica a servicios registrados en el contenedor de dependencias.

El problema: lógica de datos en la vista

Para entender la solución, primero miremos el problema. Esto es lo que NO deberíamos hacer en una aplicación real:

<h3>@producto.Nombre</h3>

@code {
    private Producto producto;

    protected override async Task OnInitializedAsync()
    {
        // ⛔ MAL: Acceso a datos directo en el componente.
        // Si queremos usar esto en otra página, tenemos que copiar y pegar código.
        // Imposible de testear unitariamente.
        var db = new MiDbContext();
        producto = db.Productos.First(p => p.Id == 1);
    }
}
Copied!

Definir el modelo de transferencia

Lo primero es tener claro qué datos vamos a mover. Usaremos una clase simple (POCO).

public class ProductoDto
{
    public int Id { get; set; }
    public string Nombre { get; set; } = string.Empty;
    public decimal Precio { get; set; }
}
Copied!

Definir la interfaz

En arquitectura limpia, los componentes no deberían depender de clases concretas (ProductoService), sino de abstracciones (IProductoService). Esto nos permitirá cambiar la implementación en el futuro (por ejemplo, pasar de datos falsos a una API real) sin tocar ni una línea de los componentes.

public interface IProductoService
{
    // Las operaciones serán asíncronas porque accederán a una DB o API
    Task<IReadOnlyList<ProductoDto>> GetProductosAsync();
    Task<ProductoDto?> GetProductoByIdAsync(int id);
}
Copied!

Crear una implementación

Ahora creamos la clase que hace el trabajo sucio. Para este ejemplo, simularemos una base de datos con una lista en memoria y un pequeño retardo (Task.Delay) para imitar la latencia de red.

public class ProductoServiceDummy : IProductoService
{
    // Simulamos una "base de datos"
    private readonly List<ProductoDto> _productos = new()
    {
        new ProductoDto { Id = 1, Nombre = "Laptop Blazor", Precio = 1200 },
        new ProductoDto { Id = 2, Nombre = "Mouse RGB", Precio = 25 },
        new ProductoDto { Id = 3, Nombre = "Teclado Mecánico", Precio = 80 }
    };

    public async Task<IReadOnlyList<ProductoDto>> GetProductosAsync()
    {
        // Simulamos espera de red (útil para probar la UI de carga)
        await Task.Delay(500);
        return _productos.ToList();
    }

    public async Task<ProductoDto?> GetProductoByIdAsync(int id)
    {
        await Task.Delay(500);
        return _productos.FirstOrDefault(p => p.Id == id);
    }
}
Copied!

Hemos llamado a la clase ProductoServiceDummy porque es una implementación provisional. Cuando tengamos la base de datos, podremos crear ProductoServiceSql con la misma interfaz.

Registrar el servicio en DI

Ahora debemos decirle a Blazor: “Cuando alguien te pida un IProductoService, dale una instancia de ProductoServiceDummy.

Vamos al Program.cs:

// Program.cs

// Usamos Scoped porque queremos que los datos vivan durante la sesión del usuario
// pero no se compartan entre todos los usuarios (como pasaría con Singleton).
builder.Services.AddScoped<IProductoService, ProductoServiceDummy>();
Copied!

Consumir el servicio desde un componente

Finalmente, volvemos a nuestro componente .razor. Ahora estará mucho más limpio y enfocado en su trabajo: pintar cosas.

@page "/productos"
@using MiApp.Servicios
@using MiApp.Modelos
@inject IProductoService ProductoService

<h3>Catálogo de Productos</h3>

@if (productos == null)
{
    <p><em>Cargando productos...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Nombre</th>
                <th>Precio</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var prod in productos)
            {
                <tr>
                    <td>@prod.Nombre</td>
                    <td>@prod.Precio €</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private IReadOnlyList<ProductoDto>? productos;

    protected override async Task OnInitializedAsync()
    {
        // El componente solo pide datos, no sabe de dónde vienen.
        productos = await ProductoService.GetProductosAsync();
    }
}
Copied!

Ventajas de esta arquitectura

  1. Reutilización: Si necesitamos la lista de productos en el componente Carrito y en Checkout, inyectamos el mismo servicio. No duplicamos lógica.
  2. Mantenibilidad: Si cambiamos la forma de obtener datos (ej: de SQL Server a MongoDB), solo tocamos la clase ProductoService. Los componentes ni se enteran.
  3. Testabilidad: Podemos hacer Unit Testing de ProductoService sin arrancar la UI. Y podemos probar los componentes inyectándoles un servicio falso (Mock) que devuelva datos controlados.
  4. Operaciones no bloqueantes: Las llamadas asíncronas de E/S permiten que el componente siga atendiendo el flujo de renderizado mientras espera.

¿Y si los datos están en una API externa?

En la mayoría de aplicaciones Blazor (especialmente WebAssembly), los datos no están en memoria, sino en una API REST remota.

La arquitectura no cambia: seguiremos teniendo IProductoService. Lo que cambia es la implementación. En lugar de devolver una lista fija, usaremos HttpClient para llamar al servidor.

Y ese es, precisamente, el tema de nuestro próximo artículo: HttpClient, consumo de APIs REST y uso de IHttpClientFactory.