.NET 8: ASP.NET CoreでAPIを構築する

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

.NET 8とASP.NET CoreによるREST API構築ガイド

.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プロジェクトが作成されます。

Program.cscsharp
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<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Register business services
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();

// Configure controllers with validation
builder.Services.AddControllers();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// 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設定とスマートな規約によりオブジェクトリレーショナルマッピングを処理します。

Models/Product.cscsharp
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キーワードにより、重要なプロパティが作成時に必ず初期化されることが保証されます。

Models/Category.cscsharp
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;
}
Data/AppDbContext.cscsharp
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マイグレーションで生成されるデータベーススキーマを正確に制御できます。

Entity Framework Core Migrations

マイグレーションはデータベーススキーマをバージョン管理します。dotnet ef migrations add InitialCreateを実行後、dotnet ef database updateで変更を適用します。

DTOとFluentValidationによるバリデーション

DTO(Data Transfer Object)は、ドメインモデルとAPIで公開されるデータを分離します。FluentValidationは、宣言的で保守しやすいバリデーションを提供します。

DTOs/ProductDtos.cscsharp
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は不変かつ簡潔になり、自動的な値の等価性が得られます。

Validators/ProductValidators.cscsharp
using FluentValidation;
using ProductApi.DTOs;

namespace ProductApi.Validators;

public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
    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<UpdateProductDto>
{
    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エラーを返します。

.NETの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

ビジネスサービスと抽象化レイヤー

サービスレイヤーはビジネスロジックとデータベース操作をカプセル化し、テストとメンテナンスを容易にします。

Services/IProductService.cscsharp
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);
}
Services/ProductService.cscsharp
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
        var result = await _context.Products
            .Where(p => p.Id == id)
            .ExecuteDeleteAsync();

        return result > 0;
    }
}

ExecuteDeleteAsync(EF Core 7以降の新機能)を使用することで、削除前のエンティティ読み込みが不要になり、パフォーマンスが向上します。

APIコントローラーとRESTエンドポイント

コントローラーはRESTエンドポイントを公開し、適切なHTTPステータスコード処理によりビジネスサービスへの呼び出しをオーケストレーションします。

Controllers/ProductsController.cscsharp
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 FluentValidation
        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ドキュメントの自動生成のために可能なレスポンスコードを文書化します。

DTOs/PaginatedResponse.cscsharp
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;
}
Route Constraints

{id:int}のような制約を使用することで、ルーティングの競合を防ぎ、フォーマットが正しくない場合は自動的に404を返します。

グローバルエラーハンドリング

エラーハンドリングミドルウェアは、一貫性のある安全なレスポンスのために例外処理を一元化します。

Middleware/ExceptionMiddleware.cscsharp
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>();
    }
}
Program.cs (adding middleware)csharp
var app = builder.Build();

// Exception middleware must be first
app.UseExceptionMiddleware();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
// ... rest of configuration

設定と環境変数

外部化された設定により、コードを変更することなくアプリケーションを異なる環境に適応させることができます。

appsettings.jsonjson
{
  "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"
  }
}
Configuration/ApiSettings.cscsharp
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";
}
Program.cs (injecting configuration)csharp
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によるユニットテスト

ユニットテストは、サービスとコントローラーの動作を分離して検証します。

Tests/ProductServiceTests.cscsharp
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 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、ネイティブのDependency Injectionの組み合わせにより、保守性とテスト容易性に優れたアプリケーションの構築が可能になります。

高品質な.NET APIのチェックリスト

  • DTOをドメインモデルから分離する
  • ビジネスロジック用のサービスレイヤーを実装する
  • 宣言的バリデーションにFluentValidationを使用する
  • グローバルエラーハンドリングミドルウェアを設定する
  • IOptionsで設定を外部化する
  • サービスのユニットテストを作成する
  • Swagger/OpenAPIでAPIをドキュメント化する

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

レイヤードアーキテクチャ(Controller、Services、Repository/DbContext)は関心の分離を促進し、アプリケーションの進化を容易にします。record、requiredプロパティ、ExecuteDeleteAsyncなどの.NET 8の機能は、コードをモダン化すると同時にパフォーマンスを向上させます。

タグ

#dotnet
#aspnet core
#csharp
#rest api
#entity framework

共有

関連記事