Architecture Multi-tiers et Injection de dépendances
Notions théoriques
Le problème du God Object
Sans architecture structurée, tout le code finit dans un seul fichier ou une seule classe : logique métier, accès à la base de données, et affichage se mélangent. Cette classe "qui fait tout" est appelée un God Object (ou contrôleur God-Object). Elle est difficile à tester, à maintenir et à faire évoluer.
Les 4 couches de l'architecture Multi-tiers
Une bonne architecture sépare les responsabilités en couches distinctes :
┌─────────────────────────────────┐
│ Présentation (Program.cs / │ ← Point d'entrée, contrôleur ASP.NET
│ Contrôleur) │
├─────────────────────────────────┤
│ Service (logique métier) │ ← Règles de gestion, orchestration
├─────────────────────────────────┤
│ Repository (accès aux données) │ ← Requêtes SQL / EF Core
├─────────────────────────────────┤
│ Domain (entités) │ ← Classes pures, sans dépendances
└─────────────────────────────────┘
Couche Domain
Les entités pures du domaine métier. Elles ne dépendent d'aucune technologie externe.
// Domain/Personnage.cs
public class Personnage
{
public int Id { get; set; }
public string Nom { get; set; } = "";
public int Force { get; set; }
}
Couche Repository
L'interface définit le contrat d'accès aux données. L'implémentation contient le code EF Core ou SQL.
// Repository/IPersonnageRepository.cs
public interface IPersonnageRepository
{
Task<Personnage?> TrouverParIdAsync(int id);
Task<List<Personnage>> TousLesPersonnagesAsync();
Task AjouterAsync(Personnage personnage);
Task SupprimerAsync(int id);
}
// Repository/PersonnageRepositorySql.cs
public class PersonnageRepositorySql : IPersonnageRepository
{
private readonly AppDbContext _context;
public PersonnageRepositorySql(AppDbContext context)
=> _context = context;
public async Task<List<Personnage>> TousLesPersonnagesAsync()
=> await _context.Personnages.ToListAsync();
public async Task<Personnage?> TrouverParIdAsync(int id)
=> await _context.Personnages.FindAsync(id);
public async Task AjouterAsync(Personnage p)
{
_context.Personnages.Add(p);
await _context.SaveChangesAsync();
}
public async Task SupprimerAsync(int id)
{
var p = await TrouverParIdAsync(id);
if (p is not null)
{
_context.Personnages.Remove(p);
await _context.SaveChangesAsync();
}
}
}
Couche Service
La logique métier : règles de validation, orchestration des opérations. Le service ne connaît que l'interface du repository, jamais l'implémentation concrète.
// Services/PersonnageService.cs
public class PersonnageService
{
private readonly IPersonnageRepository _repo;
// Le repository est injecté dans le constructeur
public PersonnageService(IPersonnageRepository repo)
=> _repo = repo;
public async Task<List<Personnage>> ObtenirTousAsync()
=> await _repo.TousLesPersonnagesAsync();
public async Task<Personnage?> ObtenirParIdAsync(int id)
=> await _repo.TrouverParIdAsync(id);
public async Task CreerAsync(string nom, int force)
{
if (string.IsNullOrWhiteSpace(nom))
throw new ArgumentException("Le nom ne peut pas être vide.");
if (force is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(force), "La force doit être entre 0 et 100.");
await _repo.AjouterAsync(new Personnage { Nom = nom, Force = force });
}
}
Couche Présentation et injection de dépendances
Dans ASP.NET Core, l'injection de dépendances est intégrée nativement. On enregistre les dépendances dans Program.cs :
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Enregistrement des dépendances
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IPersonnageRepository, PersonnageRepositorySql>();
builder.Services.AddScoped<PersonnageService>();
var app = builder.Build();
AddScoped vs AddTransient vs AddSingleton
| Méthode | Durée de vie | Usage typique |
|---|---|---|
AddScoped | Une instance par requête HTTP | Repository, Service, DbContext |
AddTransient | Une nouvelle instance à chaque injection | Services légers, sans état |
AddSingleton | Une seule instance pour toute l'application | Cache, Configuration, Logger |
Le parallèle avec Spring Boot (Java) : AddScoped ≈ @Service ou @Repository, AddSingleton ≈ @Component avec @Scope("singleton") (comportement par défaut de Spring).
Injection dans le contrôleur
// Controllers/PersonnagesController.cs
[ApiController]
[Route("api/[controller]")]
public class PersonnagesController : ControllerBase
{
private readonly PersonnageService _service;
// ASP.NET Core injecte automatiquement le service
public PersonnagesController(PersonnageService service)
=> _service = service;
[HttpGet]
public async Task<IActionResult> GetAll()
{
var personnages = await _service.ObtenirTousAsync();
return Ok(personnages);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var personnage = await _service.ObtenirParIdAsync(id);
return personnage is null ? NotFound() : Ok(personnage);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreerPersonnageRequest request)
{
await _service.CreerAsync(request.Nom, request.Force);
return Created();
}
}
public record CreerPersonnageRequest(string Nom, int Force);
Exemple pratique
// Exemple console (sans ASP.NET) pour illustrer l'injection manuelle
// Domain
public class Personnage
{
public int Id { get; set; }
public string Nom { get; set; } = "";
public int Force { get; set; }
}
// Repository (interface + implémentation en mémoire pour l'exemple)
public interface IPersonnageRepository
{
List<Personnage> TousLesPersonnages();
Personnage? TrouverParId(int id);
void Ajouter(Personnage p);
}
public class PersonnageRepositoryMemoire : IPersonnageRepository
{
private readonly List<Personnage> _donnees = new();
private int _nextId = 1;
public List<Personnage> TousLesPersonnages() => _donnees.ToList();
public Personnage? TrouverParId(int id) => _donnees.FirstOrDefault(p => p.Id == id);
public void Ajouter(Personnage p)
{
p.Id = _nextId++;
_donnees.Add(p);
}
}
// Service
public class PersonnageService
{
private readonly IPersonnageRepository _repo;
public PersonnageService(IPersonnageRepository repo) => _repo = repo;
public List<Personnage> ObtenirTous() => _repo.TousLesPersonnages();
public Personnage? ObtenirParId(int id) => _repo.TrouverParId(id);
public void Creer(string nom, int force)
{
if (string.IsNullOrWhiteSpace(nom))
throw new ArgumentException("Le nom ne peut pas être vide.");
_repo.Ajouter(new Personnage { Nom = nom, Force = force });
}
}
// Présentation (injection manuelle)
class Program
{
static void Main()
{
// Injection de dépendances (simulée manuellement ici)
IPersonnageRepository repo = new PersonnageRepositoryMemoire();
PersonnageService service = new PersonnageService(repo);
// Logique métier via le service
service.Creer("Aragorn", 85);
service.Creer("Gandalf", 95);
foreach (var p in service.ObtenirTous())
Console.WriteLine($"[{p.Id}] {p.Nom} — Force : {p.Force}");
var trouve = service.ObtenirParId(1);
Console.WriteLine($"\nRecherche id=1 : {trouve?.Nom ?? "introuvable"}");
}
}
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Dans ce TP, vous allez refactorer le projet RPG en 4 couches distinctes et configurer l'injection de dépendances.
Étape 1 — Créer l'interface IPersonnageRepository
Définissez l'interface qui représente le contrat d'accès aux données pour les personnages.
Définir l'interface en premier force à réfléchir au contrat public (quelles méthodes sont nécessaires ?) avant d'écrire le code de la base de données. C'est aussi ce qui permet d'écrire les tests unitaires avec un mock avant même que l'implémentation SQL ne soit prête.
Étape 2 — Implémenter le Service avec injection du Repository
Créez PersonnageService en injectant IPersonnageRepository dans son constructeur.
Le Service déclare sa dépendance sur IPersonnageRepository, jamais sur PersonnageRepositorySql. Cela signifie que vous pouvez remplacer l'implémentation SQL par une implémentation en mémoire (pour les tests) ou une implémentation qui appelle une API REST, sans changer une seule ligne du Service.
Étape 3 — Configurer l'injection de dépendances dans Program.cs
Enregistrez les dépendances dans Program.cs d'une application ASP.NET Core.
AddScoped crée une nouvelle instance par requête HTTP et la réutilise au sein de cette même requête. C'est le choix correct pour les Repository et les Service car ils peuvent partager le même DbContext (lui aussi Scoped), ce qui garantit la cohérence des transactions dans une requête.