[CS] CARLOS SEIDL EN
Artigos

Claude Code com .NET, Parte 2: o Primeiro Projeto

Parte 2: o kickoff de uma Minimal API em .NET 10 com testes, do pedido em modo plano à skill de revisão antes do commit.

Por 7 min de leitura
  • Claude Code
  • IA
  • .NET 10
  • Minimal API
Capa do artigo: Claude Code com .NET, Parte 2: o Primeiro Projeto

Esta é a Parte 2. Na Parte 1 vimos a configuração: o CLAUDE.md, o settings.json com regras de permissão, os modos de permissão e as skills. Aqui aplicamos tudo no kickoff de uma Minimal API em .NET 10 com testes, e fechamos com boas práticas, quando usar e quando evitar.

Tarefas API: Claude Code em um projeto .NET na prática

O projeto é a Tarefas API: uma Minimal API com lista de tarefas em memória. O repositório mostra cada etapa em um commit.

O projeto Tarefas API como um todo: configuração do Claude Code e código .NET
O projeto Tarefas API como um todo: configuração do Claude Code e código .NET

Passo 1: o repositório. Crie a solução e o git, e abra o Claude na raiz:

Bash
mkdir tarefas-api && cd tarefas-api


git init


dotnet new web -n Tarefas.Api -o src/Tarefas.Api


dotnet new xunit -n Tarefas.Api.Tests -o tests/Tarefas.Api.Tests


claude

Passo 2: o CLAUDE.md. Rode /init para o rascunho e depois enxugue. O que ficou é curto, com comandos, estrutura, convenções e regras de fluxo (o ideal é menos de 200 linhas):

Markdown
# Tarefas API





API mínima em ASP.NET Core (.NET 10) com lista de tarefas em


memória. Visão geral e como rodar: @README.md





## Comandos





- Build: `dotnet build`


- Testes: `dotnet test`


- Rodar a API: `dotnet run --project src/Tarefas.Api`


- Pacotes NuGet: sempre com


  `--source https://api.nuget.org/v3/index.json`





## Estrutura





- `src/Tarefas.Api/Program.cs`: só composição (DI e rotas)


- `src/Tarefas.Api/TarefaEndpoints.cs`: todas as rotas


- `src/Tarefas.Api/TarefaRepositorio.cs`: armazenamento


- `tests/Tarefas.Api.Tests/`: testes de integração (xUnit)





## Convenções





- C# 14, `Nullable` habilitado, namespaces com escopo de arquivo.


- Linhas de código com no máximo 66 colunas.


- Rotas em `/tarefas`, nomes de domínio em português.


- Sem regra de negócio em `Program.cs`.


- Erros de validação voltam como `ValidationProblem` (400).


- Todo endpoint novo vem com pelo menos um teste de sucesso


  e um de erro.





## Fluxo de trabalho





- Antes de mudar mais de um arquivo, proponha um plano curto.


- Rode `dotnet build` e `dotnet test` antes de dizer que


  terminou.


- Não faça commit nem push sem eu pedir.


- Nunca leia nem escreva arquivos `.env`.

Passo 3: permissões. O .claude/settings.json do projeto libera o que é seguro, pergunta nos commits e bloqueia o .env. (Dá para acrescentar a linha $schema do SchemaStore no topo, para autocompletar no editor.)

JSON
{


  "permissions": {


    "allow": [


      "Bash(dotnet build *)",


      "Bash(dotnet test *)",


      "Bash(dotnet run *)",


      "Bash(git status *)",


      "Bash(git diff *)"


    ],


    "ask": [


      "Bash(git commit *)",


      "Bash(git push *)"


    ],


    "deny": [


      "Read(./.env)",


      "Read(./.env.*)",


      "Bash(git push --force *)"


    ]


  }


}

Passo 4: skills. A primeira, novo-endpoint, descreve o roteiro de um endpoint novo. O $ARGUMENTS recebe o que você digita depois do nome (/novo-endpoint DELETE /tarefas/{id} remove uma tarefa):

Markdown
---


name: novo-endpoint


description: >-


  Adiciona um endpoint à Tarefas API seguindo as convenções do


  projeto (rota em TarefaEndpoints.cs, teste em tests/). Use


  quando pedirem um novo endpoint, rota ou recurso HTTP.


argument-hint: "[verbo] [rota] [descrição]"


allowed-tools:


  - Bash(dotnet build *)


  - Bash(dotnet test *)


---





# Novo endpoint





Pedido: $ARGUMENTS





