# .NET 8 : Créer une API avec ASP.NET Core > Guide complet pour créer une API REST professionnelle avec .NET 8 et ASP.NET Core. Controllers, Entity Framework Core, validation et bonnes pratiques expliqués. - Published: 2026-01-17 - Updated: 2026-03-28 - Author: SharpSkill - Tags: dotnet, aspnet core, csharp, rest api, entity framework - Reading time: 14 min --- .NET 8 représente une version majeure du framework Microsoft, apportant des améliorations significatives en termes de performance et de productivité pour le développement d'APIs. ASP.NET Core combine la puissance du langage C# avec une architecture moderne et modulaire, idéale pour les applications d'entreprise. Ce guide couvre la création complète d'une API REST professionnelle, de la configuration initiale au déploiement. > **.NET 8 LTS** > > .NET 8 est une version Long-Term Support (LTS) avec un support de 3 ans. Les APIs Minimal et les améliorations de Native AOT en font un choix optimal pour les microservices et les applications cloud-native. ## Configuration initiale du projet .NET 8 La création d'un projet ASP.NET Core API utilise le CLI .NET pour générer une structure de projet optimisée. La configuration des packages NuGet essentiels prépare le terrain pour le développement. ```bash # terminal # Vérification de la version .NET installée dotnet --version # 8.0.x attendu # Création du projet API dotnet new webapi -n ProductApi -o ProductApi cd ProductApi # Ajout des packages essentiels dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package FluentValidation.AspNetCore dotnet add package Swashbuckle.AspNetCore ``` Ces commandes créent un projet API avec les dépendances nécessaires pour Entity Framework Core, la validation et la documentation Swagger. ```csharp // Program.cs using Microsoft.EntityFrameworkCore; using ProductApi.Data; using ProductApi.Services; using FluentValidation; using FluentValidation.AspNetCore; var builder = WebApplication.CreateBuilder(args); // Configuration Entity Framework Core avec SQL Server builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); // Enregistrement des services métier builder.Services.AddScoped(); builder.Services.AddScoped(); // Configuration des controllers avec validation builder.Services.AddControllers(); builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddValidatorsFromAssemblyContaining(); // Configuration Swagger pour la 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(); ``` Cette configuration utilise le pattern Minimal API de .NET 8 tout en conservant les controllers pour une structure claire et maintenable. ## Modèles de données et Entity Framework Core Les modèles représentent les entités métier de l'application. Entity Framework Core assure le mapping objet-relationnel avec une configuration fluide et des conventions intelligentes. ```csharp // Models/Product.cs namespace ProductApi.Models; public class Product { // Clé primaire avec auto-incrémentation public int Id { get; set; } // Propriétés requises (non-nullable en C# 8+) public required string Name { get; set; } public required string Description { get; set; } // Prix avec précision décimale public decimal Price { get; set; } // Stock avec valeur par défaut public int StockQuantity { get; set; } = 0; // Statut du produit public bool IsActive { get; set; } = true; // Relation avec Category (clé étrangère) public int CategoryId { get; set; } public Category? Category { get; set; } // Dates de suivi automatiques public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } ``` L'utilisation du mot-clé `required` de C# 11 garantit que les propriétés essentielles sont toujours initialisées lors de la création. ```csharp // Models/Category.cs namespace ProductApi.Models; public class Category { public int Id { get; set; } public required string Name { get; set; } // Slug pour les URLs conviviales public required string Slug { get; set; } public string? Description { get; set; } // Navigation inverse : liste des produits de cette catégorie 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 pour chaque entité public DbSet Products => Set(); public DbSet Categories => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Configuration de l'entité Product modelBuilder.Entity(entity => { // Index sur le nom pour recherche rapide entity.HasIndex(p => p.Name); // Précision du prix : 18 chiffres, 2 décimales entity.Property(p => p.Price) .HasPrecision(18, 2); // Relation avec Category entity.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); }); // Configuration de l'entité Category modelBuilder.Entity(entity => { // Slug unique entity.HasIndex(c => c.Slug).IsUnique(); // Longueur maximale du nom entity.Property(c => c.Name).HasMaxLength(100); }); } } ``` La configuration Fluent API offre un contrôle précis sur le schéma de base de données généré par les migrations EF Core. > **Migrations Entity Framework Core** > > Les migrations permettent de versionner le schéma de base de données. Exécuter `dotnet ef migrations add InitialCreate` puis `dotnet ef database update` pour appliquer les changements. ## DTOs et validation avec FluentValidation Les DTOs (Data Transfer Objects) séparent les modèles de domaine des données exposées via l'API. FluentValidation offre une validation déclarative et maintenable. ```csharp // DTOs/ProductDtos.cs namespace ProductApi.DTOs; // DTO pour la création d'un produit public record CreateProductDto( string Name, string Description, decimal Price, int StockQuantity, int CategoryId ); // DTO pour la mise à jour d'un produit public record UpdateProductDto( string? Name, string? Description, decimal? Price, int? StockQuantity, bool? IsActive ); // DTO pour la réponse (lecture) public record ProductDto( int Id, string Name, string Description, decimal Price, int StockQuantity, bool IsActive, string CategoryName, DateTime CreatedAt ); // DTO pour la liste avec pagination public record ProductListDto( int Id, string Name, decimal Price, int StockQuantity, bool IsActive, string CategoryName ); ``` L'utilisation des records C# 9+ rend les DTOs immuables et concis, avec égalité par valeur automatique. ```csharp // Validators/ProductValidators.cs using FluentValidation; using ProductApi.DTOs; namespace ProductApi.Validators; public class CreateProductValidator : AbstractValidator { public CreateProductValidator() { // Le nom est requis et limité à 200 caractères RuleFor(x => x.Name) .NotEmpty().WithMessage("Le nom du produit est requis.") .MaximumLength(200).WithMessage("Le nom ne peut pas dépasser 200 caractères."); // Description requise avec longueur minimale RuleFor(x => x.Description) .NotEmpty().WithMessage("La description est requise.") .MinimumLength(10).WithMessage("La description doit contenir au moins 10 caractères."); // Prix positif obligatoire RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Le prix doit être supérieur à 0.") .LessThanOrEqualTo(999999.99m).WithMessage("Le prix maximum est 999 999,99."); // Stock non négatif RuleFor(x => x.StockQuantity) .GreaterThanOrEqualTo(0).WithMessage("Le stock ne peut pas être négatif."); // Catégorie valide RuleFor(x => x.CategoryId) .GreaterThan(0).WithMessage("Une catégorie valide est requise."); } } public class UpdateProductValidator : AbstractValidator { public UpdateProductValidator() { // Validation conditionnelle : seulement si la valeur est fournie 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 s'intègre automatiquement au pipeline de validation ASP.NET Core, retournant des erreurs 400 structurées. ## Services métier et couche d'abstraction La couche de services encapsule la logique métier et les opérations de base de données, facilitant les tests et la maintenance. ```csharp // Services/IProductService.cs using ProductApi.DTOs; namespace ProductApi.Services; public interface IProductService { // Récupération avec pagination Task<(IEnumerable Items, int TotalCount)> GetAllAsync( int page = 1, int pageSize = 10, string? search = null, int? categoryId = null); // Récupération par ID Task GetByIdAsync(int id); // Création Task CreateAsync(CreateProductDto dto); // Mise à jour Task UpdateAsync(int id, UpdateProductDto dto); // Suppression 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) { // Construction de la requête de base var query = _context.Products .Include(p => p.Category) .AsQueryable(); // Filtre par recherche textuelle if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(p => p.Name.Contains(search) || p.Description.Contains(search)); } // Filtre par catégorie if (categoryId.HasValue) { query = query.Where(p => p.CategoryId == categoryId.Value); } // Compte total avant pagination var totalCount = await query.CountAsync(); // Application de la 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) { // Récupération avec inclusion de la catégorie var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Mapping vers DTO return new ProductDto( product.Id, product.Name, product.Description, product.Price, product.StockQuantity, product.IsActive, product.Category?.Name ?? "Non catégorisé", product.CreatedAt); } public async Task CreateAsync(CreateProductDto dto) { // Création de l'entité var product = new Product { Name = dto.Name, Description = dto.Description, Price = dto.Price, StockQuantity = dto.StockQuantity, CategoryId = dto.CategoryId }; // Ajout et sauvegarde _context.Products.Add(product); await _context.SaveChangesAsync(); // Chargement de la catégorie pour la réponse 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 ?? "Non catégorisé", product.CreatedAt); } public async Task UpdateAsync(int id, UpdateProductDto dto) { // Récupération de l'entité existante var product = await _context.Products .Include(p => p.Category) .FirstOrDefaultAsync(p => p.Id == id); if (product == null) return null; // Mise à jour conditionnelle des champs 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; // Mise à jour de la date de modification 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 ?? "Non catégorisé", product.CreatedAt); } public async Task DeleteAsync(int id) { // Suppression directe sans chargement préalable var result = await _context.Products .Where(p => p.Id == id) .ExecuteDeleteAsync(); return result > 0; } } ``` L'utilisation de `ExecuteDeleteAsync` (nouveau dans EF Core 7+) améliore les performances en évitant le chargement de l'entité avant suppression. ## Controllers API et endpoints REST Les controllers exposent les endpoints REST et orchestrent les appels aux services métier avec une gestion appropriée des codes 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; } /// /// Récupère la liste des produits avec pagination et filtres. /// [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) { // Validation des paramètres de pagination if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 10; var (items, totalCount) = await _productService.GetAllAsync( page, pageSize, search, categoryId); // Réponse paginée standardisée var response = new PaginatedResponse { Items = items, Page = page, PageSize = pageSize, TotalCount = totalCount, TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) }; return Ok(response); } /// /// Récupère un produit par son identifiant. /// [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 = $"Produit avec l'ID {id} non trouvé." }); } return Ok(product); } /// /// Crée un nouveau produit. /// [HttpPost] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Create([FromBody] CreateProductDto dto) { // La validation est automatique via FluentValidation var product = await _productService.CreateAsync(dto); // Retourne 201 avec l'URL de la ressource créée return CreatedAtAction( nameof(GetById), new { id = product.Id }, product); } /// /// Met à jour un produit existant. /// [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 = $"Produit avec l'ID {id} non trouvé." }); } return Ok(product); } /// /// Supprime un produit. /// [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 = $"Produit avec l'ID {id} non trouvé." }); } // 204 No Content pour une suppression réussie return NoContent(); } } ``` Les attributs `ProducesResponseType` documentent les codes de réponse possibles pour la génération automatique de la documentation 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; } ``` > **Contraintes de route** > > L'utilisation de contraintes comme `{id:int}` évite les conflits de routage et retourne automatiquement un 404 si le format est incorrect. ## Gestion globale des erreurs Un middleware de gestion des erreurs centralise le traitement des exceptions pour des réponses cohérentes et sécurisées. ```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 le pipeline await _next(context); } catch (Exception ex) { // Log de l'erreur _logger.LogError(ex, "Une erreur non gérée s'est produite"); // Préparation de la réponse context.Response.ContentType = "application/json"; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; // Réponse différente selon l'environnement var response = _env.IsDevelopment() ? new ErrorResponse( StatusCode: context.Response.StatusCode, Message: ex.Message, Details: ex.StackTrace) : new ErrorResponse( StatusCode: context.Response.StatusCode, Message: "Une erreur interne s'est produite.", Details: null); // Sérialisation avec options camelCase var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var json = JsonSerializer.Serialize(response, options); await context.Response.WriteAsync(json); } } } // DTO pour les réponses d'erreur public record ErrorResponse(int StatusCode, string Message, string? Details); // Extension pour enregistrer le middleware public static class ExceptionMiddlewareExtensions { public static IApplicationBuilder UseExceptionMiddleware(this IApplicationBuilder app) { return app.UseMiddleware(); } } ``` ```csharp // Program.cs (ajout du middleware) var app = builder.Build(); // Le middleware d'exception doit être en premier app.UseExceptionMiddleware(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // ... reste de la configuration ``` ## Configuration et variables d'environnement La configuration externalisée permet d'adapter l'application à différents environnements sans modification de code. ```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 (injection de la configuration) builder.Services.Configure( builder.Configuration.GetSection("ApiSettings")); // Utilisation dans un service public class ProductService : IProductService { private readonly ApiSettings _settings; public ProductService(IOptions settings) { _settings = settings.Value; } } ``` ## Tests unitaires avec xUnit Les tests unitaires valident le comportement des services et controllers de manière isolée. ```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() { // Configuration de la base de données en mémoire 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(); // Ajout d'une catégorie de test 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); // Les champs non fournis restent inchangés 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)); } } ``` L'exécution des tests se fait avec `dotnet test` depuis la racine du projet. ## Conclusion .NET 8 avec ASP.NET Core offre un écosystème complet et performant pour créer des APIs REST professionnelles. La combinaison d'Entity Framework Core pour l'accès aux données, FluentValidation pour la validation et l'injection de dépendances native permet de construire des applications maintenables et testables. ### Checklist pour une API .NET de qualité - ✅ Séparer les DTOs des modèles de domaine - ✅ Implémenter une couche de services pour la logique métier - ✅ Utiliser FluentValidation pour des validations déclaratives - ✅ Configurer un middleware de gestion globale des erreurs - ✅ Externaliser la configuration avec IOptions - ✅ Écrire des tests unitaires pour les services - ✅ Documenter l'API avec Swagger/OpenAPI L'architecture en couches (Controllers → Services → Repository/DbContext) favorise la séparation des responsabilités et facilite l'évolution de l'application. Les fonctionnalités de .NET 8 comme les records, les required properties et ExecuteDeleteAsync modernisent le code tout en améliorant les performances. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/dotnet/dotnet-8-creer-api-aspnet-core