Je suis récemment tombé sur un problème architectural apparemment insignifiant. J'avais un référentiel simple dans mon code qui s'appelait ainsi (le code est en C #):
var user = /* create user somehow */;
_userRepository.Add(user);
/* do some other stuff*/
_userRepository.SaveChanges();
SaveChanges
était un simple wrapper qui valide les modifications de la base de données:
void SaveChanges()
{
_dataContext.SaveChanges();
_logger.Log("User DB updated: " + someImportantInfo);
}
Ensuite, après un certain temps, j'ai dû implémenter une nouvelle logique qui enverrait des notifications par e-mail chaque fois qu'un utilisateur était créé dans le système. Comme il y avait beaucoup d'appels à _userRepository.Add()
et SaveChanges
autour du système, j'ai décidé de mettre à jour SaveChanges
comme ceci:
void SaveChanges()
{
_dataContext.SaveChanges();
_logger.Log("User DB updated: " + someImportantInfo);
foreach (var newUser in dataContext.GetAddedUsers())
{
_eventService.RaiseEvent(new UserCreatedEvent(newUser ))
}
}
De cette façon, le code externe pourrait s'abonner à UserCreatedEvent et gérer la logique métier requise qui enverrait des notifications.
Mais on m'a fait remarquer que ma modification de SaveChanges
violait le principe de responsabilité unique, et que SaveChanges
devait simplement enregistrer et ne déclencher aucun événement.
Est-ce un point valable? Il me semble que déclencher un événement ici est essentiellement la même chose que la journalisation: il suffit d'ajouter des fonctionnalités secondaires à la fonction. Et SRP ne vous interdit pas d'utiliser des événements de journalisation ou de déclenchement dans vos fonctions, il dit simplement qu'une telle logique doit être encapsulée dans d'autres classes, et il est normal qu'un référentiel appelle ces autres classes.
Oui, cela peut être une condition valide d'avoir un référentiel qui déclenche certains événements sur certaines actions comme Add
ou SaveChanges
- et je ne vais pas remettre cela en question (comme certaines autres réponses) juste parce que votre exemple spécifique d'ajout d'utilisateurs et d'envoi d'e-mails peut sembler un peu artificiel. Dans ce qui suit, supposons que cette exigence est parfaitement justifiée dans le contexte de votre système.
Donc oui , encoder la mécanique des événements ainsi que la journalisation ainsi que l'enregistrement dans une méthode viole le SRP . Dans de nombreux cas, il s'agit probablement d'une violation acceptable, en particulier lorsque personne ne souhaite jamais répartir les responsabilités de maintenance des "enregistrer les modifications" et "déclencher l'événement" entre différentes équipes/responsables. Mais supposons qu'un jour quelqu'un veuille faire exactement cela, peut-il être résolu de manière simple, peut-être en mettant le code de ces problèmes dans différentes bibliothèques de classes?
La solution à cela est de laisser votre référentiel d'origine rester responsable de la validation des modifications de la base de données, rien d'autre, et de créer un proxy référentiel qui a exactement le même public , réutilise le référentiel d'origine et ajoute les mécanismes d'événement supplémentaires aux méthodes.
// In EventFiringUserRepo:
public void SaveChanges()
{
_basicRepo.SaveChanges();
FireEventsForNewlyAddedUsers();
}
private void FireEventsForNewlyAddedUsers()
{
foreach (var newUser in _basicRepo.DataContext.GetAddedUsers())
{
_eventService.RaiseEvent(new UserCreatedEvent(newUser))
}
}
Vous pouvez appeler la classe proxy un NotifyingRepository
ou ObservableRepository
si vous le souhaitez, sur le modèle de @ Peter's réponse très votée (qui en fait ne dit pas comment résoudre le Violation de SRP, disant seulement que la violation est correcte).
La nouvelle et l'ancienne classe de référentiel devraient toutes deux dériver d'une interface commune, comme comme indiqué dans la description classique du modèle de proxy .
Ensuite, dans votre code d'origine, initialisez _userRepository
par un objet de la nouvelle classe EventFiringUserRepo
. De cette façon, vous gardez le référentiel d'origine séparé de la mécanique des événements. Si nécessaire, vous pouvez avoir le référentiel de déclenchement d'événement et le référentiel d'origine côte à côte et laisser les appelants décider s'ils utilisent le premier ou le dernier.
Pour répondre à une préoccupation mentionnée dans les commentaires: cela ne mène-t-il pas à des proxys au-dessus des proxies au-dessus des proxys, et ainsi de suite? En fait, l'ajout de la mécanique des événements crée une fondation pour ajouter plus exigences du type "envoyer des e-mails" en vous abonnant simplement aux événements, donc respectez également le SRP avec ces exigences, sans procuration supplémentaire. Mais la seule chose qui doit être ajoutée une fois ici est la mécanique de l'événement elle-même.
Si ce type de séparation en vaut vraiment la peine dans le contexte de votre système, c'est vous et votre réviseur devez en décider vous-même. Je ne séparerais probablement pas la journalisation du code d'origine, ni en utilisant un autre proxy ni en ajoutant un enregistreur à l'événement d'écoute, bien que ce soit possible.
L'envoi d'une notification indiquant que le magasin de données persistantes a changé semble être une chose sensée à faire lors de l'enregistrement.
Bien sûr, vous ne devez pas considérer Add comme un cas spécial - vous devez également déclencher des événements pour Modify et Delete. C'est le traitement spécial du cas "Add" qui sent, oblige le lecteur à expliquer pourquoi il sent, et conduit finalement certains lecteurs du code à conclure qu'il doit violer SRP.
Un référentiel "notifiant" qui peut être interrogé, modifié et déclenche des événements sur les modifications, est un objet parfaitement normal. Vous pouvez vous attendre à en trouver plusieurs variantes dans à peu près n'importe quel projet de taille décente.
Mais un référentiel "notifiant" est-il vraiment ce dont vous avez besoin? Vous avez mentionné C #: de nombreuses personnes conviendraient que l'utilisation d'un System.Collections.ObjectModel.ObservableCollection<>
au lieu de System.Collections.Generic.List<>
lorsque ce dernier est tout ce dont vous avez besoin, c'est toutes sortes de mauvais et de mauvais, mais peu pointeraient immédiatement vers SRP.
Ce que vous faites maintenant, c'est échanger votre UserList _userRepository
avec un ObservableUserCollection _userRepository
. Si c'est le meilleur plan d'action ou non dépend de l'application. Mais alors qu'il fait incontestablement le _userRepository
considérablement moins léger, à mon humble avis, il ne viole pas SRP.
Oui, c'est une violation du principe de responsabilité unique et un point valable.
Une meilleure conception serait d'avoir un processus séparé pour récupérer les "nouveaux utilisateurs" du référentiel et envoyer les e-mails. Garder une trace des utilisateurs qui ont reçu un e-mail, des échecs, des renvois, etc., etc.
De cette façon, vous pouvez gérer les erreurs, les plantages, etc., tout en évitant que votre référentiel accapare toutes les exigences qui ont l'idée que des événements se produisent "lorsque quelque chose est validé dans la base de données".
Le référentiel ne sait pas qu'un utilisateur que vous ajoutez est un nouvel utilisateur. Sa responsabilité consiste simplement à stocker l'utilisateur.
Cela vaut probablement la peine de développer les commentaires ci-dessous.
Le référentiel ne sait pas qu'un utilisateur que vous ajoutez est un nouvel utilisateur - oui, il a une méthode appelée Ajouter. Sa sémantique implique que tous les utilisateurs ajoutés sont de nouveaux utilisateurs. Combinez tous les arguments passés à Add avant d'appeler Save - et vous obtenez tous les nouveaux utilisateurs
Incorrect. Vous associez "Ajouté au référentiel" et "Nouveau".
"Ajouté au référentiel" signifie exactement ce qu'il dit. Je peux ajouter et supprimer et rajouter des utilisateurs à divers référentiels.
"Nouveau" est un état d'un utilisateur défini par des règles métier.
Actuellement, la règle métier peut être "Nouveau == vient d'être ajouté au référentiel", mais cela ne signifie pas que ce n'est pas une responsabilité distincte de connaître et d'appliquer cette règle.
Vous devez faire attention à éviter ce genre de pensée centrée sur la base de données. Vous aurez des processus de cas Edge qui ajouteront de nouveaux utilisateurs au référentiel et lorsque vous leur enverrez des e-mails, toutes les entreprises diront "Bien sûr, ce ne sont pas de" nouveaux "utilisateurs! La règle réelle est X "
Cette réponse est à mon humble avis complètement manquante: le dépôt est exactement le seul endroit central du code qui sait quand de nouveaux utilisateurs sont ajoutés
Incorrect. Pour les raisons ci-dessus, de plus, ce n'est pas un emplacement central, sauf si vous incluez réellement le code d'envoi d'e-mail dans la classe plutôt que de simplement déclencher un événement.
Vous aurez des applications qui utilisent la classe de référentiel, mais vous n'avez pas le code pour envoyer l'e-mail. Lorsque vous ajoutez des utilisateurs dans ces applications, l'e-mail ne sera pas envoyé.
Est-ce un point valable?
Oui, bien que cela dépende beaucoup de la structure de votre code. Je n'ai pas le contexte complet donc je vais essayer de parler en général.
Il me semble que déclencher un événement ici est essentiellement la même chose que la journalisation: il suffit d'ajouter des fonctionnalités secondaires à la fonction.
Ce n'est absolument pas le cas. La journalisation ne fait pas partie du flux commercial, elle peut être désactivée, elle ne devrait pas provoquer d'effets secondaires (commerciaux) et ne devrait en aucun cas influencer l'état et la santé de votre application, même si vous n'étiez pas en mesure de vous connecter pour une raison quelconque. plus rien. Comparez maintenant cela avec la logique que vous avez ajoutée.
Et SRP ne vous interdit pas d'utiliser des événements de journalisation ou de déclenchement dans vos fonctions, il dit simplement qu'une telle logique doit être encapsulée dans d'autres classes, et il est normal qu'un référentiel appelle ces autres classes.
SRP fonctionne en tandem avec ISP (S et I dans SOLID). Vous vous retrouvez avec de nombreuses classes et méthodes qui font des choses très spécifiques et rien d'autre. Ils sont très ciblés, très faciles à mettre à jour ou à remplacer, et en général faciles (er) à tester. Bien sûr, dans la pratique, vous aurez également quelques classes plus importantes qui traitent de l'orchestration: elles auront un certain nombre de dépendances et se concentreront non pas sur les actions atomisées, mais sur les actions métier, qui peuvent nécessiter plusieurs étapes. Tant que le contexte commercial est clair, ils peuvent aussi être appelés responsabilité unique, mais comme vous l'avez dit correctement, au fur et à mesure que le code se développe, vous voudrez peut-être en extraire une partie dans de nouvelles classes/interfaces.
Revenons maintenant à votre exemple particulier. Si vous devez absolument envoyer une notification chaque fois qu'un utilisateur est créé et peut-être même effectuer d'autres actions plus spécialisées, vous pouvez créer un service distinct qui encapsule cette exigence, quelque chose comme UserCreationService
, qui expose une méthode, Add(user)
, qui gère à la fois le stockage (l'appel à votre référentiel) et la notification comme une seule opération commerciale. Ou faites-le dans votre extrait d'origine, après _userRepository.SaveChanges();
SRP concerne théoriquement les personnes , comme l'explique Oncle Bob dans son article The Single Responsibility Principle . Merci à Robert Harvey de l'avoir fourni dans votre commentaire.
La bonne question est:
Si cette partie prenante est également en charge de la persistance des données (peu probable mais possible), cela ne viole pas le SRP. Sinon, c'est le cas.
Bien que techniquement il n'y ait rien de mal à ce que les référentiels notifient les événements, je suggérerais de l'examiner d'un point de vue fonctionnel où sa commodité soulève certaines préoccupations.
Créer un utilisateur, décider ce qu'est un nouvel utilisateur et sa persistance sont 3 choses différentes .
Localité à moi
Tenez compte de la prémisse précédente avant de décider si le référentiel est le bon endroit pour notifier les événements métier (quel que soit le SRP). Notez que j'ai dit événement commercial parce que pour moi UserCreated
a une connotation différente de UserStored
ou UserAdded
1. Je considérerais également que chacun de ces événements s'adresse à différents publics.
D'un côté, la création d'utilisateurs est une règle spécifique à l'entreprise qui peut impliquer ou non la persistance. Cela pourrait impliquer plus d'opérations commerciales, impliquant plus d'opérations de base de données/réseau. Opérations dont la couche de persistance n'est pas au courant. La couche de persistance n'a pas suffisamment de contexte pour décider si le cas d'utilisation s'est terminé avec succès ou non.
D'un autre côté, il n'est pas nécessairement vrai que _dataContext.SaveChanges();
a persisté avec succès l'utilisateur. Cela dépendra de l'étendue des transactions de la base de données. Par exemple, cela pourrait être vrai pour des bases de données comme MongoDB, dont les transactions sont atomiques, mais pas pour les SGBDR traditionnels qui implémentent des transactions ACID où il pourrait y avoir plus de transactions impliquées et encore à valider.
Est-ce un point valable?
Il pourrait être. Cependant, j'oserais dire que ce n'est pas seulement une question de SRP (techniquement parlant), c'est aussi une question de commodité (fonctionnellement parlant).
Il me semble que déclencher un événement ici est essentiellement la même chose que la journalisation
Absolument pas. La journalisation est censée n'avoir aucun effet secondaire, cependant, comme vous l'avez suggéré, l'événement UserCreated
est susceptible d'entraîner d'autres opérations commerciales. Comme les notifications. 3
il dit simplement qu'une telle logique doit être encapsulée dans d'autres classes, et il est normal qu'un référentiel appelle ces autres classes
Pas nécessairement vrai. SRP n'est pas une préoccupation spécifique à une classe uniquement. Il opère à différents niveaux d'abstractions, comme les couches, les bibliothèques et les systèmes! Il s'agit de cohésion, de garder ensemble ce qui change pour les mêmes raisons de la main des mêmes acteurs . Si la création d'utilisateur ( cas d'utilisation ) change, c'est probablement le moment et les raisons pour lesquelles l'événement se produit changent également.
1: Nommer correctement les choses est également important.
2: Supposons que nous ayons envoyé UserCreated
après _dataContext.SaveChanges();
, mais que la transaction de base de données a échoué ultérieurement en raison de problèmes de connexion ou de violations de contraintes. Soyez prudent avec la diffusion prématurée d'événements, car ses effets secondaires peuvent être difficiles à annuler (si cela est même possible).
3: Les processus de notification qui ne sont pas traités de manière adéquate peuvent vous faire déclencher des notifications qui ne peuvent pas être annulées/sup>
Ne vous inquiétez pas du principe de responsabilité unique. Cela ne vous aidera pas à prendre une bonne décision ici parce que vous pouvez choisir subjectivement un concept particulier comme une "responsabilité". Vous pouvez dire que la responsabilité de la classe consiste à gérer la persistance des données dans la base de données, ou vous pouvez dire que sa responsabilité consiste à effectuer tout le travail lié à la création d'un utilisateur. Ce ne sont que différents niveaux du comportement de l'application, et ce sont tous deux des expressions conceptuelles valides d'une "responsabilité unique". Ce principe est donc inutile pour résoudre votre problème.
Le principe le plus utile à appliquer dans ce cas est le principe de moindre surprise . Alors posons la question: est-ce surprenant qu'un référentiel ayant pour rôle principal de conserver les données dans une base de données envoie également des e-mails?
Oui, c'est très surprenant. Ce sont deux systèmes externes complètement séparés, et le nom SaveChanges
n'implique pas également l'envoi de notifications. Le fait que vous déléguez cela à un événement rend le comportement encore plus surprenant, car quelqu'un qui lit le code ne peut plus facilement voir quels comportements supplémentaires sont invoqués. L'indirection nuit à la lisibilité. Parfois, les avantages valent les coûts de lisibilité, mais pas lorsque vous invoquez automatiquement un système externe supplémentaire qui a des effets observables pour les utilisateurs finaux. (La journalisation peut être exclue ici car son effet est essentiellement la tenue de registres à des fins de débogage. Les utilisateurs finaux ne consomment pas le journal, donc il n'y a aucun mal à toujours journaliser.) Pire encore, cela réduit la flexibilité du timing d'envoi de l'e-mail, rendant impossible l'imbrication d'autres opérations entre la sauvegarde et la notification.
Si votre code doit généralement envoyer une notification lorsqu'un utilisateur est créé avec succès, vous pouvez créer une méthode qui le fait:
public void AddUserAndNotify(IUserRepository repo, IEmailNotification notifier, MyUser user)
{
repo.Add(user);
repo.SaveChanges();
notifier.SendUserCreatedNotification(user);
}
Mais si cela ajoute de la valeur dépend des spécificités de votre application.
Je découragerais en fait l'existence de la méthode SaveChanges
. Cette méthode va vraisemblablement valider une transaction de base de données, mais d'autres référentiels peuvent avoir modifié la base de données dans la même transaction . Le fait qu'il les engage tous est à nouveau surprenant, car SaveChanges
est spécifiquement lié à cette instance du référentiel d'utilisateurs.
Le modèle le plus simple pour gérer une transaction de base de données est un bloc externe using
:
using (DataContext context = new DataContext())
{
_userRepository.Add(context, user);
context.SaveChanges();
notifier.SendUserCreatedNotification(user);
}
Cela donne au programmeur un contrôle explicite sur le moment où les modifications pour les dépôts tous sont enregistrées, force le code à documenter explicitement la séquence d'événements qui doit se produire avant une validation, garantit qu'un retour arrière est émis en cas d'erreur (en supposant que DataContext.Dispose
émet une restauration), et évite les connexions cachées entre les classes avec état.
Je préfère également ne pas envoyer l'e-mail directement dans la demande. Il serait plus robuste d'enregistrer le besoin pour une notification dans une file d'attente. Cela permettrait une meilleure gestion des pannes. En particulier, si une erreur se produit lors de l'envoi de l'e-mail, il peut être réessayé ultérieurement sans interrompre l'enregistrement de l'utilisateur, et cela évite le cas où l'utilisateur est créé mais une erreur est renvoyée par le site.
using (DataContext context = new DataContext())
{
_userRepository.Add(context, user);
_emailNotificationQueue.AddUserCreateNotification(user);
_emailNotificationQueue.Commit();
context.SaveChanges();
}
Il est préférable de valider la file d'attente de notification en premier car le consommateur de la file d'attente peut vérifier que l'utilisateur existe avant d'envoyer l'e-mail, dans le cas où l'appel context.SaveChanges()
échoue. (Sinon, vous aurez besoin d'une stratégie de validation complète en deux phases pour éviter les heisenbugs.)
L'essentiel est d'être pratique. Réfléchissez réellement aux conséquences (à la fois en termes de risques et d'avantages) de l'écriture de code d'une manière particulière. Je trouve que le "principe de responsabilité unique" ne m'aide pas très souvent à faire cela, tandis que le "principe de moindre surprise" m'aide souvent à entrer dans la tête d'un autre développeur (pour ainsi dire) et à réfléchir à ce qui pourrait arriver.
Non cela ne viole pas le SRP.
Beaucoup semblent penser que le principe de responsabilité unique signifie qu'une fonction ne devrait faire "qu'une seule chose", puis se laisser entraîner dans une discussion sur ce qui constitue "une seule chose".
Mais ce n'est pas ce que signifie le principe. Il s'agit de préoccupations au niveau de l'entreprise. Une classe ne doit pas implémenter plusieurs préoccupations ou exigences qui peuvent changer indépendamment au niveau de l'entreprise. Disons qu'une classe stocke l'utilisateur et envoie un message de bienvenue codé en dur par e-mail. De multiples préoccupations indépendantes pourraient entraîner la modification des exigences d'une telle classe. Le concepteur peut exiger que la feuille de style/HTML du courrier électronique change. L'expert en communication pourrait exiger que le libellé du courrier soit modifié. Et l'expert UX pourrait décider que le courrier devrait être envoyé à un point différent du flux d'intégration. Ainsi, la classe est soumise à de multiples modifications des exigences de sources indépendantes. Cela viole le SRP.
Mais le déclenchement d'un événement ne viole pas alors le SRP, car l'événement ne dépend que de la sauvegarde de l'utilisateur et non d'aucune autre préoccupation. Les événements sont en fait un très bon moyen de maintenir le SRP, car vous pouvez avoir un e-mail déclenché par la sauvegarde sans que le référentiel ne soit affecté par - ou même informé - par le courrier.
Actuellement, SaveChanges
fait deux choses: il enregistre les modifications et enregistre ce qu'il fait. Maintenant, vous voulez y ajouter autre chose: envoyer des notifications par e-mail.
Vous avez eu l'idée intelligente d'y ajouter un événement, mais cela a été critiqué pour avoir violé le principe de responsabilité unique (PRS), sans remarquer qu'il avait déjà été violé.
Pour obtenir une solution SRP pure, d'abord déclenchez l'événement, puis appelez tous les crochets pour cet événement, dont il y en a maintenant trois: l'enregistrement, la journalisation et enfin l'envoi de courriels.
Soit vous déclenchez d'abord l'événement, soit vous devez ajouter à SaveChanges
. Votre solution est un hybride entre les deux. Il ne traite pas de la violation existante alors qu'il encourage à l'empêcher d'augmenter au-delà de trois choses. La refactorisation du code existant pour se conformer à SRP peut nécessiter plus de travail que ce qui est strictement nécessaire. Cela dépend de votre projet jusqu'à quel point ils veulent prendre SRP.