Article écrit par Mohamed Ali Mdalla
L’architecture hexagonale est une architecture logicielle crée par Alistair Cockburn. Elle est aussi appelée « Ports & adapters ».
Origine
L’architecture hexagonale permet à une application d’être pilotée aussi bien par des utilisateurs que par des programmes, des tests automatisés ou des scripts, et d’être développée et testée en isolation de ses éventuels systèmes d’exécution et bases de données.
Avantages
Parmi les avantages de cette approche :
- Séparation des responsabilités : l’architecture hexagonale permet de séparer clairement les responsabilités entre les différents composants d’un système. Le code métier est isolé dans le noyau de l’application, tandis que les composants d’infrastructure (comme les bdd, les services externes) sont isolés dans des adaptateurs. Cela facilite la compréhension du système et permet de modifier facilement les composants d’infrastructure sans impacter le code métier.
- Facilité de test : la séparation des responsabilités facilite également les tests unitaires. Les tests peuvent être écrits pour le code métier sans avoir besoin de démarrer toute l’infrastructure. Les adaptateurs peuvent être testés séparément avec des simulateurs de service.
- Modularité : l’architecture hexagonale favorise la modularité, ce qui facilite l’ajout de nouvelles fonctionnalités ou la modification des fonctionnalités existantes sans avoir besoin de modifier l’ensemble du système. Cela permet également une meilleure réutilisation du code.
- Flexibilité : la séparation des responsabilités et la modularité offrent également une plus grande flexibilité dans le choix des technologies d’infrastructure. Les adaptateurs peuvent être écrits en utilisant différentes technologies et peuvent être facilement remplacés si nécessaire.
- Alignement sur les besoins métier : l’architecture hexagonale se concentre sur les besoins métiers et permet de mettre en place une architecture qui reflète mieux les exigences fonctionnelles et non-fonctionnelles de l’application.
Schéma d’ensemble
Couche applicative
Cette couche contient les types de composants suivants :
- Contrôleur web ou équivalent
- Adapteur primaire qui fait le lien entre le contrôleur et le port primaire, responsable du mapping entre les modèles de l’application et les modèles du domaine avant appel du port primaire, ainsi que le mapping entre les modèles du domaine et les modèles de l’application après l’appel.
- Handler de gestion des erreurs
- Gestion des aspects authentification et habilitations
On distingue les modèles du domaine des modèles de l’api, ainsi qu’on doit éviter forcément l’utilisation des entités du domaine directement dans cette partie afin de :
- ne pas transmettre des id internes
- ne pas exposer trop de complexité
- bien conserver le modèle utilisé dans la définition du contrat UML.
Par la suite, on met en place des mappers pour échanger les informations avec le domaine en entrée et en sortie.
Couche domaine
C’est la partie que l’on veut isoler de ce qui est à gauche et à droite, dans laquelle on implémente la logique métier. Ce domaine ne doit faire appel à aucun élément d’application ou d’infrastructure directement : on doit définir des interfaces d’entrée et de sortie claires. Le but est de garantir une séparation stricte du métier afin de limiter l’impact de changements technologiques même majeurs sur l’implémentation de la logique fonctionnelle.
Couche infrastructure
Cette couche contient les implémentations des différents ports secondaires. Les ports secondaires ne doivent contenir aucune intelligence métier, uniquement des appels directs vers les composants externes (BDD, api externes, etc…). Ils doivent donc rester très simples. Si on commence à avoir de la complexité au niveau d’infrastructure, c’est qu’on a probablement des règles à remonter au niveau du domaine.
Port
Les ports sont les interfaces définies par le domaine pour interagir avec le monde extérieur. On distingue deux types de port : port primaire et port secondaire.
Ports primaires :
Ce sont les points d’entrées uniques du domaine, permettant d’appeler des fonctions (usecases) du domaine. Deux approches sont actuellement pratiquées :
- Approche classique par Interface : il s’agit des interfaces à appeler par la couche application pour effectuer des traitements. Donc un traitement du domaine est une implémentation d’un port primaire.
- Approche par Mediator : le framework DOTNET CORE permet notamment d’appliquer ce pattern. C’est le Mediator qui fait le lien entre le port primaire et les règles du domaine à appeler : ceci évite d’avoir des dépendances circulaires et permet d’exprimer des règles métier de façon indépendante. Si on utilise le pattern Mediator, les Command/Query remplacent les interfaces et jouent le rôle des ports primaires, et les Handlers remplacent l’implémentation. Pour plus de détails sur le pattern Mediator cliquez ici.
Ports secondaires :
Ce sont les points d’interfaçage pour sortir du domaine afin d’effectuer des appels à des API externes, gérer l’accès à la base de données etc… Il s’agit des interfaces définies à l’intérieur de domaine, toutefois les implémentations se trouvent à l’extérieur par des adaptateurs secondaires.
Adaptateur
Adaptateurs primaires :
Ce sont des composants situés dans la couche application, permettant d’appeler directement les ports primaires. Concrètement, un adaptateur primaire permet de :
- Récupérer les modèles de la couche d’application
- Effectuer les mappings entre les modèles d’application et les modèles du domaine
- Appeler le domaine via le port primaire
Adaptateurs secondaires :
Ce sont des composants situés dans la couche infrastructure, permettant d’appeler directement les ports secondaires. Concrètement, un adaptateur secondaire permet de :
- Faire des appels vers l’extérieur du domaine (Api externe, BDD, Message Queue etc…)
- Effectuer les mappings entre les modèles du domaine et les modèles d’infrastructure
- Appeler le monde extérieur avec les objets convertis
Comment ça marche
Implémentation
Pour illustrer l’architecture hexagonale, on propose de présenter la structure générale d’un projet développé en DOT NET afin de gérer les chantiers d’un artisan de peinture de bâtiment. On va présenter également un exemple d’implémentation de création d’un nouveau chantier dans une base de données avec les différents types d’adaptateurs et de ports. Pour les ports primaires, on va utiliser l’approche Mediator.
Structure de la solution
On propose de deviser notre solution en trois parties : un dossier application, un dossier domaine et un dossier infrastructure.
Application
- Api : un projet Asp Core webapi classique : contrôleurs, validateurs, configurations…
- ApiContracts : une bibliothèque de classe contenant les modèles d’application. On distingue les modèles de demande et les modèles de réponse.
- Api.Tests : un projet de tests unitaires liés aux actions de contrôleur.
Domaine
- Domain : une bibliothèque de classe contenant les usecases métier. Suite à l’utilisation du pattern CQRS : les usecases sont dévisés en Command (écrire) et Query (lecture).
- Domain.Contrats : une bibliothèque de classe contenant les ports primaires et les ports secondaires.
- Domain.Test : un projet de tests unitaires liés aux traitements métier.
Infrastructure
- Infrastructure : une bibliothèque de classes dans laquelle on gère les communications avec la base de données, api externe…
- Infrastructure.Tests : un projet de tests unitaires liés aux adaptateurs.
Usecase de création d’un nouveau chantier
Port primaire
Idéalement, on commence par la mise en place du port primaire. On crée la commande de création de chantier, ainsi que le modèle du domaine et le modèle de retour (Chantier, ChantierCreateResult).
namespace MDM.Domain.Contrats.Primary
{
public class CreateChantierCommand : IRequest<ChantierCreatedResult>
{
public Chantier Chantier { get; set; }
public string UserId { get; set; }
public string AuthorizationHeader { get; set; }
}
}
Adaptateur primaire
Dans notre exemple, l’action « CreateChantier » du Controller « ChantierController » joue le rôle d’adaptateur primaire :
- Validation du modèle d’application
- Mapping entre modèles de l’application et les modèles du domaine lié au port primaire « CreateChantierCommand ».
- Appel du port primaire
- Récupération du résultat du traitement
public async Task<ActionResult> CreateChantier([FromBody] Contracts.Request.Chantier chantierRequest, CancellationToken cancellationToken)
{
if (chantierRequest == null)
{
ErrorResponse errorResponse = new ChantierModelStateValidator().GetErrorFromModelState(ModelState, Request.Path);
return BadRequest(errorResponse);
}
CreateChantierCommand command = new()
{
Chantier = new()
{
Client = new()
{
Name = chantierRequest.Client.Name,
ClientType = chantierRequest.Client.ClientType,
Number = chantierRequest.Client.Number,
Telephone = chantierRequest.Client.Telephone,
ClientAddress = chantierRequest.Client.AddressClient != null ? new()
{
Voie = chantierRequest.Client.AddressClient.Voie,
Complement1 = chantierRequest.Client.AddressClient.Complement1,
Complement2 = chantierRequest.Client.AddressClient.Complement2,
CodePostale = chantierRequest.Client.AddressClient.CodePostale,
Ville = chantierRequest.Client.AddressClient.Ville,
Pays = chantierRequest.Client.AddressClient.Pays
} : null,
},
Address = new()
{
Voie = chantierRequest.Address.Voie,
Complement1 = chantierRequest.Address.Complement1,
Complement2 = chantierRequest.Address.Complement2,
CodePostale = chantierRequest.Address.CodePostale,
Ville = chantierRequest.Address.Ville,
Pays = chantierRequest.Address.Pays
}
}
};
command.AuthorizationHeader = Request.Headers[HeaderNames.Authorization];
var ChantierCreatedResult = await _mediator.Send(command, cancellationToken);
var result = new ChantierCreated()
{
Id= ChantierCreatedResult.Id
};
return Created("/chantier/", result);
}
Test unitaire d’adaptateur primaire
Dans une démarche TDD (Test-Driven Development), il est recommandé de mettre en place quelques tests pour vérifier le comportement des fonctions du domaine. Voici un exemple de tests liés à l’adaptateur :
[Test]
[TestCase("client", "The following fields are invalids: ['Client' should be an object type]")]
[TestCase("client.name", "The following fields are invalids: ['Client.Name' should be a text type]")]
[TestCase("client.clientType", "The following fields are invalids: ['Client.ClientType' should be a text type]")]
[TestCase("address", "The following fields are invalids: ['Address' should be an object type]")]
[TestCase("address.voie", "The following fields are invalids: ['Address.Voie' should be a text type]")]
[TestCase("address.codePostale", "The following fields are invalids: ['Address.CodePostale' should be a text type]")]
[TestCase("address.ville", "The following fields are invalids: ['Address.Ville' should be a text type]")]
[TestCase("address.pays", "The following fields are invalids: ['Address.Pays' should be a text type]")]
[TestCase("???", "error message")]
public void ShouldFailedWhenCreateChantierWithChantierIsNullAndModelStateIsInvalid(string field, string expectedErrorDetail)
{
Contracts.Request.Chantier chantierRequest = null;
_controller.ModelState.AddModelError($"$.{field}", "error message");
var result = _controller.CreateChantier(chantierRequest, CancellationToken.None).Result;
Assert.IsInstanceOf<BadRequestObjectResult>(result);
var errorResult = result as BadRequestObjectResult;
var errorResponse = errorResult?.Value as ErrorResponse;
Assert.That(errorResponse?.Detail, Is.EqualTo(expectedErrorDetail));
}
[Test]
public void ShouldSucceedWhenCreateChantier()
{
var createdChantierId = 123;
Contracts.Request.Chantier chantierRequest = new()
{
Client = new Client()
{
AddressClient = new Address()
},
Address = new Address()
};
_mediator.Send(Arg.Any<CreateChantierCommand>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new Domain.Contrats.Models.ChantierCreatedResult()
{
Id = createdChantierId
}));
var result = _controller.CreateChantier(chantierRequest, CancellationToken.None).Result;
Assert.IsInstanceOf<CreatedResult>(result);
var createdResult = result as CreatedResult;
var chantierCreated = createdResult?.Value as ChantierCreated;
Assert.That(createdResult?.StatusCode, Is.EqualTo(201));
Assert.That(createdResult?.Location, Is.EqualTo(string.Format("/chantier/", chantierCreated?.Id)));
Assert.That(chantierCreated?.Id, Is.EqualTo(createdChantierId));
}
Ports secondaires
Afin de créer un nouveau chantier, il faut avoir les informations liées au client et l’adresse du chantier. Pour cela il faut :
- Vérifier l’existence du client dans la base de données : si ce n’est pas le cas il faut le créer.
- Créer l’adresse du chantier
- Créer le chantier.
Dans ce cas on a besoin de trois ports secondaires présentés dans les schémas ci-dessous :
- Port secondaire du client
namespace MDM.Domain.Contrats.Secondary
{
public interface IClientRepository
{
Task<int> GetIdClientByNumberAsync(string clientNumber, CancellationToken cancellationToken);
Task<int> CreateClientAsync(Client client, CancellationToken cancellationToken);
}
}
- Port secondaire d’adresse
namespace MDM.Domain.Contrats.Secondary
{
public interface IAddressRepository
{
Task<int> CreateAddressAsync(Address address, CancellationToken cancellationToken);
}
}
- Port secondaire de chantier
namespace MDM.Domain.Contrats.Secondary
{
public interface IChantierRepository
{
Task<int> CreateChantierAsync(Chantier chantier, CancellationToken cancellationToken);
}
}
L’implémentation du usecase
Après avoir créé les ports secondaires, on peut passer à l’implémentation du usecase.
public class CreateChantierHandler : IRequestHandler<CreateChantierCommand, ChantierCreatedResult>
{
private readonly IAddressRepository _addressRepository;
private readonly IChantierRepository _chantierRepository;
private readonly IClientRepository _clientRepository;
public CreateChantierHandler(IAddressRepository addressRepository,
IChantierRepository chantierRepository,
IClientRepository clientRepository)
{
_addressRepository = addressRepository;
_chantierRepository = chantierRepository;
_clientRepository = clientRepository;
}
public async Task<ChantierCreatedResult> Handle(CreateChantierCommand command, CancellationToken cancellationToken)
{
if (command.Chantier == null)
{
throw new DomainException($"'{nameof(command.Chantier)}' can not be null.");
}
command.Chantier.Client.Id = await GetIdClient(command.Chantier.Client, cancellationToken);
command.Chantier.Address.Id = await _addressRepository.CreateAddressAsync(command.Chantier.Address, cancellationToken);
int idChantier = await _chantierRepository.CreateChantierAsync(command.Chantier, cancellationToken);
ChantierCreatedResult result = new()
{
Id = idChantier
};
return result;
}
private async Task<int> GetIdClient(Client client, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(client?.Number))
{
return await _clientRepository.GetIdClientByNumberAsync(client.Number, cancellationToken);
}
else
{
if (!client.ClientAddress.Id.HasValue)
{
client.ClientAddress.Id = await _addressRepository.CreateAddressAsync(client.ClientAddress, cancellationToken);
}
return await _clientRepository.CreateClientAsync(client, cancellationToken);
}
}
}
Test unitaire du usecase
Dans une démarche TDD, voici un exemple de tests liés au usecase :
[Test]
public async Task CreateChantier_ShouldSucceed()
{
var command = BuildAValideCreateChantierCommand();
_clientRepository.CreateClientAsync(Arg.Any<Client>(), Arg.Any<CancellationToken>()).Returns(_clientId);
_addressRepository.CreateAddressAsync(Arg.Any<Address>(), Arg.Any<CancellationToken>()).Returns(_addressId);
_chantierRepository.CreateChantierAsync(Arg.Any<Chantier>(), Arg.Any<CancellationToken>()).Returns(_createdChantierId);
var result = await _createChantierHandler.Handle(command, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(command.Chantier.Client, Is.Not.Null);
Assert.That(command.Chantier.Address, Is.Not.Null);
Assert.That(command.Chantier.Address.Voie, Is.Not.Null);
Assert.That(command.Chantier.Address.CodePostale, Is.Not.Null);
Assert.That(command.Chantier.Address.Ville, Is.Not.Null);
Assert.That(command.Chantier.Address.Pays, Is.Not.Null);
Assert.That(result.Id, Is.EqualTo(_createdChantierId));
});
}
[Test]
public async Task CreateChantier_ShouldSucceed_WhenClientNumberIsNull()
{
var command = BuildAValideCreateChantierCommand();
command.Chantier.Client.Number = null;
_clientRepository.CreateClientAsync(Arg.Any<Client>(), Arg.Any<CancellationToken>()).Returns(_clientId);
_addressRepository.CreateAddressAsync(Arg.Any<Address>(), Arg.Any<CancellationToken>()).Returns(_addressId);
_chantierRepository.CreateChantierAsync(Arg.Any<Chantier>(), Arg.Any<CancellationToken>()).Returns(_createdChantierId);
var result = await _createChantierHandler.Handle(command, CancellationToken.None);
Assert.That(result.Id, Is.EqualTo(_createdChantierId));
}
Adaptateurs secondaires
On passe finalement aux implémentations des adaptateurs secondaires : dans notre cas ils sont responsables de faire les appels nécessaires afin de créer un nouveau chantier.
- Adaptateur Client
namespace MDM.Infrastructure.Adapter
{
public class ClientRepository : IClientRepository
{
private readonly MdmDbContext _dbContext;
public ClientRepository(MdmDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<int> GetIdClientByNumberAsync(string clientNumber, CancellationToken cancellationToken)
{
return await _dbContext.Clients
.Where(client => client.Numero == clientNumber)
.Select(client => client.Id)
.FirstOrDefaultAsync();
}
public async Task<int> CreateClientAsync(MDM.Domain.Contrats.Models.Client client, CancellationToken cancellationToken)
{
var refTypeClient = await _dbContext.RefTypeClients.FirstOrDefaultAsync(type => type.Code == client.ClientType);
Client entity = new()
{
Actif = true,
DateCreation = DateTime.Now,
Numero = await GetNewNumeroClient(),
IdAdresse = client.ClientAddress.Id.Value,
IdRefTypeClient = (int)(refTypeClient?.Id),
RaisonSociale = client.Name,
Email = client.Email,
Telephone = client.Telephone,
Fax = client.Fax,
};
_dbContext.Clients.Add(entity);
await _dbContext.SaveChangesAsync();
return entity.Id;
}
private async Task<string> GetNewNumeroClient()
{
var lastClientId = await _dbContext.Clients.Select(client => client.Id).OrderByDescending(client => client).FirstOrDefaultAsync();
return $"CL{(lastClientId++)}";
}
}
}
- Adaptateur Adresse
namespace MDM.Infrastructure.Adapter
{
public class AddressRepository : IAddressRepository
{
private readonly MdmDbContext _dbContext;
public AddressRepository(MdmDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Domain.Contrats.Models.Address> GetAddressByIdAsync(int idAddress, CancellationToken cancellationToken)
{
if (idAddress == 0)
{
throw new ArgumentNullException($"{idAddress} can't be 0.");
}
var entity = await _dbContext.Addresses.FirstOrDefaultAsync(address => address.Id == idAddress);
if (entity == null)
{
throw new ArgumentNullException($"Address not exist.");
}
return new()
{
Id = entity.Id,
Voie = entity.Voie,
Complement1 = entity.Complement1,
Complement2 = entity.Complement2,
CodePostale = entity.CodePostale,
Ville = entity.Ville,
Pays = entity.Pays
};
}
public async Task<int> CreateAddressAsync(MDM.Domain.Contrats.Models.Address address, CancellationToken cancellationToken)
{
if (address == null)
{
throw new ArgumentNullException(nameof(address), $"{nameof(address)} can't be null.");
}
if (string.IsNullOrEmpty(address.Voie)
|| string.IsNullOrEmpty(address.CodePostale)
|| string.IsNullOrEmpty(address.Ville)
|| string.IsNullOrEmpty(address.Pays))
{
throw new ArgumentNullException($"One of this failed can't be null : {nameof(address.Voie)}, {nameof(address.CodePostale)}, {nameof(address.Ville)}, {nameof(address.Pays)}");
}
Address entity = new()
{
Actif = true,
DateCreation = DateTime.Now,
Voie = address.Voie,
Complement1 = address.Complement1,
Complement2 = address.Complement2,
CodePostale = address.CodePostale,
Ville = address.Ville,
Pays = address.Pays,
};
_dbContext.Addresses.Add(entity);
await _dbContext.SaveChangesAsync();
return entity.Id;
}
}
}
- Adaptateur Chantier
namespace MDM.Infrastructure.Adapter
{
public class ChantierRepository : IChantierRepository
{
private readonly MdmDbContext _dbContext;
public ChantierRepository(MdmDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<int> CreateChantierAsync(MDM.Domain.Contrats.Models.Chantier chantier, CancellationToken cancellationToken)
{
var refEtatChantier = await _dbContext.RefEtatChantiers.FirstOrDefaultAsync(etat => etat.Code == "EN_ATTENTE");
Chantier entity = new()
{
Actif = true,
DateCreation = DateTime.Now,
IdAdresse = chantier.Address.Id,
IdClient = chantier.Client.Id,
IdRefEtatChantier = refEtatChantier.Id,
Nom= chantier.Client.Name,
Numero = await GetNewNumeroChantier(),
};
_dbContext.Chantiers.Add(entity);
await _dbContext.SaveChangesAsync();
return entity.Id;
}
private async Task<string> GetNewNumeroChantier()
{
var lastChantierId = await _dbContext.Chantiers.Select(ch => ch.Id).OrderByDescending(ch => ch).FirstOrDefaultAsync();
return $"CH{(lastChantierId++)}";
}
}
}
Test unitaire d’adaptateurs secondaires
Comme les cas précédents, on doit également mettre en place les tests unitaire correspondants à notre adaptateur.
[Test]
public void GetAddressById_ShouldFailed_WhenAddressNotFound()
{
var addressRepository = new AddressRepository(_dbContext);
Assert.ThrowsAsync<ArgumentNullException>(async () => await addressRepository.GetAddressByIdAsync(0, CancellationToken.None));
}
[Test]
public async Task GetAddressById_ShouldSucced()
{
var idCreatedAddress = await CreateAddressAsync();
var addressRepository = new AddressRepository(_dbContext);
var result = await addressRepository.GetAddressByIdAsync(idCreatedAddress, CancellationToken.None);
Assert.NotNull(result);
Assert.That(idCreatedAddress, Is.EqualTo(result.Id));
}
[Test]
public void CreateAddress_ShouldFailed_WhenRequiredFieldNullOrEmpty()
{
var addressRepository = new AddressRepository(_dbContext);
var address = GetAddressInstance();
address.Voie = null;
address.CodePostale = null;
address.Ville = null;
address.Pays = null;
Assert.ThrowsAsync<ArgumentNullException>(async () => await addressRepository.CreateAddressAsync(address, CancellationToken.None));
}
[Test]
public void CreateAddress_ShouldFailed_WhenAddressNull()
{
var addressRepository = new AddressRepository(_dbContext);
Assert.ThrowsAsync<ArgumentNullException>(async () => await addressRepository.CreateAddressAsync(null, CancellationToken.None));
}
[Test]
public async Task CreateAddress_ShouldSucced_InDatabase()
{
Domain.Contrats.Models.Address address = GetAddressInstance();
var addressRepository = new AddressRepository(_dbContext);
var result = await addressRepository.CreateAddressAsync(address, CancellationToken.None);
Assert.That(result, Is.GreaterThan(0));
}
Conclusion
Pour conclure, on peut retenir que l’architecture hexagonale est une séparation simple du domaine fonctionnel d’objets plus techniques destinés aux interactions entre l’extérieur et son application. Ce type d’architecture reste simple à mettre en place et permet une plus grande flexibilité et une plus grande testabilité que des architectures plus monolithiques comme l’architecture 3-tiers. Enfin, cette architecture implique forcément la présence d’un domaine métier à préserver : dans le cas contraire son utilisation devient inutile.
À lire aussi

Les tests de contrats pilotés par le consommateur

Optimisez vos coûts grâce au FinOps

Comment Ouidou accompagne SNCF Réseau ?
