Web Api

Гібридна архітектура - Minimal API + Controllers

Комбінування Minimal API та Controllers у одному проєкті, vertical slice architecture, feature folders замість шарів, Carter library для організації endpoints, стратегії розподілу відповідальності.

Гібридна архітектура: Minimal API + Controllers

Вступ: Дилема вибору

Уявіть, що ви починаєте новий проєкт API. Перед вами стоїть вибір:

Minimal API:

app.MapGet("/api/products", async (ProductDbContext db) =>
{
    var products = await db.Products.ToListAsync();
    return Results.Ok(products);
});

Controllers:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<Product>>> GetAll()
    {
        var products = await _db.Products.ToListAsync();
        return Ok(products);
    }
}

Проблема: Обидва підходи мають свої переваги та недоліки:

ХарактеристикаMinimal APIControllers
Простота✅ Менше boilerplate❌ Більше коду
Організація❌ Все в Program.cs✅ Структуровано
Фільтри❌ Обмежена підтримка✅ Повна підтримка
Model Binding❌ Ручний✅ Автоматичний
Тестування❌ Складніше✅ Простіше
Продуктивність✅ Швидше❌ Повільніше

Питання: Чому б не використовувати обидва підходи у одному проєкті?

Реальний сценарій:

E-commerce API:
├── Products CRUD → Controllers (складна логіка, валідація, фільтри)
├── Health checks → Minimal API (простий endpoint)
├── Metrics → Minimal API (lightweight)
├── Orders CRUD → Controllers (транзакції, бізнес-логіка)
└── Webhooks → Minimal API (швидка обробка)

РішенняГібридна архітектура — комбінування Minimal API та Controllers у одному проєкті, використовуючи кожен підхід там, де він найефективніший.

Передумови: Ця стаття базується на знаннях з курсу Minimal API (17 статей) та попередніх статей Web API Controllers (01-08).

Що ви створите в цій статті

Ми побудуємо E-commerce API з гібридною архітектурою:

1. Стратегія розподілу:

Controllers:
- Products CRUD (складна бізнес-логіка)
- Orders CRUD (транзакції, валідація)

Minimal API:
- Health checks (/health, /health/ready)
- Metrics (/metrics)
- Webhooks (/webhooks/payment)

2. Vertical Slice Architecture:

Features/
├── Products/
│   ├── ProductsController.cs
│   ├── ProductService.cs
│   └── ProductDto.cs
├── Orders/
│   ├── OrdersController.cs
│   ├── OrderService.cs
│   └── OrderDto.cs
└── Health/
    └── HealthEndpoints.cs

3. Carter Library для організації:

public class HealthModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/health", () => Results.Ok("Healthy"));
    }
}

4. Спільні сервіси:

// Використовуються і в Controllers, і в Minimal API
builder.Services.AddScoped<IProductService, ProductService>();

До кінця статті ви зможете:

  • Комбінувати Minimal API та Controllers
  • Організовувати код через feature folders
  • Використовувати Carter для Minimal API endpoints
  • Застосовувати vertical slice architecture
  • Вибирати оптимальний підхід для кожного endpoint

Фундаментальні концепції

Layered vs Vertical Slice Architecture

Traditional Layered Architecture (шари):

Loading diagram...
graph TD
    A[Controllers Layer] --> B[Services Layer]
    B --> C[Repository Layer]
    C --> D[Data Layer]
    
    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#8b5cf6,stroke:#6d28d9,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#10b981,stroke:#059669,color:#ffffff
Controllers/
├── ProductsController.cs
├── OrdersController.cs
└── UsersController.cs

Services/
├── ProductService.cs
├── OrderService.cs
└── UserService.cs

Repositories/
├── ProductRepository.cs
├── OrderRepository.cs
└── UserRepository.cs

Проблеми:

  • ❌ Зміна однієї feature торкається багатьох папок
  • ❌ Складно знайти весь код для однієї feature
  • ❌ Залежності між шарами

Vertical Slice Architecture (вертикальні зрізи):

