Consultas con Joins y resultados anidados usando Dapper en APIs REST
Trae data anidada usando Joins con Dapper sin complicaciones como un profesional ✅

En el desarrollo de APIs REST modernas, es común necesitar representar relaciones uno-a-muchos en la respuesta JSON. Un ejemplo típico es devolver un producto con una lista de etiquetas asociadas. Aunque ORMs como Entity Framework facilitan este tipo de mapeos, a veces buscamos mayor control, mejor rendimiento o simplemente preferimos una alternativa más ligera: Dapper.

En este artículo, te mostraré cómo mapear manualmente una relación uno-a-muchos usando Dapper en un proyecto .NET, para obtener una respuesta JSON con objetos anidados. Partiremos desde una estructura de base de datos sencilla y construiremos paso a paso una consulta con JOIN, mapeo de resultados y una respuesta anidada lista para tu API.

Al final, habrás aprendido cómo lograr algo como esto:

{
  "id": "8c66c639-4cf0-4ed2-8882-efad60ae80e8",
  "description": "Laptop Dell XPS 13",
  "price": 1299.99,
  "tags": [
    {
      "tagId": 1,
      "name": "Electrónica"
    },
    {
      "tagId": 2,
      "name": "Portátiles"
    }
  ]
}

Sin complicaciones, sin frameworks pesados, y con el control total que Dapper ofrece.

Caso de uso: Productos y sus Tags

La base de datos que usaremos tendrá esta estructura:

Aquí te paso el script para que la crees:

-- Crear la base de datos
CREATE DATABASE DapperApiDb;
GO

-- Usar la base de datos
USE DapperApiDb;
GO

-- Crear tabla Products
CREATE TABLE Products (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    Description VARCHAR(255) NOT NULL,
    Price DECIMAL(10, 2) NOT NULL
);

-- Crear tabla Tags
CREATE TABLE Tags (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name VARCHAR(100) NOT NULL,
    ProductId UNIQUEIDENTIFIER NOT NULL,
    FOREIGN KEY (ProductId) REFERENCES Products(Id)
);

-- Declarar variables y realizar los inserts de data de prueba
DECLARE @Id1 UNIQUEIDENTIFIER = NEWID();
DECLARE @Id2 UNIQUEIDENTIFIER = NEWID();
DECLARE @Id3 UNIQUEIDENTIFIER = NEWID();

INSERT INTO Products (Id, Description, Price) VALUES
(@Id1, 'Laptop Dell XPS 13', 1299.99),
(@Id2, 'Teclado Mecánico RGB', 89.50),
(@Id3, 'Monitor 4K LG 27"', 399.00);

INSERT INTO Tags (Name, ProductId) VALUES
('Electrónica', @Id1),
('Portátiles', @Id1),
('Accesorios', @Id2),
('Gaming', @Id2),
('Pantallas', @Id3);

-- Mostrar resultados
SELECT * FROM Products;
SELECT * FROM Tags;

Ahora en tu proyecto de Visual Studio, vamos a tener un proyecto de tipo API RESTful con .NET 9

Tienes que instalar las siguientes librerías:

La estructura de mi proyecto quedó así:

Por supuesto este artículo no pretende enseñar arquitectura de software, tu lo tendrás mejor separado en capas o con la arquitectura que tu prefieras, yo aquí por cuestiones didácticas lo tengo todo en un sólo proyecto.

Cadena de conexión

Así luce mi appsettings.development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=.;Database=DapperApiDb;integrated security=true;trust server certificate=true"
  }
}

Entidades y Dtos

Estas son mis entidades Product y Tag

public class Product
{
    public Guid Id { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
}
public class Tags
{
    public int TagId { get; set; }
    public string Name { get; set; } = string.Empty;
}

Así luce mi DTO:

public class ProductWithTagsDto
{
    public Guid Id { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public List<Tags> Tags { get; set; } = new();
}

Si no sabes qué es DTO entra aquí y si quieres aprender a aplicarlo entra aquí 😉

Repositorios

Tengo mi interface

public interface IProductRepository
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product?> GetByIdAsync(Guid id);
    Task<Guid> InsertAsync(Product product);
    Task<ProductWithTagsDto?> GetProductWithTagsAsync(Guid id);
}

Y su implementación:

public class ProductRepository : IProductRepository
{
    private readonly IConfiguration _configuration;
    private readonly string _connectionString;

