Aller au contenu principal

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éthodeDurée de vieUsage typique
AddScopedUne instance par requête HTTPRepository, Service, DbContext
AddTransientUne nouvelle instance à chaque injectionServices légers, sans état
AddSingletonUne seule instance pour toute l'applicationCache, Configuration, Logger
info

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


Quelle couche contient les classes pures du domaine métier, sans dépendances externes ?


Pourquoi le Service dépend-il d'une interface IPersonnageRepository plutôt que de la classe concrète PersonnageRepositorySql ?


Quelle durée de vie choisir pour un Repository dans ASP.NET Core ?


Où enregistre-t-on les dépendances dans une application ASP.NET Core ?


Qu'est-ce qu'un 'God Object' dans le contexte de l'architecture logicielle ?


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.


Bonne pratique - L'interface avant l'implémentation

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.


Bonne pratique - Le Service ne connaît que l'interface

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.


Bonne pratique - AddScoped pour Repository et Service dans 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.

📌 Une solution