Loading diagram...
graph LR
    A[Products Feature] --> A1[Controller]
    A --> A2[Service]
    A --> A3[Repository]
    A --> A4[DTOs]
    
    B[Orders Feature] --> B1[Controller]
    B --> B2[Service]
    B --> B3[Repository]
    B --> B4[DTOs]
    
    C[Users Feature] --> C1[Controller]
    C --> C2[Service]
    C --> C3[Repository]
    C --> C4[DTOs]
    
    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#10b981,stroke:#059669,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
Features/
├── Products/
│   ├── ProductsController.cs
│   ├── ProductService.cs
│   ├── ProductRepository.cs
│   └── ProductDto.cs
├── Orders/
│   ├── OrdersController.cs
│   ├── OrderService.cs
│   ├── OrderRepository.cs
│   └── OrderDto.cs
└── Users/
    ├── UsersController.cs
    ├── UserService.cs
    ├── UserRepository.cs
    └── UserDto.cs

Переваги:

  • ✅ Весь код для feature в одному місці
  • ✅ Легко додавати/видаляти features
  • ✅ Незалежні features (менше конфліктів у команді)
  • ✅ Простіше тестувати

Стратегії розподілу: Коли що використовувати

КритерійMinimal APIControllers
Складність логікиПроста (1-5 рядків)Складна (10+ рядків)
ВалідаціяПроста або відсутняСкладна (DataAnnotations, FluentValidation)
ФільтриНе потрібніПотрібні (auth, logging, validation)
Model BindingПрості параметриСкладні DTO з вкладеними об'єктами
ТестуванняНе критичнеКритичне (unit tests)
ПродуктивністьКритична (high-throughput)Не критична
ДокументаціяПростаСкладна (XML comments, Swagger)

Приклади:

Minimal API — ідеально для:

  • Health checks (/health, /health/ready)
  • Metrics endpoints (/metrics)
  • Webhooks (швидка обробка)
  • Static content (/version, /info)
  • Redirects (//swagger)

Controllers — ідеально для:

  • CRUD операції з валідацією
  • Складна бізнес-логіка
  • Endpoints з багатьма фільтрами
  • API з версіонуванням
  • Endpoints що потребують авторизації

Практична реалізація: E-commerce Hybrid API

Крок 1: Налаштування проєкту

Створення проєкту

bash
$ dotnet new webapi -n EcommerceHybridApi
The template "ASP.NET Core Web API" was created successfully.
$ cd EcommerceHybridApi
$ dotnet add package Microsoft.EntityFrameworkCore.InMemory
info : PackageReference added successfully
$ dotnet add package Carter
info : PackageReference added successfully
Carter — бібліотека для організації Minimal API endpoints у модулі (альтернатива розкиданим app.MapGet у Program.cs).

Структура проєкту (Vertical Slices)

EcommerceHybridApi/
├── Features/
│   ├── Products/
│   │   ├── ProductsController.cs      # Controller-based
│   │   ├── ProductService.cs
│   │   ├── ProductDto.cs
│   │   └── ProductValidator.cs
│   ├── Orders/
│   │   ├── OrdersController.cs        # Controller-based
│   │   ├── OrderService.cs
│   │   └── OrderDto.cs
│   ├── Health/
│   │   └── HealthEndpoints.cs         # Minimal API
│   ├── Metrics/
│   │   └── MetricsEndpoints.cs        # Minimal API
│   └── Webhooks/
│       └── WebhookEndpoints.cs        # Minimal API
├── Shared/
│   ├── Data/
│   │   └── AppDbContext.cs
│   └── Models/
│       ├── Product.cs
│       └── Order.cs
└── Program.cs

Крок 2: Shared Infrastructure

Моделі

Створіть файл Shared/Models/Product.cs:

namespace EcommerceHybridApi.Shared.Models;

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public bool IsActive { get; set; } = true;
}

