Project foundation

Note

Esta estrutura reflete como costumo iniciar projetos C#/.NET. Os exemplos são referências conceituais e podem não cobrir todos os detalhes de implementação; conforme as tecnologias evoluem, alguns podem ficar desatualizados. O que importa é o princípio: entry point como índice, configuração delegada, módulos por domínio.

Três decisões vêm antes da primeira linha de domínio. O editor e o formatador precisam concordar, para que ninguém abra um diff cheio de mudança de espaço em branco. O Program.cs precisa caber numa tela, funcionando como o índice de tudo o que a aplicação liga. E a configuração precisa morar no appsettings, com os segredos guardados fora do repositório. O resto do projeto cresce em cima disso.

Conceitos fundamentais

ConceitoO que é
SDK-style project (projeto no formato SDK)Formato moderno de .csproj: enxuto, sem listar arquivos, com <Project Sdk="...">
Program.cs (ponto de entrada da aplicação)Arquivo top-level statements onde a aplicação é configurada e iniciada
appsettings.json (arquivo de configuração)Configuração estática por ambiente; valores sensíveis ficam em variáveis de ambiente ou secrets
EditorConfig (configuração de editor compartilhada)Arquivo .editorconfig que padroniza indentação, charset e fim de linha entre IDEs
dotnet format (formatador nativo do .NET)Ferramenta CLI que aplica regras de formatação sem dependências externas
User Secrets (segredos de usuário em desenvolvimento)Mecanismo do .NET para armazenar segredos fora do repositório em dev
NuGet (gerenciador de pacotes do .NET)Sistema de pacotes; dependências declaradas no .csproj via <PackageReference>
TargetFramework (framework-alvo do projeto)Define a versão do .NET (net8.0, net9.0); fixar para reproduzibilidade

Preparar o editor antes do primeiro arquivo

Combine indentação, charset e fim de linha antes de escrever código, e o time para de gerar diff onde não houve mudança de lógica. O .editorconfig guarda essas regras e todas as IDEs o respeitam. O dotnet format aplica a formatação e já vem com o SDK, sem instalar nada.

  • EditorConfig: indentação, charset, trailing whitespace
  • dotnet format: formatter nativo do .NET, sem instalação adicional
dotnet format

O Program.cs funciona como índice da aplicação

Quem abre o Program.cs quer saber o que a aplicação tem: banco, autenticação, limite de requisições, quais domínios. Cinco linhas respondem isso. Quando cada registro é escrito ali dentro, o arquivo cresce a cada feature nova e vira o lugar onde todo mundo mexe, com conflito de merge a cada pull request. Delegue cada bloco a um extension method e o Program.cs volta a caber na tela.

❌ Ruim: Program.cs como depósito de toda a configuração
var builder = WebApplication.CreateBuilder(args);

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

builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];
    });

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();

builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddScoped<UserService>();

builder.Services.AddScoped<INotifier, EmailNotifier>();

var app = builder.Build();

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

app.UseHttpsRedirection();
app.UseCors();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();
✅ Bom: Program.cs como índice, configuração delegada
var builder = WebApplication.CreateBuilder(args);
builder.AddAppServices();

var app = builder.Build();
app.UseAppPipeline();
app.Run();

Cada domínio registra as próprias dependências

O Program.cs não conhece DbContext, JwtBearer nem repositório: ele chama quem conhece. O extension method mora junto do domínio que ele registra, então o arquivo que cria a feature de pedidos é o mesmo que a liga na aplicação. Adicionar um domínio novo passa a ser uma linha no agregador, e o resto acontece dentro da pasta dele.

❌ Ruim: dependências de domínio registradas diretamente no Program.cs
// Program.cs: cresce sem controle conforme o projeto evolui
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();

builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddScoped<UserService>();

// cada novo domínio adiciona mais linhas aqui: sem coesão, sem dono
✅ Bom: ponto de entrada agrega os módulos
// AppServiceExtensions.cs
public static class AppServiceExtensions
{
    public static WebApplicationBuilder AddAppServices(this WebApplicationBuilder builder)
    {
        builder.AddDatabase();
        builder.AddAuth();

        builder.AddRateLimiting();
        builder.AddApiDocs();

        builder.AddOrders();
        builder.AddUsers();

        return builder;
    }
}