Siga estes passos, nesta ordem:





1. Leia `src/Tarefas.Api/TarefaEndpoints.cs` e copie o estilo


   das rotas existentes (grupo `/tarefas`, `Results.*`).


2. Se precisar de armazenamento novo, altere apenas


   `TarefaRepositorio.cs`. Não coloque lógica em `Program.cs`.


3. Valide a entrada. Erro de validação devolve


   `Results.ValidationProblem` (400).


4. Escreva os testes em `tests/Tarefas.Api.Tests/`: um de


   sucesso e um de erro, usando `WebApplicationFactory<Program>`.


5. Rode `dotnet build` e `dotnet test`. Corrija até passar.


6. Responda com a lista de arquivos alterados e o resultado


   dos testes. Não faça commit.





Linhas de código com no máximo 66 colunas.

A segunda só roda quando você chama, por causa de disable-model-invocation: true. A linha com ! executa o comando antes de o Claude ler a skill e injeta a saída no texto:

Markdown
---


name: pronto-para-commit


description: >-


  Confere se as mudanças atuais estão prontas para commit e


  sugere a mensagem.


disable-model-invocation: true


allowed-tools:


  - Bash(dotnet build *)


  - Bash(dotnet test *)


  - Bash(git diff *)


  - Bash(git status *)


---





# Pronto para commit?





## Arquivos alterados





!`git status --short`





## Instruções





1. Rode `dotnet build` e `dotnet test`.


2. Olhe `git diff` e aponte: linhas com mais de 66 colunas,


   endpoint sem teste, segredo ou `.env` no diff.


3. Se tudo estiver certo, sugira uma mensagem de commit em uma


   linha (imperativo, até 72 caracteres).


4. Não execute `git commit`. Quem decide é o desenvolvedor.

Passo 5: o kickoff em modo plano. Com a configuração pronta, inicie com claude --permission-mode plan e faça o pedido com objetivo, restrições e critério de pronto:

Texto
Vamos começar a Tarefas API. Leia o CLAUDE.md e proponha um


plano antes de editar qualquer arquivo.





Objetivo: Minimal API em .NET 10 com tarefas em memória: criar,


obter por id, listar com filtro por concluída e concluir.


Restrições: sem banco; só pacotes de teste.


Pronto quando: dotnet build e dotnet test passarem.

O plano esperado tem este formato (nomes dos arquivos vindos do CLAUDE.md): 1) tipos Tarefa e NovaTarefa; 2) TarefaRepositorio em memória; 3) rotas em TarefaEndpoints; 4) composição em Program.cs; 5) testes de integração; 6) build e testes. Você lê, corrige o que discordar (por exemplo, "Concluir deve devolver 404 se o id não existe") e aprova com Yes, manually approve edits na primeira vez, para ver cada edição.

O kickoff passo a passo: o que você decide e o que o Claude Code executa
O kickoff passo a passo: o que você decide e o que o Claude Code executa

Passo 6: o código resultante. Os tipos de domínio:

C#
namespace Tarefas.Api;





public record Tarefa(int Id, string Titulo, bool Concluida);





public record NovaTarefa(string? Titulo);

O repositório em memória, seguro para concorrência (ConcurrentDictionary e Interlocked):

C#
using System.Collections.Concurrent;





namespace Tarefas.Api;





public class TarefaRepositorio


{


    private readonly ConcurrentDictionary<int, Tarefa> _itens =


        new();


    private int _ultimoId;





    public IReadOnlyList<Tarefa> Listar(bool? concluida = null) =>


        _itens.Values


            .Where(t => concluida is null


                || t.Concluida == concluida)


            .OrderBy(t => t.Id)


            .ToList();





    public Tarefa? Obter(int id) => _itens.GetValueOrDefault(id);





    public Tarefa Criar(string titulo)


    {


        var id = Interlocked.Increment(ref _ultimoId);


        var tarefa = new Tarefa(id, titulo, false);


        _itens[id] = tarefa;


        return tarefa;


    }





    public Tarefa? Concluir(int id) =>


        _itens.TryGetValue(id, out var atual)


            ? _itens[id] = atual with { Concluida = true }


            : null;


}

As rotas ficam em um método de extensão, como manda o CLAUDE.md, e a validação devolve ValidationProblem (400):

C#
namespace Tarefas.Api;





public static class TarefaEndpoints


{


    public static IEndpointRouteBuilder MapTarefas(


        this IEndpointRouteBuilder app)