public class Order
{
    public int Id { get; set; }
    public required string CustomerName { get; set; }
    public List<OrderItem> Items { get; set; } = new();
    public decimal TotalAmount { get; set; }
    public OrderStatus Status { get; set; } = OrderStatus.Pending;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

public class OrderItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public Product? Product { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public enum OrderStatus
{
    Pending,
    Processing,
    Shipped,
    Delivered,
    Cancelled
}

DbContext

Створіть файл Shared/Data/AppDbContext.cs:

using Microsoft.EntityFrameworkCore;
using EcommerceHybridApi.Shared.Models;

namespace EcommerceHybridApi.Shared.Data;

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

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Seed products
        modelBuilder.Entity<Product>().HasData(
            new Product { Id = 1, Name = "Laptop", Price = 1299.99m, Stock = 10 },
            new Product { Id = 2, Name = "Mouse", Price = 29.99m, Stock = 50 },
            new Product { Id = 3, Name = "Keyboard", Price = 79.99m, Stock = 30 }
        );
    }
}

Крок 3: Feature 1 — Products (Controller-based)

ProductDto

Створіть файл Features/Products/ProductDto.cs:

using System.ComponentModel.DataAnnotations;

namespace EcommerceHybridApi.Features.Products;

public record ProductDto
{
    public int Id { get; init; }
    public required string Name { get; init; }
    public decimal Price { get; init; }
    public int Stock { get; init; }
    public bool IsActive { get; init; }
}

public record CreateProductDto
{
    [Required(ErrorMessage = "Product name is required")]
    [MaxLength(200)]
    public required string Name { get; init; }

    [Range(0.01, 1_000_000)]
    public decimal Price { get; init; }

    [Range(0, int.MaxValue)]
    public int Stock { get; init; }
}

public record UpdateProductDto
{
    [Required]
    [MaxLength(200)]
    public required string Name { get; init; }

    [Range(0.01, 1_000_000)]
    public decimal Price { get; init; }

    [Range(0, int.MaxValue)]
    public int Stock { get; init; }
}

ProductService

Створіть файл Features/Products/ProductService.cs:

using Microsoft.EntityFrameworkCore;
using EcommerceHybridApi.Shared.Data;
using EcommerceHybridApi.Shared.Models;

namespace EcommerceHybridApi.Features.Products;

public interface IProductService
{
    Task<List<ProductDto>> GetAllAsync();
    Task<ProductDto?> GetByIdAsync(int id);
    Task<ProductDto> CreateAsync(CreateProductDto dto);
    Task<ProductDto?> UpdateAsync(int id, UpdateProductDto dto);
    Task<bool> DeleteAsync(int id);
}

public class ProductService : IProductService
{
    private readonly AppDbContext _db;
    private readonly ILogger<ProductService> _logger;

    public ProductService(AppDbContext db, ILogger<ProductService> logger)
    {
        _db = db;
        _logger = logger;
    }

    public async Task<List<ProductDto>> GetAllAsync()
    {
        return await _db.Products
            .Where(p => p.IsActive)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price,
                Stock = p.Stock,
                IsActive = p.IsActive
            })
            .ToListAsync();
    }

    public async Task<ProductDto?> GetByIdAsync(int id)
    {
        var product = await _db.Products.FindAsync(id);
        
        if (product is null)
            return null;

        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Stock = product.Stock,
            IsActive = product.IsActive
        };
    }

    public async Task<ProductDto> CreateAsync(CreateProductDto dto)
    {
        var product = new Product
        {
            Name = dto.Name,
            Price = dto.Price,
            Stock = dto.Stock
        };

        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        _logger.LogInformation("Product {ProductId} created: {ProductName}", product.Id, product.Name);

        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Stock = product.Stock,
            IsActive = product.IsActive
        };
    }

    public async Task<ProductDto?> UpdateAsync(int id, UpdateProductDto dto)
    {
        var product = await _db.Products.FindAsync(id);
        
        if (product is null)
            return null;

        product.Name = dto.Name;
        product.Price = dto.Price;
        product.Stock = dto.Stock;

        await _db.SaveChangesAsync();

        _logger.LogInformation("Product {ProductId} updated", product.Id);

        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Stock = product.Stock,
            IsActive = product.IsActive
        };
    }

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

        product.IsActive = false; // Soft delete
        await _db.SaveChangesAsync();

        _logger.LogInformation("Product {ProductId} deleted (soft)", product.Id);

        return true;
    }
}

