Sharpyard
← Volver al blog

21 de junio de 2026

Cómo añadir una feature a una minimal API en .NET 10 con Claude Code, test-first (un ejemplo práctico en 20 minutos)

Un recorrido completo y ejecutable: arranca una minimal API en .NET 10 que Claude Code entiende y añade una feature real test-first - prompt, test de contrato en rojo, endpoint fino, verde - en unos 20 minutos.

La forma más rápida de sacarle trabajo real a Claude Code en un proyecto .NET 10 es darle un codebase pequeño y bien formado, un CLAUDE.md que fije las reglas y tests de contrato en los que pueda confiar, y luego conducirlo test-first. Este es un ejemplo práctico completo de justo eso: cogemos una minimal API en .NET 10 y le añadimos una feature real (un campo priority en las notas y un filtro), test-first, en unos 20 minutos. Cada comando y bloque de código de abajo es ejecutable contra el repo de referencia gratuito github.com/Khavel/dotnet-claude-starter, que es la minimal API en .NET 10 que usa este recorrido.

¿Cómo arrancar un proyecto .NET 10 que Claude Code entienda de verdad?

Le das al agente tres cosas de entrada: un CLAUDE.md en la raíz del repo con el mapa de la arquitectura y las reglas, un .mcp.json curado para que use herramientas reales en vez de adivinar, y tests de contrato que fijan el comportamiento. Con eso en su sitio, el agente lee las reglas antes de escribir una línea, y lo que tú revisas es un diff acotado contra una suite de tests en verde.

Empieza desde el starter gratuito, que ya trae las tres piezas conectadas a una API mínima en .NET 10:

git clone https://github.com/Khavel/dotnet-claude-starter.git
cd dotnet-claude-starter
dotnet build      # los warnings son errores aquí, a propósito
dotnet test       # los tests de contrato deberían estar en verde

El repo fija el SDK en global.json (10.0.108, con roll-forward a la última banda de features 10.0.1xx), así que todos - tú y el agente - compiláis contra el mismo toolchain de .NET 10. Luego lanza Claude Code desde la raíz del repo para que recoja CLAUDE.md y .mcp.json automáticamente:

claude

¿Qué va en el CLAUDE.md de una minimal API en .NET 10?

CLAUDE.md es el archivo que Claude Code lee automáticamente, así que es donde pones el mapa de la arquitectura, las convenciones, los comandos exactos y una definición de hecho firme. Mantenlo en unos pocos cientos de tokens: un agente solo sigue lo que de verdad cabe en su contexto. El archivo del starter dice, en esencia: esto es una API de notas en memoria; Program.cs es la raíz de composición, léela primero; Notes/ es la única feature a copiar; los tests son de contrato y arrancan la app real. El bloque de convenciones es la parte que sostiene todo:

## Conventions
- C#: nullable reference types activado, namespaces file-scoped, `records` para datos,
  clases `sealed` por defecto, última versión del lenguaje.
- Endpoints finos: validar -> llamar al store/servicio -> mapear un resultado.
  Nada de lógica de negocio en el lambda del endpoint.
- La persistencia queda detrás de `INoteStore`. Los endpoints nunca tocan el almacenamiento directamente.
- Ningún paquete NuGet nuevo sin una razón de una línea. Esto se mantiene fino a propósito.

## Definition of done (marca cada casilla)
- [ ] `dotnet build` y `dotnet test` en verde.
- [ ] El comportamiento nuevo/cambiado está cubierto por un test de contrato.
- [ ] Ningún endpoint tiene lógica de negocio ni toca el almacenamiento directamente.
- [ ] `dotnet format` no reporta cambios.

Esas reglas no son decoración. “Endpoints finos” y “la persistencia queda detrás de INoteStore” son lo que evita que el agente se invente una arquitectura por capas que no le pediste, y la checklist de definición de hecho es contra lo que se autoverifica antes de decirte que un cambio está terminado.

¿Cómo es el starter de .NET 10 antes del cambio?

Toda la API es una carpeta de feature más una raíz de composición, así que el agente (y tú) podéis sostenerla en contexto. La carpeta Notes/ es la plantilla a copiar: un modelo, una interfaz de persistencia con una implementación en memoria, y endpoints finos.

El modelo son dos records:

namespace Api.Notes;

/// <summary>Una nota. Inmutable; el store es lo único que las crea.</summary>
public record Note(Guid Id, string Title, string Body, DateTimeOffset CreatedAt);

/// <summary>Payload de entrada para crear una nota. Los campos son nullable porque el JSON puede omitirlos.</summary>
public record CreateNoteRequest(string? Title, string? Body);

La persistencia se queda detrás de una interfaz para poder cambiar la implementación sin tocar las rutas:

namespace Api.Notes;

public interface INoteStore
{
    IReadOnlyList<Note> All();
    Note? Find(Guid id);
    Note Add(string title, string body);
}

Los endpoints son finos - validar, llamar al store, mapear un resultado - y se registran en Program.cs:

group.MapGet("/", (INoteStore store) => Results.Ok(store.All()));