    public ProductRepository(IConfiguration configuration)
    {
        _configuration = configuration;
        _connectionString = _configuration.GetConnectionString("DefaultConnection");
    }

    private IDbConnection Connection => new SqlConnection(_connectionString);

    public async Task<IEnumerable<Product>> GetAllAsync()
    {
        const string sql = "SELECT * FROM Products";
        using var db = Connection;
        return await db.QueryAsync<Product>(sql);
    }

    public async Task<Product?> GetByIdAsync(Guid id)
    {
        const string sql = "SELECT * FROM Products WHERE Id = @Id";
        using var db = Connection;
        return await db.QueryFirstOrDefaultAsync<Product>(sql, new { Id = id });
    }
    public async Task<Guid> InsertAsync(Product product)
    {
        product.Id = Guid.NewGuid();

        const string sql = @"
        INSERT INTO Products (Id, Description, Price)
        VALUES (@Id, @Description, @Price);";

        using var db = Connection;
        await db.ExecuteAsync(sql, new
        {
            product.Id,
            product.Description,
            product.Price
        });

        return product.Id;
    }

    public async Task<ProductWithTagsDto?> GetProductWithTagsAsync(Guid id)
    {
        const string sql = @"
        SELECT 
            p.Id, p.Description, p.Price,
            t.Id AS TagId, t.Name
        FROM Products p
        LEFT JOIN Tags t ON p.Id = t.ProductId
        WHERE p.Id = @Id;";

        using var db = Connection;

        ProductWithTagsDto? product = null;

        var lookup = new Dictionary<Guid, ProductWithTagsDto>();

        await db.QueryAsync<ProductWithTagsDto, Tags, ProductWithTagsDto>(
            sql,
            (p, tag) =>
            {
                if (!lookup.TryGetValue(p.Id, out product))
                {
                    product = p;
                    product.Tags = new List<Tags>();
                    lookup.Add(product.Id, product);
                }

                if (tag != null && tag.TagId != 0)
                    product.Tags.Add(tag);

                return product;
            },
            new { Id = id },
            splitOn: "TagId"
        );

        return lookup.Values.FirstOrDefault();
    }
}

Controlador y Program.cs

Este es mi controller:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repository;

    public ProductsController(IProductRepository repository)
    {
        _repository = repository;
    }

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var products = await _repository.GetAllAsync();
        return Ok(products);
    }

    [HttpGet("{Id:guid}")]
    public async Task<IActionResult> GetById(Guid Id)
    {
        var product = await _repository.GetByIdAsync(Id);
        if (product is null) return NotFound();
        return Ok(product);
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] Product product)
    {
        var newId = await _repository.InsertAsync(product);
        return CreatedAtAction(nameof(GetById), new { id = newId }, new { id = newId });
    }

    [HttpGet("{id}/with-tags")]
    public async Task<IActionResult> GetProductWithTags(Guid id)
    {
        var product = await _repository.GetProductWithTagsAsync(id);

        if (product == null)
            return NotFound();

        return Ok(product);
    }
}

Y así luce mi clase Program:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddScoped<IProductRepository, ProductRepository>();


var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Pruebas

En Swagger hacemos la prueba y obtenermos lo que queremos! Puedes probar también los otros endpoints.

Ahora ya sabes como traer data anidada mediante JOINS usando Dapper, genial!

Este proyecto se encuentra en mi github aquí, dale una estrella y sígueme capo.

Si este artículo te ha gustado, y espero que así sea, compártelo con todo tu equipo de ingeniería crack! 🐿️🔥🥳

Créditos de imagen de portada: Basado en el zorrito de la Foto de Ray Hennessy en Unsplash

Deja una respuesta

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