ProductsController

Створіть файл Features/Products/ProductsController.cs:

using Microsoft.AspNetCore.Mvc;

namespace EcommerceHybridApi.Features.Products;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(IProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    /// <summary>
    /// Отримати всі продукти
    /// </summary>
    [HttpGet]
    [ProducesResponseType(typeof(List<ProductDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<List<ProductDto>>> GetAll()
    {
        var products = await _productService.GetAllAsync();
        return Ok(products);
    }

    /// <summary>
    /// Отримати продукт за ID
    /// </summary>
    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        var product = await _productService.GetByIdAsync(id);
        
        if (product is null)
            return NotFound();

        return Ok(product);
    }

    /// <summary>
    /// Створити новий продукт
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ProductDto>> Create([FromBody] CreateProductDto dto)
    {
        var product = await _productService.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }

    /// <summary>
    /// Оновити продукт
    /// </summary>
    [HttpPut("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> Update(int id, [FromBody] UpdateProductDto dto)
    {
        var product = await _productService.UpdateAsync(id, dto);
        
        if (product is null)
            return NotFound();

        return Ok(product);
    }

    /// <summary>
    /// Видалити продукт
    /// </summary>
    [HttpDelete("{id:int}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _productService.DeleteAsync(id);
        
        if (!deleted)
            return NotFound();

        return NoContent();
    }
}

Чому Controller?

  • ✅ Складна валідація (DataAnnotations)
  • ✅ CRUD операції з бізнес-логікою
  • ✅ Потребує тестування (unit tests для ProductService)
  • ✅ XML коментарі для Swagger

Крок 4: Feature 2 — Health Checks (Minimal API)

Створіть файл Features/Health/HealthEndpoints.cs:

using Carter;
using Microsoft.EntityFrameworkCore;
using EcommerceHybridApi.Shared.Data;

namespace EcommerceHybridApi.Features.Health;

public class HealthEndpoints : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/health")
            .WithTags("Health")
            .WithOpenApi();

        // Basic health check
        group.MapGet("", () => Results.Ok(new
        {
            status = "Healthy",
            timestamp = DateTime.UtcNow
        }))
        .WithName("GetHealth")
        .WithSummary("Basic health check");

        // Readiness check (перевіряє БД)
        group.MapGet("/ready", async (AppDbContext db) =>
        {
            try
            {
                await db.Database.CanConnectAsync();
                
                return Results.Ok(new
                {
                    status = "Ready",
                    database = "Connected",
                    timestamp = DateTime.UtcNow
                });
            }
            catch (Exception ex)
            {
                return Results.ServiceUnavailable(new
                {
                    status = "Not Ready",
                    database = "Disconnected",
                    error = ex.Message,
                    timestamp = DateTime.UtcNow
                });
            }
        })
        .WithName("GetReadiness")
        .WithSummary("Readiness check with database connection");

        // Liveness check
        group.MapGet("/live", () => Results.Ok(new
        {
            status = "Alive",
            timestamp = DateTime.UtcNow
        }))
        .WithName("GetLiveness")
        .WithSummary("Liveness check");
    }
}

Чому Minimal API?

  • ✅ Проста логіка (1-5 рядків)
  • ✅ Не потребує валідації
  • ✅ Висока продуктивність (викликається часто)
  • ✅ Не потребує тестування

Крок 5: Feature 3 — Metrics (Minimal API)

Створіть файл Features/Metrics/MetricsEndpoints.cs:

using Carter;
using Microsoft.EntityFrameworkCore;
using EcommerceHybridApi.Shared.Data;

namespace EcommerceHybridApi.Features.Metrics;

public class MetricsEndpoints : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/metrics", async (AppDbContext db) =>
        {
            var metrics = new
            {
                products = new
                {
                    total = await db.Products.CountAsync(),
                    active = await db.Products.CountAsync(p => p.IsActive),
                    outOfStock = await db.Products.CountAsync(p => p.Stock == 0)
                },
                orders = new
                {
                    total = await db.Orders.CountAsync(),
                    pending = await db.Orders.CountAsync(o => o.Status == Shared.Models.OrderStatus.Pending),
                    completed = await db.Orders.CountAsync(o => o.Status == Shared.Models.OrderStatus.Delivered)
                },
                timestamp = DateTime.UtcNow
            };

            return Results.Ok(metrics);
        })
        .WithTags("Metrics")
        .WithName("GetMetrics")
        .WithSummary("Get API metrics")
        .WithOpenApi();
    }
}

