Zum Inhalt springen

ASP.NET Core Minimal APIs: REST-APIs ohne Controller

Veröffentlicht am 5. Sept. 2025 | ca. 5 Min. Lesezeit |

Mit .NET 6 hat Microsoft Minimal APIs eingeführt — eine schlanke Alternative zu den klassischen Controller-basierten APIs in ASP.NET Core. Statt Klassen mit [ApiController]-Attribut und Route-Annotationen werden Endpunkte direkt in Program.cs registriert. Das Ergebnis ist deutlich weniger Boilerplate, besonders für einfache Microservices.

Klassische Controller-API vs. Minimal API

Controller-basiert (klassisch)

// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<Product>>> GetAll()
    {
        var products = await _productService.GetAllAsync();
        return Ok(products);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Product>> GetById(int id)
    {
        var product = await _productService.GetByIdAsync(id);
        return product is null ? NotFound() : Ok(product);
    }

    [HttpPost]
    public async Task<ActionResult<Product>> Create([FromBody] ProductDto dto)
    {
        var created = await _productService.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

Minimal API (ab .NET 6)

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

// GET /api/products
app.MapGet("/api/products", async (IProductService service) =>
    await service.GetAllAsync());

// GET /api/products/{id}
app.MapGet("/api/products/{id:int}", async (int id, IProductService service) =>
{
    var product = await service.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

// POST /api/products
app.MapPost("/api/products", async (ProductDto dto, IProductService service) =>
{
    var created = await service.CreateAsync(dto);
    return Results.Created($"/api/products/{created.Id}", created);
});

// DELETE /api/products/{id}
app.MapDelete("/api/products/{id:int}", async (int id, IProductService service) =>
{
    var deleted = await service.DeleteAsync(id);
    return deleted ? Results.NoContent() : Results.NotFound();
});

app.Run();

Vollständiges Setup

Projektstruktur

MyApi/
├── Program.cs              # Einstiegspunkt und Routing
├── Models/
│   ├── Product.cs          # Entität
│   └── ProductDto.cs       # Data Transfer Object (record)
├── Services/
│   ├── IProductService.cs
│   └── ProductService.cs
└── Data/
    └── AppDbContext.cs      # Entity Framework Context

Models als Records

// Models/Product.cs
public record Product(int Id, string Name, decimal Price, string Category);

// Models/ProductDto.cs — für POST/PUT Requests
public record ProductDto(
    [Required] string Name,
    [Range(0.01, double.MaxValue)] decimal Price,
    [Required] string Category
);

Entity Framework mit SQLite (für Entwicklung)

// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    public DbSet<Product> Products => Set<Product>();
}

// Program.cs — EF registrieren
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("Data Source=products.db"));

Service-Layer

// Services/IProductService.cs
public interface IProductService
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product?> GetByIdAsync(int id);
    Task<Product> CreateAsync(ProductDto dto);
    Task<bool> DeleteAsync(int id);
}

// Services/ProductService.cs
public class ProductService : IProductService
{
    private readonly AppDbContext _context;

    public ProductService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Product>> GetAllAsync()
        => await _context.Products.ToListAsync();

    public async Task<Product?> GetByIdAsync(int id)
        => await _context.Products.FindAsync(id);

    public async Task<Product> CreateAsync(ProductDto dto)
    {
        var product = new Product(0, dto.Name, dto.Price, dto.Category);
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        return product;
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product is null) return false;
        _context.Products.Remove(product);
        await _context.SaveChangesAsync();
        return true;
    }
}

Validierung und Fehlerbehandlung

Minimal APIs nutzen IValidator von FluentValidation oder die eingebauten DataAnnotations-Validierung:

// Program.cs mit Validierung
app.MapPost("/api/products", async (
    [FromBody] ProductDto dto,
    IProductService service,
    HttpContext context) =>
{
    // Manuelle Validierung mit DataAnnotations
    var validationResults = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(
        dto,
        new ValidationContext(dto),
        validationResults,
        validateAllProperties: true
    );

    if (!isValid)
    {
        return Results.ValidationProblem(validationResults
            .GroupBy(v => v.MemberNames.FirstOrDefault() ?? "")
            .ToDictionary(g => g.Key, g => g.Select(v => v.ErrorMessage!).ToArray()));
    }

    var created = await service.CreateAsync(dto);
    return Results.Created($"/api/products/{created.Id}", created);
});

Einfacher mit der Route-Group-Extension:

// Endpunkte in Gruppen organisieren (ab .NET 7)
var productsGroup = app.MapGroup("/api/products")
    .WithTags("Products")
    .WithOpenApi();

productsGroup.MapGet("/", GetAll);
productsGroup.MapGet("/{id:int}", GetById);
productsGroup.MapPost("/", Create).AddEndpointFilter<ValidationFilter<ProductDto>>();

OpenAPI / Swagger Integration

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

// ...

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

// Einzelne Endpunkte dokumentieren
app.MapGet("/api/products", async (IProductService service) =>
    await service.GetAllAsync())
    .WithName("GetAllProducts")
    .WithSummary("Alle Produkte abrufen")
    .Produces<IEnumerable<Product>>(200)
    .WithOpenApi();

Vergleich mit PHP/Symfony

Für PHP-Entwickler sind Minimal APIs vergleichbar mit einem Symfony-Controller, der nur Routes definiert:

// Symfony: Route-Definitionen im Controller
#[Route('/api/products')]
class ProductController extends AbstractController
{
    #[Route('', methods: ['GET'])]
    public function list(): JsonResponse { /* ... */ }
}
// C# Minimal API: Route-Definitionen direkt in Program.cs
app.MapGet("/api/products", async (IProductService service) => /* ... */);

Der Hauptunterschied: In Symfony ist ein Controller-Klasse der Organisationsrahmen, in Minimal APIs kann man entweder alles in Program.cs schreiben oder Methoden auslagern.

Wann Minimal APIs, wann Controller?

Kriterium Minimal APIs Controller
Boilerplate Minimal Mehr
Microservices Ideal Akzeptabel
Große APIs Unübersichtlich Besser strukturiert
Filtern/Policies Manuell Attribute-basiert
Konventionen Wenige Viele
Testing Leicht Leicht

Minimal APIs eignen sich für: kleine Microservices, Function-as-a-Service-Szenarien, Prototypen, APIs mit wenigen Endpunkten.

Controller eignen sich für: große APIs mit vielen Endpunkten, wenn Konventionen und Attribute-basierte Policies wichtig sind.

Fazit

Minimal APIs sind nicht der Ersatz für klassische Controller, sondern eine Ergänzung. Sie reduzieren Boilerplate erheblich und machen kleine .NET-APIs zugänglicher — besonders für Entwickler, die aus anderen Sprachen kommen. Für PHP-Entwickler, die C# kennenlernen möchten, sind Minimal APIs ein hervorragender Einstiegspunkt.

Thomas Wunner

Thomas Wunner

Fachinformatiker für Anwendungsentwicklung mit Ausbildereignungsprüfung und über 14 Jahre Erfahrung im Aufbau skalierbarer Webanwendungen mit Symfony und Shopware. Abseits der Tastatur ist Thomas als Rettungsschwimmer in der Wasserwacht aktiv, legt als DJ auf und erkundet die Umgebung auf dem Motorrad.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.