Уявіть, що ви починаєте новий проєкт 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 API | Controllers |
|---|---|---|
| Простота | ✅ Менше 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 у одному проєкті, використовуючи кожен підхід там, де він найефективніший.
Ми побудуємо 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>();
До кінця статті ви зможете:
Traditional Layered Architecture (шари):
Controllers/
├── ProductsController.cs
├── OrdersController.cs
└── UsersController.cs
Services/
├── ProductService.cs
├── OrderService.cs
└── UserService.cs
Repositories/
├── ProductRepository.cs
├── OrderRepository.cs
└── UserRepository.cs
Проблеми:
Vertical Slice Architecture (вертикальні зрізи):
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
Переваги:
| Критерій | Minimal API | Controllers |
|---|---|---|
| Складність логіки | Проста (1-5 рядків) | Складна (10+ рядків) |
| Валідація | Проста або відсутня | Складна (DataAnnotations, FluentValidation) |
| Фільтри | Не потрібні | Потрібні (auth, logging, validation) |
| Model Binding | Прості параметри | Складні DTO з вкладеними об'єктами |
| Тестування | Не критичне | Критичне (unit tests) |
| Продуктивність | Критична (high-throughput) | Не критична |
| Документація | Проста | Складна (XML comments, Swagger) |
Приклади:
Minimal API — ідеально для:
/health, /health/ready)/metrics)/version, /info)/ → /swagger)Controllers — ідеально для:
app.MapGet у Program.cs).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
Створіть файл 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
}
Створіть файл 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 }
);
}
}
Створіть файл 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; }
}
Створіть файл 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;
}
}
Створіть файл 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?
Створіть файл 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?
Створіть файл 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?
Створіть файл 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?
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();
Декомпозиція:
AddControllers() — реєструє ControllersAddCarter() — реєструє Carter modules (автоматично знаходить ICarterModule)MapControllers() — мапить Controller endpointsMapCarter() — мапить Minimal API endpoints з Carter modulesIProductService доступний і в Controllers, і в Minimal APIMiddleware працюють однаково для 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)
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
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"));
Поступова міграція:
Крок 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
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();
}
Для кожного endpoint виберіть оптимальний підхід (Controller або Minimal API):
GET /api/users — список користувачів з пагінацією, фільтрацією, сортуваннямGET /health — health checkPOST /api/orders — створення замовлення з валідацією та транзакцієюGET /version — версія APIPOST /webhooks/stripe — webhook від StripeЯка структура краща для команди з 5 розробників, що працюють над різними features?
Варіант A (Layered):
Controllers/
Services/
Repositories/
Варіант B (Vertical Slice):
Features/
├── Products/
├── Orders/
└── Users/
Правильна відповідь: Варіант B (Vertical Slice)
Причини:
Створіть сервіс, що використовується і в Controller, і в Minimal API:
1. Interface та Implementation:
public interface IOrderService
{
Task<OrderDto> CreateOrderAsync(CreateOrderDto dto);
Task<OrderDto?> GetOrderAsync(int id);
}
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
private readonly ILogger<OrderService> _logger;
public OrderService(AppDbContext db, ILogger<OrderService> logger)
{
_db = db;
_logger = logger;
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto dto)
{
var order = new Order
{
CustomerName = dto.CustomerName,
TotalAmount = dto.Items.Sum(i => i.Price * i.Quantity)
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} created", order.Id);
return new OrderDto(order.Id, order.CustomerName, order.TotalAmount, order.Status.ToString());
}
public async Task<OrderDto?> GetOrderAsync(int id)
{
var order = await _db.Orders.FindAsync(id);
return order is null ? null : new OrderDto(order.Id, order.CustomerName, order.TotalAmount, order.Status.ToString());
}
}
2. Реєстрація:
builder.Services.AddScoped<IOrderService, OrderService>();
3. Використання у Controller:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<ActionResult<OrderDto>> Create([FromBody] CreateOrderDto dto)
{
var order = await _orderService.CreateOrderAsync(dto);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id)
{
var order = await _orderService.GetOrderAsync(id);
return order is null ? NotFound() : Ok(order);
}
}
4. Використання у Minimal API:
public class OrderEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/api/orders/{id:int}/status", async (int id, IOrderService orderService) =>
{
var order = await orderService.GetOrderAsync(id);
return order is null ? Results.NotFound() : Results.Ok(new { status = order.Status });
})
.WithTags("Orders");
}
}
Переваги:
IOrderService)Використовуйте різні підходи залежно від environment:
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Development: використовуємо Minimal API для швидкого прототипування
app.MapGet("/api/products", async (AppDbContext db) =>
{
var products = await db.Products.ToListAsync();
return Results.Ok(products);
})
.WithTags("Products (Dev)");
}
else
{
// Production: використовуємо Controllers для стабільності
app.MapControllers();
}
Створіть endpoint, що використовує Controller, але має Minimal API fallback:
var useControllers = builder.Configuration.GetValue<bool>("Features:UseControllers", true);
if (useControllers)
{
builder.Services.AddControllers();
// ... після app.Build()
app.MapControllers();
}
else
{
// Fallback на Minimal API
app.MapGet("/api/products", async (AppDbContext db) =>
{
var products = await db.Products.ToListAsync();
return Results.Ok(products);
});
app.MapGet("/api/products/{id:int}", async (int id, AppDbContext db) =>
{
var product = await db.Products.FindAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
}
appsettings.json:
{
"Features": {
"UseControllers": true
}
}
Створіть систему модулів, де кожна feature сама реєструє свої endpoints:
1. Interface:
public interface IFeatureModule
{
void RegisterServices(IServiceCollection services);
void RegisterEndpoints(IEndpointRouteBuilder app);
}
2. Products Feature Module:
namespace EcommerceHybridApi.Features.Products;
public class ProductsModule : IFeatureModule
{
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<IProductService, ProductService>();
// Інші сервіси для Products feature
}
public void RegisterEndpoints(IEndpointRouteBuilder app)
{
// Можна використовувати і Controllers, і Minimal API
var group = app.MapGroup("/api/products")
.WithTags("Products");
// Minimal API endpoints для простих операцій
group.MapGet("/count", async (IProductService service) =>
{
var products = await service.GetAllAsync();
return Results.Ok(new { count = products.Count });
});
// Controllers для складних операцій реєструються через MapControllers()
}
}
3. Extension Method:
public static class FeatureModuleExtensions
{
public static IServiceCollection AddFeatureModules(
this IServiceCollection services,
params IFeatureModule[] modules)
{
foreach (var module in modules)
{
module.RegisterServices(services);
}
return services;
}
public static IEndpointRouteBuilder MapFeatureModules(
this IEndpointRouteBuilder app,
params IFeatureModule[] modules)
{
foreach (var module in modules)
{
module.RegisterEndpoints(app);
}
return app;
}
}
4. Program.cs:
var productsModule = new ProductsModule();
var ordersModule = new OrdersModule();
builder.Services.AddFeatureModules(productsModule, ordersModule);
// ... після app.Build()
app.MapFeatureModules(productsModule, ordersModule);
app.MapControllers(); // Для Controllers з modules
Переваги:
Автоматично знаходьте та реєструйте всі feature modules:
public static class FeatureModuleExtensions
{
public static IServiceCollection AddAllFeatureModules(this IServiceCollection services)
{
var moduleType = typeof(IFeatureModule);
var modules = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => moduleType.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
.Select(type => (IFeatureModule)Activator.CreateInstance(type)!)
.ToList();
foreach (var module in modules)
{
module.RegisterServices(services);
}
// Зберігаємо modules для використання у MapAllFeatureModules
services.AddSingleton<IEnumerable<IFeatureModule>>(modules);
return services;
}
public static IEndpointRouteBuilder MapAllFeatureModules(this IEndpointRouteBuilder app)
{
var modules = app.ServiceProvider.GetRequiredService<IEnumerable<IFeatureModule>>();
foreach (var module in modules)
{
module.RegisterEndpoints(app);
}
return app;
}
}
Program.cs:
builder.Services.AddAllFeatureModules(); // Автоматично знаходить всі IFeatureModule
// ... після app.Build()
app.MapAllFeatureModules();
app.MapControllers();
Переваги:
Створіть endpoint для порівняння продуктивності Controllers vs Minimal API:
public class PerformanceEndpoints : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/performance/compare", async (AppDbContext db) =>
{
var iterations = 1000;
var results = new Dictionary<string, long>();
// Test 1: Minimal API
var sw1 = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
var _ = await db.Products.CountAsync();
}
sw1.Stop();
results["MinimalAPI"] = sw1.ElapsedMilliseconds;
// Test 2: Direct service call (як у Controller)
var productService = app.ServiceProvider.GetRequiredService<IProductService>();
var sw2 = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
var _ = await productService.GetAllAsync();
}
sw2.Stop();
results["ControllerService"] = sw2.ElapsedMilliseconds;
return Results.Ok(new
{
iterations,
results,
winner = results.MinBy(r => r.Value).Key,
difference = $"{Math.Abs(results["MinimalAPI"] - results["ControllerService"])}ms"
});
})
.WithTags("Performance")
.ExcludeFromDescription(); // Не показувати у Swagger
}
}
Результат:
{
"iterations": 1000,
"results": {
"MinimalAPI": 245,
"ControllerService": 267
},
"winner": "MinimalAPI",
"difference": "22ms"
}
У цій статті ви навчилися комбінувати Minimal API та Controllers у гібридній архітектурі:
1. Vertical Slice Architecture:
2. Стратегія розподілу:
| Використовуйте Controllers для: | Використовуйте Minimal API для: |
|---|---|
| Складної бізнес-логіки | Простих endpoints (1-5 рядків) |
| CRUD з валідацією | Health checks, metrics |
| Endpoints з фільтрами | Webhooks (швидка обробка) |
| API що потребує тестування | Static content, redirects |
| Складного model binding | Lightweight endpoints |
3. Carter Library:
ICarterModule)app.MapGet у Program.cs4. Спільні компоненти:
✅ Flexibility — використовуйте кращий інструмент для кожної задачі
✅ Performance — Minimal API для high-throughput endpoints
✅ Maintainability — Controllers для складної логіки
✅ Team productivity — vertical slices зменшують конфлікти
✅ Gradual migration — можна поступово мігрувати з одного підходу на інший
✅ Використовуйте vertical slice architecture для великих проєктів
✅ Створюйте спільні сервіси замість дублювання логіки
✅ Документуйте стратегію — коли використовувати який підхід
✅ Групуйте Minimal API через Carter або MapGroup
✅ Тестуйте обидва підходи однаково (integration tests)
✅ Моніторьте продуктивність — порівнюйте підходи
Carter Library
Vertical Slice Architecture
Feature Folders
Minimal API vs Controllers
HATEOAS та Resource Expansion
Hypermedia as the Engine of Application State, Richardson Maturity Model, HAL формат, LinkGenerator в ASP.NET Core, resource expansion через ?expand=author,comments, sparse fieldsets та self-discoverable API.
Документація API - Swashbuckle, NSwag та генерація клієнтів
Production-level документація для Web API Controllers, XML-коментарі → OpenAPI, Swashbuckle фільтри (IOperationFilter, IDocumentFilter), аутентифікація у Swagger UI, NSwag для генерації C# та TypeScript клієнтів, Refit автоматичний HTTP-клієнт.