Чому Minimal API?

  • ✅ Lightweight endpoint
  • ✅ Проста агрегація даних
  • ✅ Не потребує складної логіки

Крок 6: Feature 4 — Webhooks (Minimal API)

Створіть файл Features/Webhooks/WebhookEndpoints.cs:

using Carter;

namespace EcommerceHybridApi.Features.Webhooks;

public class WebhookEndpoints : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/webhooks")
            .WithTags("Webhooks")
            .WithOpenApi();

        // Payment webhook
        group.MapPost("/payment", async (PaymentWebhookDto dto, ILogger<WebhookEndpoints> logger) =>
        {
            logger.LogInformation(
                "Payment webhook received: OrderId={OrderId}, Status={Status}",
                dto.OrderId,
                dto.Status);

            // Швидка обробка (без складної логіки)
            // У production: додати до черги для асинхронної обробки

            return Results.Ok(new { received = true, timestamp = DateTime.UtcNow });
        })
        .WithName("PaymentWebhook")
        .WithSummary("Receive payment webhook");

        // Shipping webhook
        group.MapPost("/shipping", async (ShippingWebhookDto dto, ILogger<WebhookEndpoints> logger) =>
        {
            logger.LogInformation(
                "Shipping webhook received: OrderId={OrderId}, TrackingNumber={TrackingNumber}",
                dto.OrderId,
                dto.TrackingNumber);

            return Results.Ok(new { received = true, timestamp = DateTime.UtcNow });
        })
        .WithName("ShippingWebhook")
        .WithSummary("Receive shipping webhook");
    }
}

public record PaymentWebhookDto(int OrderId, string Status, decimal Amount);
public record ShippingWebhookDto(int OrderId, string TrackingNumber, string Carrier);

Чому Minimal API?

  • ✅ Швидка обробка (webhooks мають timeout)
  • ✅ Проста логіка (логування + відповідь)
  • ✅ Висока продуктивність

Крок 7: Program.cs — Об'єднання всього

using Microsoft.EntityFrameworkCore;
using Carter;
using EcommerceHybridApi.Shared.Data;
using EcommerceHybridApi.Features.Products;

var builder = WebApplication.CreateBuilder(args);

// Database
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("EcommerceDb"));

// Services (спільні для Controllers та Minimal API)
builder.Services.AddScoped<IProductService, ProductService>();

// Controllers
builder.Services.AddControllers();

// Carter (для Minimal API modules)
builder.Services.AddCarter();

// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new()
    {
        Title = "E-commerce Hybrid API",
        Version = "v1",
        Description = "API combining Controllers and Minimal API"
    });
});

var app = builder.Build();

// Seed database
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.EnsureCreated();
}

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

app.UseHttpsRedirection();
app.UseAuthorization();

// Controllers endpoints
app.MapControllers();

// Minimal API endpoints (через Carter)
app.MapCarter();

// Root redirect
app.MapGet("/", () => Results.Redirect("/swagger"))
    .ExcludeFromDescription();

app.Run();