// AppPipelineExtensions.cs
public static class AppPipelineExtensions
{
    public static WebApplication UseAppPipeline(this WebApplication app)
    {
        app.UseHttpsRedirection();
        app.UseRateLimiter();

        app.UseCors();
        app.UseAuthentication();

        app.UseAuthorization();

        app.MapAppEndpoints();
        app.MapApiDocs();

        return app;
    }
}
✅ Bom: domínio de Orders dono da sua configuração
// Features/Orders/OrdersExtensions.cs
public static class OrdersExtensions
{
    public static WebApplicationBuilder AddOrders(this WebApplicationBuilder builder)
    {
        builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
        builder.Services.AddScoped<OrderService>();

        return builder;
    }

    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapGet("/", OrderEndpoints.FindAll);
        group.MapGet("/{id:guid}", OrderEndpoints.FindById);

        group.MapPost("/", OrderEndpoints.Create);

        return app;
    }
}

Cada domínio lê a própria seção da configuração

builder.Configuration["Auth:Authority"] espalha o nome da chave pelo código como texto. Um erro de digitação devolve null sem reclamar, e o problema aparece depois, na primeira requisição autenticada. O Options pattern lê a seção inteira uma vez e a entrega como um record: o nome da chave existe num lugar só, e o valor ausente aparece no startup.

❌ Ruim: configuração lida com chaves espalhadas
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"]; // chave solta
        options.Audience = builder.Configuration["Auth:Audience"];   // chave solta
    });
✅ Bom: Options pattern, seção tipada por domínio
// Auth/AuthOptions.cs
public record AuthOptions(string Authority, string Audience)
{
    public const string Section = "Auth";
}

// Auth/AuthExtensions.cs
public static class AuthExtensions
{
    public static WebApplicationBuilder AddAuth(this WebApplicationBuilder builder)
    {
        var authOptions = builder.Configuration
            .GetSection(AuthOptions.Section)
            .Get<AuthOptions>()!;

        builder.Services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.Authority = authOptions.Authority;
                options.Audience = authOptions.Audience;
            });

        return builder;
    }
}

Banco de dados

A configuração do DbContext mora no extension method de infraestrutura, e a connection string vem do IConfiguration. Escrita direto no código, ela entra no repositório junto com a senha do banco de produção, e trocar de ambiente vira recompilação.

❌ Ruim: DbContext configurado inline no Program.cs com string hardcoded
// Program.cs: acoplado ao SQL Server, connection string exposta
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer("Server=prod-db;Database=App;User=sa;Password=Abc123!"));
✅ Bom: DbContext registrado no módulo de infraestrutura
// Infrastructure/DatabaseExtensions.cs
public static class DatabaseExtensions
{
    public static WebApplicationBuilder AddDatabase(this WebApplicationBuilder builder)
    {
        var connectionString = builder.Configuration.GetConnectionString("Default")!;

        builder.Services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(connectionString));

        return builder;
    }
}

OpenAPI

O .NET 9 gera a documentação da API sem Swashbuckle, com o pacote Microsoft.AspNetCore.OpenApi. Deixe a configuração num extension method e exponha a documentação só em Development: em produção, ela entrega a quem quiser ver o mapa completo das suas rotas. A interface de leitura fica por conta do Scalar, que consome o documento gerado.

❌ Ruim: Swashbuckle inline no Program.cs, exposto em todos os ambientes
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
});

// no pipeline, sem guard de ambiente
app.UseSwagger();
app.UseSwaggerUI();
✅ Bom: OpenAPI nativo, Scalar como UI, apenas em Development
// Infrastructure/ApiDocsExtensions.cs
public static class ApiDocsExtensions
{
    public static WebApplicationBuilder AddApiDocs(this WebApplicationBuilder builder)
    {
        if (!builder.Environment.IsDevelopment())
            return builder;

        builder.Services.AddOpenApi(options =>
        {
            options.AddDocumentTransformer((document, context, ct) =>
            {
                document.Info = new OpenApiInfo
                {
                    Title = "My App API",
                    Version = "v1",
                };
                return Task.CompletedTask;
            });
        });

        return builder;
    }

    public static WebApplication MapApiDocs(this WebApplication app)
    {
        if (!app.Environment.IsDevelopment())
            return app;

        app.MapOpenApi();
        app.MapScalarApiReference(options =>
        {
            options.Title = "My App API";
            options.Theme = ScalarTheme.Purple;
        });

        return app;
    }
}
✅ Bom: aggregator inclui ApiDocs
// AppServiceExtensions.cs
builder.AddDatabase();
builder.AddAuth();