group.MapGet("/{id:guid}", (Guid id, INoteStore store) =>
    store.Find(id) is { } note ? Results.Ok(note) : Results.NotFound());

Program.cs es la raíz de composición que lo conecta todo con el hosting mínimo de .NET 10:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddSingleton<INoteStore, InMemoryNoteStore>();

var app = builder.Build();
if (app.Environment.IsDevelopment())
    app.MapOpenApi(); // documento OpenAPI en /openapi/v1.json

app.MapNotesEndpoints();
app.Run();

// Expuesto para que el proyecto de tests arranque la app en memoria con WebApplicationFactory<Program>.
public partial class Program { }

Ese public partial class Program { } del final es el detalle pequeño pero importante que hace posible todo el bucle test-first, que usa la siguiente sección.

¿Cómo escribir un test de contrato que arranque la app real en .NET 10?

Arrancas la aplicación de verdad en memoria con WebApplicationFactory<Program> y la ejercitas sobre HTTP, de modo que el test fija el comportamiento observable y no las tripas. Eso es lo que lo convierte en una red de seguridad contra la que el agente puede refactorizar: si los tests de contrato siguen en verde, la API sigue haciendo lo que los clientes esperan. Los tests del starter usan xUnit con constructor primario e IClassFixture:

using System.Net;
using System.Net.Http.Json;
using Api.Notes;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Api.Tests;

public class NotesApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Posting_a_note_then_fetching_it_round_trips()
    {
        var client = factory.CreateClient();

        var created = await client.PostAsJsonAsync(
            "/api/notes", new { title = "Ship the lead magnet", body = "Hecho conduciendo al agente." });
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);

        var note = await created.Content.ReadFromJsonAsync<Note>();
        Assert.NotNull(note);

        var fetched = await client.GetFromJsonAsync<Note>($"/api/notes/{note!.Id}");
        Assert.Equal(note.Id, fetched!.Id);
    }
}

WebApplicationFactory<Program> (de Microsoft.AspNetCore.Mvc.Testing) arranca la app con su inyección de dependencias y su routing reales, solo que sin un socket de red. Nada de mocks, ningún doble de test para el store - el store en memoria es el real. Ese es justo el tipo de test que un agente debería escribir primero.

El ejemplo práctico: añadir un campo priority, test-first

Este es el cambio real, el mismo del docs/add-a-feature-in-20-min.md del propio repo. La feature es pequeña a propósito; lo que importa es el bucle. Le das a Claude Code un único prompt:

Añade un campo opcional priority a las notas (low | normal | high, por defecto normal) y un filtro GET /api/notes?priority=high. Escribe el test de contrato primero. Mantén los endpoints finos y la validación fuera del store.

Paso 1 - el agente escribe un test de contrato que falla (rojo)

Siguiendo la regla del CLAUDE.md “test de contrato primero”, el agente añade un test que afirma el nuevo comportamiento antes de que exista código de producción:

[Fact]
public async Task Notes_can_be_filtered_by_priority()
{
    var client = factory.CreateClient();
    await client.PostAsJsonAsync("/api/notes", new { title = "Urgent", body = "", priority = "high" });
    await client.PostAsJsonAsync("/api/notes", new { title = "Whenever", body = "" });

    var high = await client.GetFromJsonAsync<List<Note>>("/api/notes?priority=high");

    Assert.NotNull(high);
    Assert.All(high!, n => Assert.Equal("high", n.Priority));
}

dotnet test ahora falla - ni siquiera compila todavía, porque Note no tiene Priority. Ese es el objetivo: el test en rojo es la definición precisa y verificable por máquina de lo que significa “hecho” para este cambio.

Paso 2 - tocar el modelo

Los records hacen del cambio de modelo un one-liner. El agente añade el campo y el campo opcional de la request:

public record Note(Guid Id, string Title, string Body, string Priority, DateTimeOffset CreatedAt);
public record CreateNoteRequest(string? Title, string? Body, string? Priority);

Paso 3 - la validación se queda fuera del store, el filtrado detrás de la interfaz

Los guardrails del CLAUDE.md deciden dónde vive cada pieza de lógica. Normalizar y validar el priority es una regla, así que va junto a la otra validación en el handler del POST; filtrar es asunto del almacenamiento, así que va detrás de INoteStore. Primero la interfaz gana el filtro y el store lo implementa:

public interface INoteStore
{
    IReadOnlyList<Note> All(string? priority = null);
    Note? Find(Guid id);
    Note Add(string title, string body, string priority);
}
public IReadOnlyList<Note> All(string? priority = null) =>
    _notes.Values
        .Where(n => priority is null || n.Priority == priority)
        .OrderByDescending(n => n.CreatedAt)
        .ToList();

Luego los endpoints siguen finos. El GET pasa el filtro tal cual; el POST normaliza y valida los valores permitidos antes de que lleguen al store:

group.MapGet("/", (string? priority, INoteStore store) => Results.Ok(store.All(priority)));