Декомпозиція:

  1. AddControllers() — реєструє Controllers
  2. AddCarter() — реєструє Carter modules (автоматично знаходить ICarterModule)
  3. MapControllers() — мапить Controller endpoints
  4. MapCarter() — мапить Minimal API endpoints з Carter modules
  5. Спільні сервісиIProductService доступний і в Controllers, і в Minimal API

Крок 8: Тестування

bash
$ dotnet run
info: Now listening on: https://localhost:5001
# Тест 1: Controller endpoint (Products)
$ curl https://localhost:5001/api/products
HTTP/1.1 200 OK
[
{ "id": 1, "name": "Laptop", "price": 1299.99 },
{ "id": 2, "name": "Mouse", "price": 29.99 }
]
# Тест 2: Minimal API endpoint (Health)
$ curl https://localhost:5001/health
HTTP/1.1 200 OK
{
"status": "Healthy",
"timestamp": "2024-01-15T10:30:00Z"
}
# Тест 3: Minimal API endpoint (Metrics)
$ curl https://localhost:5001/metrics
HTTP/1.1 200 OK
{
"products": { "total": 3, "active": 3, "outOfStock": 0 },
"orders": { "total": 0, "pending": 0, "completed": 0 }
}
# Тест 4: Webhook endpoint
$ curl -X POST https://localhost:5001/webhooks/payment \
-H "Content-Type: application/json" \
-d '{"orderId":1,"status":"paid","amount":1299.99}'
HTTP/1.1 200 OK
{ "received": true, "timestamp": "2024-01-15T10:31:00Z" }

Просунуті техніки

1. Спільні Middleware для обох підходів

Middleware працюють однаково для Controllers та Minimal API:

// Correlation ID middleware
app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
        ?? Guid.NewGuid().ToString();

    context.Response.Headers.Append("X-Correlation-ID", correlationId);
    context.Items["CorrelationId"] = correlationId;

    await next();
});

// Працює для обох:
// - /api/products (Controller)
// - /health (Minimal API)

2. Спільні Filters через Endpoint Filters

Endpoint Filters працюють для Minimal API (аналог Action Filters):

public class LoggingEndpointFilter : IEndpointFilter
{
    private readonly ILogger<LoggingEndpointFilter> _logger;

    public LoggingEndpointFilter(ILogger<LoggingEndpointFilter> logger)
    {
        _logger = logger;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var path = context.HttpContext.Request.Path;
        _logger.LogInformation("Executing endpoint: {Path}", path);

        var result = await next(context);

        _logger.LogInformation("Executed endpoint: {Path}", path);

        return result;
    }
}

Використання:

// Для Minimal API
app.MapGet("/metrics", async (AppDbContext db) => { ... })
    .AddEndpointFilter<LoggingEndpointFilter>();

// Для Controllers — використовуйте Action Filters

3. Групування Minimal API endpoints

Carter автоматично групує, але можна і вручну:

var api = app.MapGroup("/api/v1")
    .WithTags("API v1")
    .RequireAuthorization(); // Для всієї групи

api.MapGet("/status", () => Results.Ok("OK"));
api.MapGet("/version", () => Results.Ok("1.0.0"));

4. Міграція з Controllers на Minimal API

Поступова міграція:

Крок 1: Ідентифікуйте прості endpoints

// Простий endpoint — кандидат на міграцію
[HttpGet("version")]
public IActionResult GetVersion()
{
    return Ok(new { version = "1.0.0" });
}

Крок 2: Створіть Minimal API еквівалент

app.MapGet("/api/products/version", () => Results.Ok(new { version = "1.0.0" }))
    .WithTags("Products");

Крок 3: Видаліть Controller метод

Крок 4: Повторіть для інших простих endpoints

5. Feature Toggles для вибору підходу

var useMinimalApi = builder.Configuration.GetValue<bool>("Features:UseMinimalApiForProducts");

if (useMinimalApi)
{
    // Minimal API version
    app.MapGet("/api/products", async (AppDbContext db) =>
    {
        var products = await db.Products.ToListAsync();
        return Results.Ok(products);
    });
}
else
{
    // Controller version (через MapControllers)
    app.MapControllers();
}