builder.AddRateLimiting();
builder.AddApiDocs();

builder.AddOrders();
builder.AddUsers();

// AppPipelineExtensions.cs
app.UseHttpsRedirection();
app.UseRateLimiter();

app.UseCors();
app.UseAuthentication();

app.UseAuthorization();
app.MapAppEndpoints();

app.MapApiDocs();          // MapOpenApi + Scalar, só em Development

Limite de requisições

O limite de requisições é um middleware (função que roda antes do handler): registra-se como serviço no AddAppServices e entra no pipeline com UseRateLimiter. Cada política ganha um nome e pode valer para a aplicação inteira ou para um endpoint específico. Os números (quantas requisições, em quanto tempo) vêm da configuração, porque eles mudam entre ambientes e você vai querer ajustá-los sem recompilar.

❌ Ruim: rate limiting inline no Program.cs, sem options tipadas
// Program.cs: configuração acoplada, sem separação de responsabilidade
builder.Services.AddRateLimiter(limiter =>
{
    limiter.AddFixedWindowLimiter("default", window =>
    {
        window.PermitLimit = 100;    // literal mágico
        window.Window = TimeSpan.FromSeconds(60); // literal mágico
        window.QueueLimit = 0;
    });
    limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
✅ Bom: rate limiting configurado via extension method
// Infrastructure/RateLimitingExtensions.cs
public static class RateLimitingExtensions
{
    public static WebApplicationBuilder AddRateLimiting(this WebApplicationBuilder builder)
    {
        var options = builder.Configuration
            .GetSection(RateLimitingOptions.Section)
            .Get<RateLimitingOptions>()!;

        builder.Services.AddRateLimiter(limiter =>
        {
            limiter.AddFixedWindowLimiter("default", window =>
            {
                window.PermitLimit = options.PermitLimit;
                window.Window = TimeSpan.FromSeconds(options.WindowSeconds);
                window.QueueLimit = 0;
            });

            limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
        });

        return builder;
    }
}

public record RateLimitingOptions(int PermitLimit, int WindowSeconds)
{
    public const string Section = "RateLimiting";
}
// AppServiceExtensions.cs
builder.AddDatabase();
builder.AddAuth();

builder.AddRateLimiting();
builder.AddOrders();

builder.AddUsers();
✅ Bom: aplicando política por endpoint
group.MapPost("/", OrderEndpoints.Create)
    .RequireRateLimiting("default");

A ordem do pipeline decide o que fica protegido

O middleware roda na ordem em que você o registra, e essa ordem muda o comportamento sem gerar nenhum erro. UseAuthorization antes de UseAuthentication executa a verificação de permissão quando a identidade do usuário ainda não foi resolvida: a rota parece protegida e não está. O limite de requisições entra cedo, antes de autenticação e banco, para que a requisição excedente seja recusada sem gastar trabalho.

UseHttpsRedirection   → redireciona antes de qualquer processamento
UseRateLimiter        → bloqueia cedo, antes de autenticação e I/O
UseCors               → cabeçalhos CORS antes de autenticação
UseAuthentication     → resolve a identidade
UseAuthorization      → usa a identidade resolvida
MapAppEndpoints       → roteia para os handlers
❌ Ruim: UseAuthorization antes de UseAuthentication
app.UseAuthorization();   // identidade ainda não foi resolvida
app.UseAuthentication();  // tarde demais

app.MapAppEndpoints();
✅ Bom: ordem correta do pipeline
app.UseHttpsRedirection();
app.UseRateLimiter();

app.UseCors();
app.UseAuthentication();

app.UseAuthorization();
app.MapAppEndpoints();

Onde cada arquivo mora

src/
├── Program.cs
├── AppServiceExtensions.cs
├── AppPipelineExtensions.cs
├── Features/
│   ├── Orders/
│   │   ├── OrdersExtensions.csAddOrders() + MapOrderEndpoints()
│   │   ├── OrderEndpoints.cs
│   │   ├── OrderService.cs
│   │   └── OrderRequest.cs
│   └── Users/
│       ├── UsersExtensions.cs
│       ├── UserEndpoints.cs
│       └── UserService.cs
└── Infrastructure/
    ├── DatabaseExtensions.csAddDatabase()
    ├── AuthExtensions.csAddAuth()
    └── AppDbContext.cs

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.