# .NET 8: ASP.NET Core로 API 구축하기 > .NET 8과 ASP.NET Core를 사용한 전문적인 REST API 구축 완벽 가이드. 컨트롤러, Entity Framework Core, 유효성 검사 및 모범 사례를 설명합니다. - Published: 2026-01-17 - Updated: 2026-04-10 - Author: SharpSkill - Tags: dotnet, aspnet core, csharp, rest api, entity framework - Reading time: 14 min --- .NET 8은 Microsoft 프레임워크의 주요 릴리스로, API 개발에서 성능과 생산성 면에서 상당한 개선을 제공합니다. ASP.NET Core는 C#의 강력한 기능과 현대적이고 모듈화된 아키텍처를 결합하여 엔터프라이즈 애플리케이션에 이상적인 환경을 제공합니다. 이 가이드에서는 초기 설정부터 프로덕션 준비 코드까지, 전문적인 REST API의 완전한 구축 과정을 다룹니다. > **.NET 8 LTS** > > .NET 8은 3년간 지원되는 Long-Term Support(LTS) 릴리스입니다. Minimal API와 Native AOT 개선으로 마이크로서비스 및 클라우드 네이티브 애플리케이션에 최적의 선택이 됩니다. ## .NET 8을 사용한 초기 프로젝트 설정 ASP.NET Core API 프로젝트 생성은 최적화된 프로젝트 구조를 생성하는 .NET CLI를 사용합니다. 필수 NuGet 패키지 구성으로 개발의 기반을 준비합니다. ```bash # terminal # Check installed .NET version dotnet --version # Expected: 8.0.x # Create the API project dotnet new webapi -n ProductApi -o ProductApi cd ProductApi # Add essential packages dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package FluentValidation.AspNetCore dotnet add package Swashbuckle.AspNetCore ``` 이 명령어들은 Entity Framework Core, 유효성 검사, Swagger 문서화에 필요한 종속성이 포함된 API 프로젝트를 생성합니다. ```csharp // Program.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.Services; using FluentValidation; using FluentValidation.AspNetCore; var builder = WebApplication.CreateBuilder(args); // Configure Entity Framework Core with SQL Server builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); // Register business services builder.Services.AddScoped(); builder.Services.AddScoped(); // Configure controllers with validation builder.Services.AddControllers(); builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); // Configure Swagger for documentation builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() { Title = "Product API", Version = "v1" }); }); var app = builder.Build(); // Middleware pipeline if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); ``` 이 구성은 .NET 8의 Minimal API 패턴을 활용하면서, 명확하고 유지보수하기 쉬운 구조를 위해 컨트롤러를 유지합니다. ## 데이터 모델과 Entity Framework Core 모델은 애플리케이션의 비즈니스 엔티티를 나타냅니다. Entity Framework Core는 Fluent 구성과 스마트한 컨벤션으로 객체-관계형 매핑을 처리합니다. ```csharp // Models/Product.cs namespace ProductApi.Models; public class Product { // Primary key with auto-increment public int Id { get; set; } // Required properties (non-nullable in C# 8+) public required string Name { get; set; } public required string Description { get; set; } // Price with decimal precision public decimal Price { get; set; } // Stock with default value public int StockQuantity { get; set; } = 0; // Product status public bool IsActive { get; set; } = true; // Relationship with Category (foreign key) public int CategoryId { get; set; } public Category? Category { get; set; } // Automatic tracking dates public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } ``` C# 11의 `required` 키워드는 필수 속성이 생성 시 항상 초기화되도록 보장합니다. ```csharp // Models/Category.cs namespace ProductApi.Models; public class Category { public int Id { get; set; } public required string Name { get; set; } // Slug for friendly URLs public required string Slug { get; set; } public string? Description { get; set; } // Inverse navigation: list of products in this category public ICollection Products { get; set; } = new List(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } ``` ```csharp // Data/AppDbContext.cs using Microsoft.EntityFrameworkCore; using ProductApi.Models; namespace ProductApi.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } // DbSets for each entity public DbSet Products => Set(); public DbSet Categories => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Product entity configuration modelBuilder.Entity(entity => { // Index on name for fast search entity.HasIndex(p => p.Name); // Price precision: 18 digits, 2 decimals entity.Property(p => p.Price) .HasPrecision(18, 2); // Relationship with Category entity.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); }); // Category entity configuration modelBuilder.Entity(entity => { // Unique slug entity.HasIndex(c => c.Slug).IsUnique(); // Maximum name length entity.Property(c => c.Name).HasMaxLength(100); }); } } ``` Fluent API 구성은 EF Core 마이그레이션으로 생성되는 데이터베이스 스키마에 대한 정밀한 제어를 제공합니다. > **Entity Framework Core Migrations** > > 마이그레이션은 데이터베이스 스키마를 버전 관리합니다. `dotnet ef migrations add InitialCreate`를 실행한 후 `dotnet ef database update`로 변경 사항을 적용합니다. ## DTO와 FluentValidation을 활용한 유효성 검사 DTO(Data Transfer Object)는 도메인 모델과 API를 통해 노출되는 데이터를 분리합니다. FluentValidation은 선언적이고 유지보수하기 쉬운 유효성 검사를 제공합니다. ```csharp // DTOs/ProductDtos.cs namespace ProductApi.DTOs; // DTO for product creation public record CreateProductDto( string Name, string Description, decimal Price, int StockQuantity, int CategoryId ); // DTO for product update public record UpdateProductDto( string? Name, string? Description, decimal? Price, int? StockQuantity, bool? IsActive ); // DTO for response (read) public record ProductDto( int Id, string Name, string Description, decimal Price, int StockQuantity, bool IsActive, string CategoryName, DateTime CreatedAt ); // DTO for list with pagination public record ProductListDto( int Id, string Name, decimal Price, int StockQuantity, bool IsActive, string CategoryName ); ``` C# 9+ record를 사용하면 DTO가 불변적이고 간결해지며, 자동 값 동등성이 제공됩니다. ```csharp // Validators/ProductValidators.cs using FluentValidation; using ProductApi.DTOs; namespace ProductApi.Validators; public class CreateProductValidator : AbstractValidator { public CreateProductValidator() { // Name is required and limited to 200 characters RuleFor(x => x.Name) .NotEmpty().WithMessage("Product name is required.") .MaximumLength(200).WithMessage("Name cannot exceed 200 characters."); // Description required with minimum length RuleFor(x => x.Description) .NotEmpty().WithMessage("Description is required.") .MinimumLength(10).WithMessage("Description must contain at least 10 characters."); // Positive price required RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be greater than 0.") .LessThanOrEqualTo(999999.99m).WithMessage("Maximum price is 999,999.99."); // Non-negative stock RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative."); // Valid category RuleFor(x => x.CategoryId) .GreaterThan(0).WithMessage("A valid category is required."); } } public class UpdateProductValidator : AbstractValidator { public UpdateProductValidator() { // Conditional validation: only if value is provided RuleFor(x => x.Name) .MaximumLength(200) .When(x => !string.IsNullOrEmpty(x.Name)); RuleFor(x => x.Price) .GreaterThan(0) .When(x => x.Price.HasValue); RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0) .When(x => x.StockQuantity.HasValue); } } ``` FluentValidation은 ASP.NET Core 유효성 검사 파이프라인에 자동으로 통합되어 구조화된 400 오류를 반환합니다. ## 비즈니스 서비스와 추상화 계층 서비스 계층은 비즈니스 로직과 데이터베이스 작업을 캡슐화하여 테스트와 유지보수를 용이하게 합니다. ```csharp // Services/IProductService.cs using ProductApi.DTOs; namespace ProductApi.Services; public interface IProductService { // Retrieval with pagination Task<(IEnumerable Items, int TotalCount)> GetAllAsync( int page = 1, int pageSize = 10, string? search = null, int? categoryId = null); // Retrieval by ID Task GetByIdAsync(int id); // Creation Task CreateAsync(CreateProductDto dto); // Update Task UpdateAsync(int id, UpdateProductDto dto); // Deletion Task DeleteAsync(int id); } ``` ```csharp // Services/ProductService.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.DTOs; using ProductApi.Models; namespace ProductApi.Services; public class ProductService : IProductService { private readonly AppDbContext _context; public ProductService(AppDbContext context) { _context = context; } public async Task<(IEnumerable Items, int TotalCount)> GetAllAsync( int page = 1, int pageSize = 10, string? search = null, int? categoryId = null) { // Build base query var query = _context.Products .Include(p => p.Category) .AsQueryable(); // Filter by text search if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(p => p.Name.Contains(search) || p.Description.Contains(search)); } // Filter by category if (categoryId.HasValue) { query = query.Where(p => p.CategoryId == categoryId.Value); } // Total count before pagination var totalCount = await query.CountAsync(); // Apply pagination var items = await query .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new ProductListDto( p.Id, p.Name, p.Price, p.StockQuantity, p.IsActive, p.Category!.Name)) .ToListAsync(); return (items, totalCount); } public async Task GetByIdAsync(int id) { // Retrieve with category inclusion var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Map to DTO return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task CreateAsync(CreateProductDto dto) { // Create entity var product = new Product { Name = dto.Name, Description = dto.Description, Price = dto.Price, StockQuantity = dto.StockQuantity, CategoryId = dto.CategoryId }; // Add and save _context.Products.Add(product); await _context.SaveChangesAsync(); // Load category for response await _context.Entry(product) .Reference(p => p.Category) .LoadAsync(); return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task UpdateAsync(int id, UpdateProductDto dto) { // Retrieve existing entity var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Conditional field updates if (!string.IsNullOrEmpty(dto.Name)) product.Name = dto.Name; if (!string.IsNullOrEmpty(dto.Description)) product.Description = dto.Description; if (dto.Price.HasValue) product.Price = dto.Price.Value; if (dto.StockQuantity.HasValue) product.StockQuantity = dto.StockQuantity.Value; if (dto.IsActive.HasValue) product.IsActive = dto.IsActive.Value; // Update modification date product.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Uncategorized", product.CreatedAt); } public async Task DeleteAsync(int id) { // Direct deletion without prior loading var result = await _context.Products .Where(p => p.Id == id) .ExecuteDeleteAsync(); return result > 0; } } ``` `ExecuteDeleteAsync`(EF Core 7+ 신규 기능)를 사용하면 삭제 전 엔티티 로딩이 불필요해져 성능이 향상됩니다. ## API 컨트롤러와 REST 엔드포인트 컨트롤러는 REST 엔드포인트를 노출하고 적절한 HTTP 상태 코드 처리로 비즈니스 서비스 호출을 오케스트레이션합니다. ```csharp // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; using ProductApi.DTOs; using ProductApi.Services; namespace ProductApi.Controllers; [ApiController] [Route("api/[controller]")] [Produces("application/json")] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService = productService; } /// /// Retrieves the list of products with pagination and filters. /// [HttpGet] [ProducesResponseType(typeof(PaginatedResponse), StatusCodes.Status200OK)] public async Task GetAll( [FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? search = null, [FromQuery] int? categoryId = null) { // Validate pagination parameters if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 10; var (items, totalCount) = await _productService.GetAllAsync( page, pageSize, search, categoryId); // Standardized paginated response var response = new PaginatedResponse { Items = items, Page = page, PageSize = pageSize, TotalCount = totalCount, TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) }; return Ok(response); } /// /// Retrieves a product by its identifier. /// [HttpGet("{id:int}")] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetById(int id) { var product = await _productService.GetByIdAsync(id); if (product == null) { return NotFound(new { message = $"Product with ID {id} not found." }); } return Ok(product); } /// /// Creates a new product. /// [HttpPost] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Create([FromBody] CreateProductDto dto) { // Validation is automatic via FluentValidation var product = await _productService.CreateAsync(dto); // Returns 201 with the created resource URL return CreatedAtAction( nameof(GetById), new { id = product.Id }, product); } /// /// Updates an existing product. /// [HttpPut("{id:int}")] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Update(int id, [FromBody] UpdateProductDto dto) { var product = await _productService.UpdateAsync(id, dto); if (product == null) { return NotFound(new { message = $"Product with ID {id} not found." }); } return Ok(product); } /// /// Deletes a product. /// [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { var deleted = await _productService.DeleteAsync(id); if (!deleted) { return NotFound(new { message = $"Product with ID {id} not found." }); } // 204 No Content for successful deletion return NoContent(); } } ``` `ProducesResponseType` 속성은 자동 Swagger 문서 생성을 위해 가능한 응답 코드를 문서화합니다. ```csharp // DTOs/PaginatedResponse.cs namespace ProductApi.DTOs; public class PaginatedResponse { public IEnumerable Items { get; set; } = Enumerable.Empty(); public int Page { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } public int TotalPages { get; set; } public bool HasPreviousPage => Page > 1; public bool HasNextPage => Page < TotalPages; } ``` > **Route Constraints** > > `{id:int}`와 같은 제약 조건을 사용하면 라우팅 충돌을 방지하고, 형식이 올바르지 않을 경우 자동으로 404를 반환합니다. ## 전역 오류 처리 오류 처리 미들웨어는 일관되고 안전한 응답을 위해 예외 처리를 중앙 집중화합니다. ```csharp // Middleware/ExceptionMiddleware.cs using System.Net; using System.Text.Json; namespace ProductApi.Middleware; public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IHostEnvironment _env; public ExceptionMiddleware( RequestDelegate next, ILogger logger, IHostEnvironment env) { _next = next; _logger = logger; _env = env; } public async Task InvokeAsync(HttpContext context) { try { // Continue pipeline await _next(context); } catch (Exception ex) { // Log the error _logger.LogError(ex, "An unhandled exception occurred"); // Prepare response context.Response.ContentType = "application/json"; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; // Different response based on environment var response = _env.IsDevelopment() ? new ErrorResponse( StatusCode: context.Response.StatusCode, Message: ex.Message, Details: ex.StackTrace) : new ErrorResponse( StatusCode: context.Response.StatusCode, Message: "An internal error occurred.", Details: null); // Serialize with camelCase options var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var json = JsonSerializer.Serialize(response, options); await context.Response.WriteAsync(json); } } } // DTO for error responses public record ErrorResponse(int StatusCode, string Message, string? Details); // Extension to register middleware public static class ExceptionMiddlewareExtensions { public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder app) { return app.UseMiddleware(); } } ``` ```csharp // Program.cs (adding middleware) var app = builder.Build(); // Exception middleware must be first app.UseExceptionMiddleware(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // ... rest of configuration ``` ## 구성 및 환경 변수 외부화된 구성을 통해 코드 변경 없이 애플리케이션을 다양한 환경에 맞게 조정할 수 있습니다. ```json // appsettings.json { "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=ProductDb;User Id=sa;Password=YourPassword;TrustServerCertificate=true" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } }, "ApiSettings": { "DefaultPageSize": 10, "MaxPageSize": 100, "ApiVersion": "1.0" } } ``` ```csharp // Configuration/ApiSettings.cs namespace ProductApi.Configuration; public class ApiSettings { public int DefaultPageSize { get; set; } = 10; public int MaxPageSize { get; set; } = 100; public string ApiVersion { get; set; } = "1.0"; } ``` ```csharp // Program.cs (injecting configuration) builder.Services.Configure( builder.Configuration.GetSection("ApiSettings")); // Usage in a service public class ProductService : IProductService { private readonly ApiSettings _settings; public ProductService(IOptions settings) { _settings = settings.Value; } } ``` ## xUnit을 활용한 단위 테스트 단위 테스트는 서비스와 컨트롤러의 동작을 격리하여 검증합니다. ```csharp // Tests/ProductServiceTests.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.DTOs; using ProductApi.Models; using ProductApi.Services; using Xunit; namespace ProductApi.Tests; public class ProductServiceTests { private AppDbContext CreateInMemoryContext() { // Configure in-memory database var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; return new AppDbContext(options); } [Fact] public async Task CreateAsync_ValidDto_ReturnsProductDto() { // Arrange using var context = CreateInMemoryContext(); // Add test category var category = new Category { Id = 1, Name = "Electronics", Slug = "electronics" }; context.Categories.Add(category); await context.SaveChangesAsync(); var service = new ProductService(context); var dto = new CreateProductDto( Name: "Test Product", Description: "Test Description", Price: 99.99m, StockQuantity: 10, CategoryId: 1); // Act var result = await service.CreateAsync(dto); // Assert Assert.NotNull(result); Assert.Equal("Test Product", result.Name); Assert.Equal(99.99m, result.Price); Assert.Equal("Electronics", result.CategoryName); } [Fact] public async Task GetByIdAsync_NonExistent_ReturnsNull() { // Arrange using var context = CreateInMemoryContext(); var service = new ProductService(context); // Act var result = await service.GetByIdAsync(999); // Assert Assert.Null(result); } [Fact] public async Task UpdateAsync_ExistingProduct_UpdatesFields() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Tech", Slug = "tech" }; var product = new Product { Id = 1, Name = "Original Name", Description = "Original Description", Price = 50.00m, StockQuantity = 5, CategoryId = 1 }; context.Categories.Add(category); context.Products.Add(product); await context.SaveChangesAsync(); var service = new ProductService(context); var updateDto = new UpdateProductDto( Name: "Updated Name", Description: null, Price: 75.00m, StockQuantity: null, IsActive: null); // Act var result = await service.UpdateAsync(1, updateDto); // Assert Assert.NotNull(result); Assert.Equal("Updated Name", result.Name); Assert.Equal(75.00m, result.Price); // Fields not provided remain unchanged Assert.Equal(5, result.StockQuantity); } [Fact] public async Task DeleteAsync_ExistingProduct_ReturnsTrue() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Test", Slug = "test" }; var product = new Product { Id = 1, Name = "To Delete", Description = "Will be deleted", Price = 10.00m, CategoryId = 1 }; context.Categories.Add(category); context.Products.Add(product); await context.SaveChangesAsync(); var service = new ProductService(context); // Act var result = await service.DeleteAsync(1); // Assert Assert.True(result); Assert.Null(await context.Products.FindAsync(1)); } [Fact] public async Task GetAllAsync_WithSearch_FiltersResults() { // Arrange using var context = CreateInMemoryContext(); var category = new Category { Id = 1, Name = "Category", Slug = "category" }; context.Categories.Add(category); context.Products.AddRange( new Product { Id = 1, Name = "Apple iPhone", Description = "Phone", Price = 999, CategoryId = 1 }, new Product { Id = 2, Name = "Samsung Galaxy", Description = "Phone", Price = 899, CategoryId = 1 }, new Product { Id = 3, Name = "Apple MacBook", Description = "Laptop", Price = 1999, CategoryId = 1 } ); await context.SaveChangesAsync(); var service = new ProductService(context); // Act var (items, totalCount) = await service.GetAllAsync(search: "Apple"); // Assert Assert.Equal(2, totalCount); Assert.All(items, p => Assert.Contains("Apple", p.Name)); } } ``` 테스트는 프로젝트 루트에서 `dotnet test`로 실행합니다. ## 결론 .NET 8과 ASP.NET Core는 전문적인 REST API를 구축하기 위한 완전하고 고성능인 에코시스템을 제공합니다. 데이터 액세스를 위한 Entity Framework Core, 유효성 검사를 위한 FluentValidation, 네이티브 의존성 주입의 조합으로 유지보수 가능하고 테스트 가능한 애플리케이션을 구축할 수 있습니다. ### 고품질 .NET API 체크리스트 - DTO를 도메인 모델에서 분리 - 비즈니스 로직을 위한 서비스 계층 구현 - 선언적 유효성 검사에 FluentValidation 사용 - 전역 오류 처리 미들웨어 구성 - IOptions로 구성 외부화 - 서비스에 대한 단위 테스트 작성 - Swagger/OpenAPI로 API 문서화 계층화된 아키텍처(Controller, Services, Repository/DbContext)는 관심사의 분리를 촉진하고 애플리케이션의 발전을 용이하게 합니다. record, required 속성, ExecuteDeleteAsync 등 .NET 8 기능은 코드를 현대화하면서 동시에 성능을 향상시킵니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/dotnet/dotnet-8-building-api-aspnet-core