Практичні завдання

Рівень 1: Базове розуміння

Завдання 1.1: Вибір підходу

Для кожного endpoint виберіть оптимальний підхід (Controller або Minimal API):

  1. GET /api/users — список користувачів з пагінацією, фільтрацією, сортуванням
  2. GET /health — health check
  3. POST /api/orders — створення замовлення з валідацією та транзакцією
  4. GET /version — версія API
  5. POST /webhooks/stripe — webhook від Stripe

Завдання 1.2: Layered vs Vertical Slice

Яка структура краща для команди з 5 розробників, що працюють над різними features?

Варіант A (Layered):

Controllers/
Services/
Repositories/

Варіант B (Vertical Slice):

Features/
├── Products/
├── Orders/
└── Users/

Рівень 2: Логіка та розширення

Завдання 2.1: Спільний сервіс для обох підходів

Створіть сервіс, що використовується і в Controller, і в Minimal API:

Завдання 2.2: Conditional Routing

Використовуйте різні підходи залежно від environment:

Завдання 2.3: Hybrid Endpoint з Fallback

Створіть endpoint, що використовує Controller, але має Minimal API fallback:


Рівень 3: Архітектура та створення

Завдання 3.1: Feature Module System

Створіть систему модулів, де кожна feature сама реєструє свої endpoints:

Завдання 3.2: Auto-Discovery Feature Modules

Автоматично знаходьте та реєструйте всі feature modules:

Завдання 3.3: Performance Comparison Tool

Створіть endpoint для порівняння продуктивності Controllers vs Minimal API:


Резюме

У цій статті ви навчилися комбінувати Minimal API та Controllers у гібридній архітектурі:

Ключові концепції

1. Vertical Slice Architecture:

  • Організація коду за features замість шарів
  • Кожна feature містить всю логіку (Controller/Endpoints, Service, DTOs)
  • Незалежні features — менше конфліктів у команді

2. Стратегія розподілу:

Використовуйте Controllers для:Використовуйте Minimal API для:
Складної бізнес-логікиПростих endpoints (1-5 рядків)
CRUD з валідацієюHealth checks, metrics
Endpoints з фільтрамиWebhooks (швидка обробка)
API що потребує тестуванняStatic content, redirects
Складного model bindingLightweight endpoints

3. Carter Library:

  • Організація Minimal API у модулі (ICarterModule)
  • Альтернатива розкиданим app.MapGet у Program.cs
  • Автоматичне знаходження та реєстрація modules

4. Спільні компоненти:

  • Сервіси доступні обом підходам
  • Middleware працюють однаково
  • DbContext спільний

Переваги гібридного підходу

Flexibility — використовуйте кращий інструмент для кожної задачі
Performance — Minimal API для high-throughput endpoints
Maintainability — Controllers для складної логіки
Team productivity — vertical slices зменшують конфлікти
Gradual migration — можна поступово мігрувати з одного підходу на інший

Best Practices

Використовуйте vertical slice architecture для великих проєктів
Створюйте спільні сервіси замість дублювання логіки
Документуйте стратегію — коли використовувати який підхід
Групуйте Minimal API через Carter або MapGroup
Тестуйте обидва підходи однаково (integration tests)
Моніторьте продуктивність — порівнюйте підходи

Коли використовувати гібридний підхід:
  • ✅ Великі проєкти з різними типами endpoints
  • ✅ Команди з різним досвідом (деякі знають Controllers, інші Minimal API)
  • ✅ Міграція з Controllers на Minimal API (поступово)
  • ✅ Потрібна висока продуктивність для певних endpoints

Додаткові ресурси

Minimal API vs Controllers


Наступна стаття:Документація API: Swashbuckle, NSwag та генерація клієнтів — production-level документація, XML-коментарі, Swashbuckle фільтри, NSwag для генерації C# та TypeScript клієнтів, Refit автоматичний HTTP-клієнт.