.NET 10: ASP.NET CoreでREST APIを構築する
.NET 10とASP.NET Coreを使用したプロフェッショナルなREST APIの構築に関する完全ガイド。コントローラー、Entity Framework Core、組み込みバリデーション、ベストプラクティスを解説。

.NET 10は現在のLong-Term Support(LTS)リリースであり、Minimal APIのネイティブバリデーション、デフォルトでのOpenAPI 3.1サポート、大幅なパフォーマンス向上を提供する。ASP.NET CoreはC#とモジュラーアーキテクチャを組み合わせ、エンタープライズアプリケーションに適した環境を提供する。本ガイドでは、初期セットアップからデプロイまで、プロダクション対応のREST APIの構築を解説する。
.NET 10は2028年後半までサポートされる。Minimal APIの組み込みバリデーション、OpenAPI 3.1サポート、Native AOTの改善により、新規APIプロジェクトに最適な選択肢となっている。
.NET 10 CLIによるプロジェクトセットアップ
ASP.NET Core APIプロジェクトの作成には、最適化されたプロジェクト構造を生成する.NET CLIを使用する。必須NuGetパッケージの設定により、開発の基盤が整う。
# terminal
# Check installed .NET version
dotnet --version
# Expected: 10.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 Swashbuckle.AspNetCoreこれらのコマンドにより、Entity Framework CoreとSwaggerドキュメントに必要な依存関係を含むAPIプロジェクトが作成される。.NET 10では基本的なバリデーションにFluentValidationは不要となった。
using Microsoft.EntityFrameworkCore;
using ProductApi.Data;
using ProductApi.Services;
var builder = WebApplication.CreateBuilder(args);
// Configure Entity Framework Core with SQL Server
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Register business services
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
// Configure controllers with built-in validation (.NET 10)
builder.Services.AddControllers();
builder.Services.AddValidation();
// Configure OpenAPI 3.1 (default in .NET 10)
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();AddValidation()メソッドはASP.NET Core 10で導入された組み込みバリデーションを有効にし、一般的なシナリオでFluentValidationなどのサードパーティライブラリが不要となる。
データモデルとEntity Framework Core 10
モデルはアプリケーションのビジネスエンティティを表現する。Entity Framework CoreはFluent設定とスマートな規約によりオブジェクトリレーショナルマッピングを処理する。
namespace ProductApi.Models;
public class Product
{
// Primary key with auto-increment
public int Id { get; set; }
// Required properties (non-nullable in C# 14)
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; }
}requiredキーワードにより、重要なプロパティが作成時に必ず初期化されることが保証される。
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<Product> Products { get; set; } = new List<Product>();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}using Microsoft.EntityFrameworkCore;
using ProductApi.Models;
namespace ProductApi.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
// DbSets for each entity
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Product entity configuration
modelBuilder.Entity<Product>(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<Category>(entity =>
{
// Unique slug
entity.HasIndex(c => c.Slug).IsUnique();
// Maximum name length
entity.Property(c => c.Name).HasMaxLength(100);
});
}
}Fluent API設定により、EF Coreマイグレーションで生成されるデータベーススキーマを正確に制御できる。
マイグレーションはデータベーススキーマをバージョン管理する。dotnet ef migrations add InitialCreateを実行後、dotnet ef database updateで変更を適用する。
DTOとASP.NET Core 10の組み込みバリデーション
DTO(Data Transfer Object)は、ドメインモデルとAPIで公開されるデータを分離する。ASP.NET Core 10ではDataAnnotationsを使用した組み込みバリデーションが導入され、ほとんどのケースでサードパーティライブラリが不要となった。
using System.ComponentModel.DataAnnotations;
namespace ProductApi.DTOs;
// DTO for product creation with DataAnnotations
public record CreateProductDto(
[Required(ErrorMessage = "Product name is required.")]
[MaxLength(200, ErrorMessage = "Name cannot exceed 200 characters.")]
string Name,
[Required(ErrorMessage = "Description is required.")]
[MinLength(10, ErrorMessage = "Description must contain at least 10 characters.")]
string Description,
[Range(0.01, 999999.99, ErrorMessage = "Price must be between 0.01 and 999,999.99.")]
decimal Price,
[Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")]
int StockQuantity,
[Required(ErrorMessage = "A valid category is required.")]
int CategoryId
);
// DTO for product update
public record UpdateProductDto(
[MaxLength(200)]
string? Name,
string? Description,
[Range(0.01, 999999.99)]
decimal? Price,
[Range(0, int.MaxValue)]
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# recordを使用することで、DTOは不変かつ簡潔になり、自動的な値の等価性が得られる。組み込みバリデーションはバリデーション失敗時に構造化された400エラーを自動的に返す。ASP.NET Coreパターンの面接対策については、ASP.NET Core面接質問集を参照。
.NETの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
ビジネスサービスと抽象化レイヤー
サービスレイヤーはビジネスロジックとデータベース操作をカプセル化し、テストとメンテナンスを容易にする。
using ProductApi.DTOs;
namespace ProductApi.Services;
public interface IProductService
{
// Retrieval with pagination
Task<(IEnumerable<ProductListDto> Items, int TotalCount)> GetAllAsync(
int page = 1,
int pageSize = 10,
string? search = null,
int? categoryId = null);
// Retrieval by ID
Task<ProductDto?> GetByIdAsync(int id);
// Creation
Task<ProductDto> CreateAsync(CreateProductDto dto);
// Update
Task<ProductDto?> UpdateAsync(int id, UpdateProductDto dto);
// Deletion
Task<bool> DeleteAsync(int id);
}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<ProductListDto> 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<ProductDto?> 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<ProductDto> 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<ProductDto?> 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<bool> DeleteAsync(int id)
{
// Direct deletion without prior loading (EF Core 7+)
var result = await _context.Products
.Where(p => p.Id == id)
.ExecuteDeleteAsync();
return result > 0;
}
}ExecuteDeleteAsyncを使用することで、削除前のエンティティ読み込みが不要になり、パフォーマンスが向上する。高度なパターンについては、.NETのクリーンアーキテクチャを参照。
APIコントローラーとRESTエンドポイント
コントローラーはRESTエンドポイントを公開し、適切なHTTPステータスコード処理によりビジネスサービスへの呼び出しをオーケストレーションする。
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;
}
/// <summary>
/// Retrieves the list of products with pagination and filters.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PaginatedResponse<ProductListDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> 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<ProductListDto>
{
Items = items,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
return Ok(response);
}
/// <summary>
/// Retrieves a product by its identifier.
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Creates a new product.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateProductDto dto)
{
// Validation is automatic via AddValidation()
var product = await _productService.CreateAsync(dto);
// Returns 201 with the created resource URL
return CreatedAtAction(
nameof(GetById),
new { id = product.Id },
product);
}
/// <summary>
/// Updates an existing product.
/// </summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Deletes a product.
/// </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(new { message = $"Product with ID {id} not found." });
}
// 204 No Content for successful deletion
return NoContent();
}
}ProducesResponseType属性は、Swaggerドキュメントの自動生成のために可能なレスポンスコードを文書化する。
namespace ProductApi.DTOs;
public class PaginatedResponse<T>
{
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
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;
}{id:int}のような制約を使用することで、ルーティングの競合を防ぎ、フォーマットが正しくない場合は自動的に404を返す。
グローバルエラーハンドリングミドルウェア
エラーハンドリングミドルウェアは、一貫性のある安全なレスポンスのために例外処理を一元化する。
using System.Net;
using System.Text.Json;
namespace ProductApi.Middleware;
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> 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<ExceptionMiddleware>();
}
}var app = builder.Build();
// Exception middleware must be first
app.UseExceptionMiddleware();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// ... rest of configuration設定と環境変数
外部化された設定により、コードを変更することなくアプリケーションを異なる環境に適応させることができる。
{
"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"
}
}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";
}builder.Services.Configure<ApiSettings>(
builder.Configuration.GetSection("ApiSettings"));
// Usage in a service
public class ProductService : IProductService
{
private readonly ApiSettings _settings;
public ProductService(IOptions<ApiSettings> settings)
{
_settings = settings.Value;
}
}xUnitによるユニットテスト
ユニットテストは、サービスとコントローラーの動作を分離して検証する。
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<AppDbContext>()
.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 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));
}
}テストはプロジェクトルートからdotnet testで実行する。EF Coreパフォーマンスパターンの詳細については、テスト戦略を含む専用ガイドを参照。
Sources
- What's new in ASP.NET Core 10 - 組み込みバリデーション、OpenAPI 3.1サポート
- What's new in .NET 10 - LTSリリースの詳細、C# 14機能
- Entity Framework Core documentation - EF Core 10のアップデート
- Announcing .NET 10 - 公式リリース発表
プロダクション用.NET APIのチェックリスト
.NET 10とASP.NET Coreは、プロフェッショナルなREST APIを構築するための完全で高性能なエコシステムを提供する。組み込みバリデーション、データアクセス用Entity Framework Core、ネイティブDependency Injectionの組み合わせにより、保守性とテスト容易性に優れたアプリケーションの構築が可能となる。
- DTOをドメインモデルから分離する
- ビジネスロジック用のサービスレイヤーを実装する
- DataAnnotationsによる組み込みバリデーションを使用する(.NET 10以降)
- グローバルエラーハンドリングミドルウェアを設定する
- IOptionsで設定を外部化する
- サービスのユニットテストを作成する
- OpenAPI 3.1でAPIをドキュメント化する
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
レイヤードアーキテクチャ(Controller、Services、Repository/DbContext)は関心の分離を促進し、アプリケーションの進化を容易にする。組み込みバリデーション、デフォルトでのOpenAPI 3.1、改善されたNative AOTサポートなどの.NET 10機能は、パフォーマンスを向上させながらAPI開発をモダン化する。
.NET のバグを見つけられますか
実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

執筆
Anthony Fillion-MailletSharpSkill 創業者
10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。
2026年8月22日 更新
タグ
共有
関連記事

.NETによるClean Architecture実践ガイド
C#を用いた.NETでClean Architectureを習得します。SOLID原則、レイヤー分離、保守性の高いアプリケーションを構築するための実装パターンを学びます。

C#/.NET面接質問集:2026年完全ガイド
C#と.NETの技術面接で頻出の17問を徹底解説。LINQ、async/await、依存性注入、Entity Framework Coreからアーキテクチャパターンまで、コード例付きで実践的に学べます。

Entity Framework Core:2026年のパフォーマンス最適化とベストプラクティス
EF Core 10のパフォーマンス最適化を解説。AsNoTracking、コンパイル済みクエリ、バッチ操作、スプリットクエリ、LeftJoin演算子など、.NET 10アプリケーション向けの実践的なC#コード例を紹介します。