group.MapPost("/", (CreateNoteRequest request, INoteStore store) =>
{
    var title = request.Title?.Trim() ?? string.Empty;
    var body = request.Body?.Trim() ?? string.Empty;
    var priority = request.Priority?.Trim().ToLowerInvariant() ?? "normal";

    var errors = new Dictionary<string, string[]>();
    if (title.Length == 0)
        errors["title"] = ["Title is required."];
    if (priority is not ("low" or "normal" or "high"))
        errors["priority"] = ["Priority must be low, normal, or high."];

    if (errors.Count > 0)
        return Results.ValidationProblem(errors);

    var note = store.Add(title, body, priority);
    return Results.Created($"/api/notes/{note.Id}", note);
});

Fíjate en lo que no pasó: no se coló lógica de negocio en la ruta, y el endpoint nunca tocó el diccionario directamente. Es la convención aguantando, porque el agente la leyó antes de escribir.

Paso 4 - verde, format, hecho

dotnet test       # todo en verde, incluido el nuevo test del filtro
dotnet format     # no reporta cambios

El agente recorre la checklist Definition of done del CLAUDE.md y marca cada casilla: build y tests en verde, comportamiento nuevo cubierto por un test de contrato, sin lógica de negocio en el endpoint, sin paquete nuevo, formatter limpio. Tú revisas un único diff acotado contra una suite que pasa - no 200 líneas de andamiaje especulativo. Eso son los 20 minutos enteros.

¿Por qué .NET 10 encaja bien con este bucle agent-first?

Una minimal API en .NET 10 le da al agente unas barreras inusualmente fuertes: los nullable reference types y TreatWarningsAsErrors convierten clases enteras de errores en fallos de build que el agente tiene que arreglar antes de poder cantar “hecho”, y WebApplicationFactory<Program> hace triviales los tests de contrato reales y rápidos. El agente no puede vender un build en verde a base de humo. El compilador y la suite de tests son dos comprobaciones independientes que tiene que satisfacer, y ambas son baratas de correr dentro del bucle. Es la naturaleza fuertemente tipada y amigable con los tests del .NET moderno jugando a tu favor en vez de ser una carga.

El starter se apoya en esto a propósito: los warnings son errores (Directory.Build.props), el SDK está fijado y los tests de contrato arrancan la app real. Nada de ello es exótico - es la misma disciplina que un desarrollador .NET senior ya valora, dispuesta para que un agente pueda apoyarse en ella.

Preguntas frecuentes

¿Necesito el starter entero o me basta con un CLAUDE.md? Puedes añadir un CLAUDE.md a cualquier solución .NET 10 y obtener casi todo el valor de inmediato. El starter solo te ahorra el montaje: trae el CLAUDE.md, un .mcp.json curado y los tests de contrato con WebApplicationFactory<Program> ya conectados a una minimal API en .NET 10 que funciona, en github.com/Khavel/dotnet-claude-starter. Clónalo, mira la forma y cópiala a tu propio repo.

¿Por qué test-first en vez de dejar que el agente escriba el código y luego los tests? Un test de contrato en rojo es una especificación exacta y verificable por máquina del cambio. El agente escribe contra ese objetivo y tú obtienes una suite en verde que revisar, en vez de fiarte de un diff y esperar que los tests a posteriori de verdad ejerciten el nuevo comportamiento.

¿Esto funciona en Windows con .NET 10? Sí. Claude Code corre de forma nativa en PowerShell, y el CLI de dotnet, dotnet test y dotnet format son los mismos comandos que usa el agente. El pin de global.json mantiene tu máquina y al agente en el mismo SDK de .NET 10.

¿Son realistas los 20 minutos? Para un cambio de este tamaño contra un codebase limpio y bien testeado, sí - la mayor parte del tiempo de reloj es el agente corriendo dotnet test y dotnet format, no tú tecleando. El número escala con el codebase, pero el bucle (prompt, test en rojo, implementación fina, verde) es el mismo en una solución grande; solo que con más iteraciones.

¿Qué servidores MCP usa el starter? Un conjunto deliberadamente pequeño - filesystem y git en .mcp.json, más notas sobre cómo añadir un servidor de C# con Roslyn cuando quieras que el agente razone sobre la solución de forma semántica y no como texto. Añade servidores cuando tengas una necesidad concreta, no por si acaso.

Sobre el autor

Escrito por Khavel, que construye tooling .NET nativo para agentes de IA. La referencia gratuita y de código abierto de este flujo es github.com/Khavel/dotnet-claude-starter - una API mínima en .NET 10 envuelta en la capa de operación (CLAUDE.md, .mcp.json, tests de contrato) que recorre este artículo. Clónalo y conduce a tu agente por el mismo bucle.


¿Construyendo un SaaS de producción en .NET 10 así, agent-first? El starter es la prueba gratuita del flujo; el kit completo lo escala a autenticación, multitenancy y pagos con la misma disciplina de test-first y endpoints finos. Únete a la lista de espera fundadora si esa es la base que quieres.

Relacionado: Claude Code para desarrolladores .NET y MCP para .NET.

El kit completo de SaaS en .NET, nativo para agentes.

Únete a la lista de espera