.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.

Guide pour créer une API REST avec .NET 8 et ASP.NET Core

.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.

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

// Enregistrement des services métier
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();

// Configuration des controllers avec validation
builder.Services.AddControllers();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// 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.

Models/Product.cscsharp
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.

Models/Category.cscsharp
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<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 pour chaque entité
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configuration de l'entité Product
        modelBuilder.Entity<Product>(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<Category>(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.

DTOs/ProductDtos.cscsharp
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.

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

namespace ProductApi.Validators;

public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
    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<UpdateProductDto>
{
    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.

Prêt à réussir tes entretiens .NET ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

Services/IProductService.cscsharp
using ProductApi.DTOs;

namespace ProductApi.Services;

public interface IProductService
{
    // Récupération avec pagination
    Task<(IEnumerable<ProductListDto> Items, int TotalCount)> GetAllAsync(
        int page = 1,
        int pageSize = 10,
        string? search = null,
        int? categoryId = null);

    // Récupération par ID
    Task<ProductDto?> GetByIdAsync(int id);

    // Création
    Task<ProductDto> CreateAsync(CreateProductDto dto);

    // Mise à jour
    Task<ProductDto?> UpdateAsync(int id, UpdateProductDto dto);

    // Suppression
    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)
    {
        // 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<ProductDto?> 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<ProductDto> 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<ProductDto?> 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<bool> 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.

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>
    /// Récupère la liste des produits avec pagination et filtres.
    /// </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)
    {
        // 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<ProductListDto>
        {
            Items = items,
            Page = page,
            PageSize = pageSize,
            TotalCount = totalCount,
            TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
        };

        return Ok(response);
    }

    /// <summary>
    /// Récupère un produit par son identifiant.
    /// </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 = $"Produit avec l'ID {id} non trouvé." });
        }

        return Ok(product);
    }

    /// <summary>
    /// Crée un nouveau produit.
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> 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);
    }

    /// <summary>
    /// Met à jour un produit existant.
    /// </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 = $"Produit avec l'ID {id} non trouvé." });
        }

        return Ok(product);
    }

    /// <summary>
    /// Supprime un produit.
    /// </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 = $"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.

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;
}
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.

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 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<ExceptionMiddleware>();
    }
}
Program.cs (ajout du middleware)csharp
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.

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 (injection de la configuration)csharp
builder.Services.Configure<ApiSettings>(
    builder.Configuration.GetSection("ApiSettings"));

// Utilisation dans un service
public class ProductService : IProductService
{
    private readonly ApiSettings _settings;

    public ProductService(IOptions<ApiSettings> settings)
    {
        _settings = settings.Value;
    }
}

Tests unitaires avec xUnit

Les tests unitaires valident le comportement des services et controllers de manière isolée.

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()
    {
        // Configuration de la base de données en mémoire
        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();

        // 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

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

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.

Tags

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

Partager

Articles similaires