blazor-servicios-datos-arquitectura

Data Services in Blazor: Separating Logic and Interface

  • 5 min

A data service is a class that encapsulates data retrieval and modification outside the visual components.

Since we can write C# directly in the @code block, it’s very easy to fall into the trap of placing database queries, tax calculation logic, and complex validations inside the same .razor file that renders a button.

This violates the Separation of Concerns (SoC) principle.

  • Components should handle Presenting data and capturing events.
  • Services should handle Retrieving and Processing that data.

We will extract this logic into services registered in the dependency injection container.

The Problem: Data Logic in the View

To understand the solution, let’s first look at the problem. This is what we should NOT do in a real application:

<h3>@producto.Nombre</h3>

@code {
    private Producto producto;

    protected override async Task OnInitializedAsync()
    {
        // ⛔ BAD: Direct data access in the component.
        // If we want to use this in another page, we have to copy and paste code.
        // Impossible to unit test.
        var db = new MiDbContext();
        producto = db.Productos.First(p => p.Id == 1);
    }
}
Copied!

Define the Transfer Model

The first thing is to be clear about what data we will move. We will use a simple (POCO) class.

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

Define the Interface

In clean architecture, components should not depend on concrete classes (ProductoService), but on abstractions (IProductoService). This will allow us to change the implementation in the future (for example, switching from fake data to a real API) without touching a single line of the components.

public interface IProductoService
{
    // Operations will be asynchronous because they will access a DB or API
    Task<IReadOnlyList<ProductoDto>> GetProductosAsync();
    Task<ProductoDto?> GetProductoByIdAsync(int id);
}
Copied!

Create an Implementation

Now we create the class that does the hard work. For this example, we will simulate a database with an in-memory list and a small delay (Task.Delay) to mimic network latency.

public class ProductoServiceDummy : IProductoService
{
    // Simulate a "database"
    private readonly List<ProductoDto> _productos = new()
    {
        new ProductoDto { Id = 1, Nombre = "Laptop Blazor", Precio = 1200 },
        new ProductoDto { Id = 2, Nombre = "RGB Mouse", Precio = 25 },
        new ProductoDto { Id = 3, Nombre = "Mechanical Keyboard", Precio = 80 }
    };

    public async Task<IReadOnlyList<ProductoDto>> GetProductosAsync()
    {
        // Simulate network wait (useful for testing loading UI)
        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!

We have named the class ProductoServiceDummy because it is a temporary implementation. When we have the database, we can create ProductoServiceSql with the same interface.

Register the Service in DI

Now we must tell Blazor: “When someone asks you for an IProductoService, give them an instance of ProductoServiceDummy.

Let’s go to Program.cs:

// Program.cs

// We use Scoped because we want the data to live during the user's session
// but not be shared among all users (as would happen with Singleton).
builder.Services.AddScoped<IProductoService, ProductoServiceDummy>();
Copied!

Consume the Service from a Component

Finally, we return to our .razor component. Now it will be much cleaner and focused on its job: rendering things.

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

<h3>Product Catalog</h3>

@if (productos == null)
{
    <p><em>Loading products...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Name</th>
                <th>Price</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()
    {
        // The component just requests data, it doesn't know where it comes from.
        productos = await ProductoService.GetProductosAsync();
    }
}
Copied!

Advantages of this Architecture

  1. Reusability: If we need the product list in the Cart and Checkout components, we inject the same service. We don’t duplicate logic.
  2. Maintainability: If we change the way data is obtained (e.g., from SQL Server to MongoDB), we only touch the ProductoService class. The components never notice.
  3. Testability: We can unit test ProductoService without starting the UI. And we can test components by injecting a fake service (Mock) that returns controlled data.
  4. Non-blocking operations: Asynchronous I/O calls allow the component to continue handling the render flow while waiting.

What If the Data Is in an External API?

In most Blazor applications (especially WebAssembly), data is not in memory, but in a remote REST API.

The architecture doesn’t change: we will still have IProductoService. What changes is the implementation. Instead of returning a fixed list, we will use HttpClient to call the server.

And that is precisely the topic of our next article: HttpClient, consuming REST APIs, and using IHttpClientFactory.