    {


        var grupo = app.MapGroup("/tarefas");





        grupo.MapGet("/",


            (TarefaRepositorio repo, bool? concluida) =>


                repo.Listar(concluida));





        grupo.MapGet("/{id:int}",


            (int id, TarefaRepositorio repo) =>


                repo.Obter(id) is { } t


                    ? Results.Ok(t)


                    : Results.NotFound());





        grupo.MapPost("/",


            (NovaTarefa nova, TarefaRepositorio repo) =>


        {


            if (string.IsNullOrWhiteSpace(nova.Titulo))


            {


                return Results.ValidationProblem(


                    new Dictionary<string, string[]>


                    {


                        ["titulo"] = ["Informe o título."]


                    });


            }





            var tarefa = repo.Criar(nova.Titulo.Trim());


            return Results.Created(


                $"/tarefas/{tarefa.Id}", tarefa);


        });





        grupo.MapPost("/{id:int}/concluir",


            (int id, TarefaRepositorio repo) =>


                repo.Concluir(id) is { } t


                    ? Results.Ok(t)


                    : Results.NotFound());





        return app;


    }


}

O Program.cs só compõe. A última linha expõe o Program para os testes de integração:

C#
using Tarefas.Api;





var builder = WebApplication.CreateBuilder(args);


builder.Services.AddSingleton<TarefaRepositorio>();





var app = builder.Build();


app.MapTarefas();


app.Run();





public partial class Program;

Passo 7: os testes. O WebApplicationFactory<Program> sobe a API em memória, sem porta de rede. O pacote Microsoft.AspNetCore.Mvc.Testing entra no projeto de testes:

C#
using System.Net;


using System.Net.Http.Json;


using Microsoft.AspNetCore.Mvc.Testing;


using Tarefas.Api;





namespace Tarefas.Api.Tests;





public class TarefasEndpointsTests(


    WebApplicationFactory<Program> fabrica)


    : IClassFixture<WebApplicationFactory<Program>>


{


    private readonly HttpClient _http = fabrica.CreateClient();





    private async Task<Tarefa> CriarAsync(string titulo)


    {


        var resp = await _http.PostAsJsonAsync(


            "/tarefas", new NovaTarefa(titulo));


        resp.EnsureSuccessStatusCode();


        return (await resp.Content.ReadFromJsonAsync<Tarefa>())!;


    }





    [Fact]


    public async Task Criar_com_titulo_valido_retorna_201()


    {


        var resp = await _http.PostAsJsonAsync(


            "/tarefas", new NovaTarefa("Ler a documentação"));





        Assert.Equal(HttpStatusCode.Created, resp.StatusCode);


        var tarefa = await resp.Content


            .ReadFromJsonAsync<Tarefa>();


        Assert.Equal("Ler a documentação", tarefa!.Titulo);


        Assert.False(tarefa.Concluida);


        Assert.Equal($"/tarefas/{tarefa.Id}",


            resp.Headers.Location!.OriginalString);


    }





    [Fact]


    public async Task Criar_com_titulo_vazio_retorna_400()


    {


        var resp = await _http.PostAsJsonAsync(


            "/tarefas", new NovaTarefa("   "));





        Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);


    }





    [Fact]


    public async Task Obter_inexistente_retorna_404()


    {


        var resp = await _http.GetAsync("/tarefas/999999");





        Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);


    }





    [Fact]


    public async Task Concluir_marca_a_tarefa_como_concluida()


    {


        var criada = await CriarAsync("Rodar os testes");





        var resp = await _http.PostAsync(


            $"/tarefas/{criada.Id}/concluir", null);





        Assert.Equal(HttpStatusCode.OK, resp.StatusCode);


        var tarefa = await resp.Content


            .ReadFromJsonAsync<Tarefa>();


        Assert.True(tarefa!.Concluida);


    }





    [Fact]


    public async Task Listar_filtra_por_concluida()


    {


        var aberta = await CriarAsync("Aberta");


        var feita = await CriarAsync("Feita");


        await _http.PostAsync(


            $"/tarefas/{feita.Id}/concluir", null);





        var abertas = await _http.GetFromJsonAsync<List<Tarefa>>(


            "/tarefas?concluida=false");





        Assert.Contains(abertas!, t => t.Id == aberta.Id);


        Assert.DoesNotContain(abertas!, t => t.Id == feita.Id);


    }


}

Passo 8: build, testes e execução.

Bash
dotnet build


dotnet test


dotnet run --project src/Tarefas.Api

Os testes terminam com Aprovado! Com falha: 0, Aprovado: 5. Com a API no ar, o POST com título válido e o POST com título vazio respondem:

Texto
HTTP/1.1 201 Created


Location: /tarefas/1


{"id":1,"titulo":"Ler a documentação","concluida":false}





HTTP/1.1 400 Bad Request


Content-Type: application/problem+json


{"title":"One or more validation errors occurred.",


 "status":400,"errors":{"titulo":["Informe o título."]}}

Passo 9: revisar e registrar. Chame /pronto-para-commit. A skill roda build e testes, aponta linhas longas e endpoint sem teste, e sugere a mensagem; o git commit continua sendo seu, protegido pela regra ask.

Boas práticas de CLAUDE.md, permissões e segredos

  • CLAUDE.md enxuto: um arquivo gigante dilui as instruções. Meta de menos de 200 linhas; o que vale para só uma parte do código vai para regras por caminho em .claude/rules/. Escreva o que o Claude não descobre sozinho: comandos, convenções e proibições.
  • Permissão específica, não ampla: libere comandos exatos.
JSON
"allow": ["Bash(*)"]


"allow": ["Bash(dotnet test *)", "Bash(git diff *)"]

A primeira linha libera qualquer comando; a segunda, só o necessário. Regras deny valem em todos os modos. - Segredos fora do repositório: nada de chave em CLAUDE.md ou .claude/settings.json (versionados). Use settings.local.json e CLAUDE.local.md, ambos no .gitignore. Em máquinas com um .gitignore global que ignora .claude/, o repositório precisa de !.claude/ para versionar a configuração do time. - Instruções não são garantias: o CLAUDE.md é contexto, não regra imposta. Para bloquear algo de fato, use deny ou um hook. - Skill com efeito colateral é manual: deploy, commit e migração levam disable-model-invocation: true, para só rodarem quando você chamar. - Descrição de skill específica: "ajuda com endpoints" é vaga; "Adiciona um endpoint à Tarefas API (rota em TarefaEndpoints.cs, teste em tests/)" diz quando usar. - Um objetivo por conversa: use /clear ao trocar de tarefa e /rewind quando o rumo errou, em vez de empilhar correções. - Revise o diff, sempre: o agente roda os testes, mas não substitui a leitura. Peça testes de erro, não só de sucesso. - Não confunda autonomia com confiança: modos mais livres (auto, acceptEdits) combinam com código coberto por testes e em repositório com git limpo.

Quando usar o Claude Code em projetos .NET e quando evitar

Use o Claude Code quando:

  • A tarefa envolve vários arquivos e comandos: criar uma feature com testes, migrar um padrão, investigar um erro de build.
  • O projeto tem comandos de build e teste confiáveis, que dão o critério de pronto.
  • Você repete roteiros (novo endpoint, revisão, release) e pode transformá-los em skills.

Evite (ou reduza a autonomia) quando:

  • Não há testes nem build automatizado: o agente não tem como saber se acertou, e você revisará tudo na mão.
  • O trabalho exige decisão de arquitetura que ainda não foi tomada: converse e decida antes, depois peça a execução.
  • Há dados sensíveis, produção ou credenciais ao alcance: isole o ambiente e use regras deny.
  • É uma edição de uma linha: o editor resolve mais rápido.

Se a necessidade for dar acesso do modelo a sistemas externos (banco, API interna), o caminho é o MCP do artigo anterior: um servidor MCP é registrado no Claude Code com claude mcp add.

Bash
claude mcp add --transport stdio pedidos -- \


  dotnet run --project src/PedidosMcpServer --no-build

Conclusão: contexto, limites, ritmo e skills no Claude Code

A jornada tem uma ordem. Primeiro o contexto (CLAUDE.md), depois os limites (settings.json e modos de permissão), depois o ritmo (pedir com objetivo e critério de pronto, planejar, iterar em passos curtos, confirmar o que importa) e, por fim, os atalhos (skills). Cada passo deixou um arquivo no repositório, e o projeto de exemplo saiu compilando e com os cinco testes passando.

A reflexão prática: o que separa uma boa sessão de uma ruim está menos no modelo e mais no que você escreveu antes de pedir. Trate o CLAUDE.md, as regras e as skills como código do projeto: versione, revise em pull request e melhore a cada vez que o Claude errar por falta de contexto.

Referências da Parte 2: docs do Claude Code e repositório

Leia também

Quer levar IA e Claude Code para o fluxo do seu time .NET? Conheça o serviço de backend e arquitetura .NET.

← todos os artigos