Entities vs. Services im Domain Driven Design (DDD)
Ich wollte ein Feedback zum Design einer Chat-Anwendung erhalten, die ich kürzlich entwickelt habe. Die Anwendung ist in PHP geschrieben, aber die Sprache ist hier wahrscheinlich nicht wichtig.
Die Hauptkomponenten sind User, Conversationund Message:
class User {
public function getId(): int {}
public function getUsername(): string {}
public function getFirstName(): string {}
public function getLastName(): string {}
// token used with a 3rd party API for sending messages
public function getToken(): ?string;
// if a user doesn't have a token they can't be messaged
public function isOnline(): bool {}
public function __construct(int $id, string $username, ...) {}
}
class Conversation {
public function getId(): int {}
public function getUsers(): User[] {}
public function __construct(int $id, array $users) {}
}
class Message {
public function getId(): int {}
public function getText(): string {}
public function getConversation(): Conversation {}
public function getAuthor(): User {}
public function __construct(int $id, string $text, Conversation $conversation) {}
}
Ich habe auch einige Dienste:
class MessageSender implements MessageSenderInterface
{
private LoggerInterface $logger;
public function send(Message $message): void { foreach ($message->getConversation()->getUsers() as $user) { if (!$user->isOnline()) {
$this->logger->warn('User is offline and cannot be messaged'); } if ($user->equals($message->getAuthor())) { // continue; don't send messages to authors } $messageData = [
'to' => $user->getToken(), 'from' => $message->getAuthor()->getUsername(),
'text' => $message->getText(),
];
// send the message through some external API
}
}
}
Der größte Teil der Arbeit wird über das erledigt MessageSender, aber ich frage mich, ob die Domain möglicherweise besser mit so etwas gekapselt ist:
class Message {
public function getId(): int {}
public function getText(): string {}
public function __construct(int $id, string $text, Conversation $conversation) {}
public function send(MessageSenderInterface $sender, LoggerInterface $logger) {
... send logic in here
}
}
Sie können sehen, dass durch Verschieben der sendFunktionalität innerhalb des MessageObjekts zwei exponierte Eigenschaften vollständig entfernt werden ( getConversationund nicht mehr vorhanden getAuthorsind) und der Dienst effektiv vollständig entfernt werden kann. Infolgedessen kennt das Nachrichtenobjekt jetzt Logger und Nachrichtensender, auch wenn es sich nur um Schnittstellen handelt.
Was sagt DDD dazu? Ich bevorzuge es, weniger Daten verfügbar zu machen und mag die Kapselung, die die zweite Option bietet.
Antworten
Einige DDD-Praktiker schlagen vor, dass es in Ordnung ist, Technologieebenen dynamisch in Ihr Domänenmodell einzufügen. Auf diese Weise modelliert, ist Ihr Domain-Code nicht direkt von Technologiekomponenten abhängig und kommuniziert nur über abstrakte Schnittstellen.
Ich würde jedoch vorschlagen, dass Sie nur den domänenbezogenen Code in der Domänenmodellschicht behalten und alle Technologieinteraktionen (DB, Message Brokers, Logger) in Ihrer Serviceschicht organisieren. Dies sind normalerweise Anwendungsdienste und Befehlshandler im DDD / CQRS-Jargon.
Hier sind einige Gründe, warum das Platzieren von Code, der mit Technologiekomponenten im Domänenmodell interagiert, wahrscheinlich eine schlechte Praxis ist:
- Sie verwenden DDD, um die Komplexität zu reduzieren. Wenn Sie jedoch technologische Aspekte in Ihr Domänenmodell einfügen, wird Ihre Vision einer echten Geschäftslogik beeinträchtigt. Es geht bei der Übersetzung verloren, wenn der größte Teil Ihres Codes technologische Probleme wie das Laden und Speichern von Daten oder das Senden von Nachrichten behandelt.
- Ihr Domain-Modell verfügt über Kenntnisse über Workflows in Ihren Technologiekomponenten. Es weiß unter anderem, wann Daten beibehalten werden müssen, wie Daten beibehalten werden müssen und wann Nachrichten gesendet werden müssen.
- Sie benötigen ohnehin eine andere Ebene, um Überprüfungen durchzuführen, die sich über die gesamte Sammlung erstrecken (z. B. Überprüfung der Eindeutigkeit der E-Mail-Adresse).
- Ihre Domain kann nicht isoliert getestet werden. Sie organisieren immer Technologiekomponenten (oder Mocks), wenn Sie die Domänenlogik testen.
Wenn Sie diesen Denkprozess Ihrem Beispiel zuordnen, befindet sich die Logik zur Entscheidung, ob die Nachricht gesendet werden soll, in der Domänenschicht. Der Code zum Formatieren der Ereignisdaten und zum Versenden an den Nachrichtenbroker befindet sich in der Serviceschicht.
In einem separaten Hinweis würde man diese drei Komponenten (Benutzer, Konversation und Nachricht) in einer DDD-Anwendung nicht auf diese Weise organisieren. Sie würden über Transaktionsgrenzen nachdenken und aggregatesDatenobjekte erstellen .
A Userwäre ein Aggregat mit eigenen eingeschlossenen Objekten und Verhaltensweisen. A Conversationwäre ein anderes Aggregat und würde MessageObjekte darin einschließen , und alle nachrichtenbezogenen Interaktionen würden über das Konversationsaggregat erfolgen.
Da es sich um separate Aggregate handelt, würden Sie keine UserObjekte in ein ConversationAggregat einbetten . Sie hätten nur Referenzen (Benutzer-IDs). Sie hätten ein Lesemodell, das verfolgt, welche Benutzer in einer Konversation online sind, und es zum Versenden von Nachrichten verwendet.
Ich schlage vor, dass Sie den EventSourcery- Kurs durchlaufen , um diese Konzepte besser zu verstehen. Der Code für den Kurs ist tatsächlich in PHP.
Update 1:
Ihr MessageObjekt greift auf das ConversationObjekt zurück, um Benutzer für seine Arbeit zu sammeln. Daher ist es sinnvoll, es in das ConversationObjekt einzuschließen .
Ich werde über zwei Konzepte sprechen, die derzeit möglicherweise nicht Teil Ihrer Architektur sind, aber helfen würden: Anwendungsdienste und Domänenereignisse .
Sie würden eine Zwischenschicht "Application Service" zwischen Ihrem Controller und der Domänenschicht einführen.
Der App-Dienst ist dafür verantwortlich, die (injizierten) Infrastrukturdienste aufzurufen, die Domänenschicht aufzurufen und die erforderlichen Daten beizubehalten / zu laden. Der Controller ist nur dafür verantwortlich, die Anforderungsparameter zu erfassen (in Ihrem Fall Benutzereingaben zu erfassen), die Authentifizierung sicherzustellen (falls erforderlich) und dann die Application Service-Methode aufzurufen.
Application Services sind die direkten Clients des Domänenmodells und fungieren als Vermittler für die Koordination zwischen der Außenwelt und der Domänenschicht. Sie sind für die Behandlung von Infrastrukturproblemen wie ID-Generierung, Transaktionsmanagement, Verschlüsselung usw. verantwortlich. Diese Verantwortlichkeiten betreffen auch nicht die Controller-Schicht.
Nehmen wir an, es MessageSenderwird in einen Anwendungsdienst umgewandelt. Hier ist ein Beispiel für einen Kontrollfluss:
- API sendet die Anfrage mit
conversation_id,user_id(Autor) undmessage. - Der Anwendungsdienst wird
Conversationaus der Datenbank geladen. Wenn die Konversations-ID gültig ist und der Autor an dieser Konversation teilnehmen kann (dies sind Invarianten), rufen Sie einesendMethode für dasConversationObjekt auf. - Das Conversation-Objekt fügt die Nachricht zu seinen eigenen Daten hinzu, führt seine Geschäftslogik aus und entscheidet, welche Benutzer die Nachricht senden sollen.
- Das Conversation-Objekt wird ausgelöst
events, um an eine Nachrichtenschnittstelle gesendet zu werden (diese werden in einer temporären Variablen gesammelt, die für diese Sitzung gültig ist) und zurückgegeben. Diese Ereignisse enthalten die gesamten Daten, um Details der Nachricht (Zeitstempel, Überwachungsprotokoll usw.) zu rekonstruieren, und berücksichtigen nicht nur das, was später an den Empfänger gesendet wird. - Der Anwendungsdienst behält das aktualisierte Konversationsobjekt bei und löst alle Ereignisse aus, die während der letzten Verarbeitung ausgelöst wurden.
- Ein Teilnehmer, der auf das Ereignis wartet, sammelt es, erstellt die Nachricht im richtigen Format (wählt nur die Daten aus, die er für das Ereignis benötigt) und führt den eigentlichen Push an den Empfänger durch.
Mit dieser Struktur haben Sie eine gute Umsetzung des Open-Closed-Prinzips.
- Ihr Konversationsobjekt ändert sich nur, wenn Sie die Geschäftslogik ändern (z. B. wer die Nachricht erhalten soll).
- Ihr Anwendungsdienst ändert sich selten, da er lediglich Konversationsobjekte lädt und beibehält und alle ausgelösten Ereignisse im Nachrichtenbroker veröffentlicht.
- Ihre Teilnehmerlogik ändert sich nur, wenn Sie zusätzliche Daten an den Empfänger senden.
Update 2: Pseudocode
Anwendungsservice:
class MessageSender(ApplicationService):
def send_message(request):
// Deconstruct request object and call method
conversation = ConversationRepo.find_by_id(request.id)
// Call a method on the aggregate that generates events and updates the aggregates state
conversation.send_message(request.from_user_id, request.content)
// Application Service saves the aggregate
ConversationRepo.save(conversation)
// Any events raised are dispatched once the conversation has been successfully saved
for event in conversation.events:
message_interface.dispatch(event)
Domänenmodell:
class User(Aggregate):
id: int
username: str
first_name: str
last_name: str
token: str
is_online: bool
class Message(Entity):
id: int
author_id: int
content: str
sent_at: time
class Conversation(Aggregate):
id: int
users: list
messages = list
events = list // not persisted
def send_message(from_user_id, content):
for user in self.users:
if not user.is_online:
logger.warn("User is offline and cannot be messaged")
if user.id == from_user_id:
// continue; do not send messages to authors
messages.append(Message(author_id=from_user_id, content=content, sent_at=datetime.utcnow()))
self.events.add(SendMessageEvent(to=user.token, from=user.username, text=content))
Im Allgemeinen (und dies gilt auch außerhalb von DDD) möchten Sie Abhängigkeiten so anordnen, dass der Code von Dingen abhängt, die stabiler sind als er selbst. Stabil bedeutet hier etwas, das sich mit der Entwicklung der Software vergleichsweise seltener ändert. (Zunächst müssen Sie eine fundierte Vermutung anstellen, die auf Ihrem Verständnis der Domäne basiert. Mit der Zeit können Sie jedoch bei der Arbeit am System ein besseres Gefühl dafür bekommen, was tendenziell stabil ist, vorausgesetzt, Sie sind es suchen).
Während es stimmt, dass Entitäten im Allgemeinen die Geschäftslogik kapseln sollten, werden einige der Objekte in Ihrer Domäne besser als einfache Datenstrukturen dargestellt (also nur Sammlungen von Feldern, sehr wenig Verhalten - mehr oder weniger das, was Sie jetzt haben). Diese Objekte sind solche, die keine besonders interessanten Verhaltensinteraktionen mit anderen Objekten haben. Sie sind beispielsweise keine Mitglieder von (nicht trivialen) Aggregaten, die wichtige Geschäftsregeln enthalten. Stattdessen werden sie meistens manipuliert oder von anderem Code als Daten verwendet. Wenn sich die Codebasis weiterentwickelt, können Sie jedoch feststellen, dass mehr verhaltensreiche Objekte entstehen, wenn Sie aktiv nach Möglichkeiten suchen, Teile davon umzugestalten und umzustrukturieren.
Aber im Moment scheint das Konzept einer Nachricht eine ziemlich stabile Sache zu sein. Wenn das der Fall ist, möchte ich lieber, dass die Dinge davon abhängen, und umgekehrt. Und Sie haben Recht, es fühlt sich falsch an, wenn die Nachricht, wie sie derzeit konzipiert ist, über Logger und externe Nachrichtenversanddienste Bescheid weiß.
Ich möchte Ihre Aufmerksamkeit jedoch auf etwas anderes lenken. Schauen Sie sich Ihre MessageSenderKlasse und ihre sendMethode an. Es enthält eine geeignete Domänenlogik: Sie enthält Regeln, die bestimmen, wann eine Nachricht an wen gesendet werden kann usw. Anschließend wird jedoch auf eine andere Abstraktionsebene gesprungen und Daten in ein Format umgewandelt, das für eine externe API geeignet ist:
class MessageSender implements MessageSenderInterface
{
private LoggerInterface $logger; public function send(Message $message): void {
//////// --- this is domain logic --- ///////////////////////////
/////////////////////////////////////////////////////////////////
foreach ($message->getConversation()->getUsers() as $user) {
if (!$user->isOnline()) { $this->logger->warn('User is offline and cannot be messaged');
}
if ($user->equals($message->getAuthor())) {
// continue; don't send messages to authors
}
//////// --- this is infrastructure --- ////////////////////
////////////////////////////////////////////////////////////
$messageData = [ 'to' => $user->getToken(),
'from' => $message->getAuthor()->getUsername(), 'text' => $message->getText(),
];
// send the message through some external API
}
}
}
IMO, das sachdienlichere Refactoring besteht darin, die infrastrukturbezogenen (untergeordneten) Teile zu trennen (überprüfen Sie Ihren Code auf andere Stellen, an denen dies möglich ist). Es würde ungefähr so aussehen:
// Note that this class doesn't implement the interface now. It doesn't need to.
// Instead, it has a reference to a MessageServiceInterface instance.
class MessageSender
{
// these could be injected
private LoggerInterface $logger; private MessageServiceInterface $messageService; // <---
public function send(Message $message): void { foreach ($message->getConversation()->getUsers() as $user) { if (!$user->isOnline()) {
$this->logger->warn('User is offline and cannot be messaged'); continue; } if ($user->equals($message->getAuthor())) { continue; // don't send messages to authors } $this->messageService->sendTo($user, $message);
}
}
// other methods...
}
Jetzt werden keine Abstraktionsebenen gemischt, und wenn Sie den Code lesen, wird die Geschäftslogik tatsächlich genauer ausgedrückt, ohne die zusätzliche Unordnung, die mit der Logistik des Sendens der Nachricht verbunden war (die Protokollierung bringt die Dinge ein wenig durcheinander, aber immer noch). Außerdem werden die Verantwortlichkeiten klarer getrennt: Diese Klasse behandelt die Geschäftslogik auf höherer Ebene, während das $messageServiceObjekt das Aufrufen der externen API (und die damit verbundene Datenkonvertierung) übernimmt. Noch wichtiger ist, dass die Aufgabentrennung jetzt eine klare Schnittstelle zwischen den beiden Komponenten bedeutet. In anderen Teilen Ihrer Codebasis sind die beiden Aspekte des Codes möglicherweise stärker miteinander vermischt und implizit auf nicht offensichtliche Weise miteinander gekoppelt - und wenn Sie sie so belassen, werden sie mit der Zeit nur stärker gekoppelt.
Dies ermöglicht auch eine ordnungsgemäße Abhängigkeitsinversion - die MessageSendererklärt, dass sie auf einer Abstraktion beruht, die MessageServiceInterface(diese Schnittstelle sollte sich in derselben Schicht befinden wie MessageSender), und dann implementiert eine Klasse in der Infrastrukturschicht diese Schnittstelle und stellt die tatsächlichen Funktionen zum Senden von Nachrichten bereit.
Wenn Sie jetzt zu Ihren Domain-Entitäten zurückkehren, können Sie wahrscheinlich einige Dinge tun, um sie mit Verhalten zu bereichern - und Ihren Code ausdrucksvoller und lesbarer zu machen. In MessageSenderüberprüfen Sie beispielsweise, ob der aktuelle Benutzer der Autor der Nachricht ist oder nicht. Dies scheint eine Regel zu sein, von der erwartet werden kann, dass sie relativ stabil bleibt. Eine Sache, die Sie hier tun können, ist, entweder zu aktivieren Messageoder ConversationIhnen eine Liste von Benutzern mit dem ausgeschlossenen Autor zu geben:
foreach ($message->getConversation()->getUsers() as $user) { //... if ($user->equals($message->getAuthor())) { continue; // don't send messages to authors } //... } // ----- vs -------------------------------- foreach ($message->getRecipients() as $recipient) {
//...
}
Sie werden wahrscheinlich mehr Möglichkeiten wie diese finden, aber gehen Sie nicht über Bord - da andere Dinge von diesen Entitäten abhängen, versuchen Sie, sie so umzugestalten, dass ihre Stabilität nicht wesentlich beeinträchtigt wird. Wenn Sie eine Methode wie diese einführen und diese bei fast jeder Änderung ändern muss (was zu kaskadierenden Änderungen führt), ist diese Methode in Bezug auf das Design nicht sinnvoll. Dieser Code gehört woanders hin, wo die Auswirkungen von Änderungen enthalten sein können.
Bei DDD geht es darum, Domänenkonzepte zu entdecken und Ihre Annahmen zu überprüfen. Sie können dies weiter untersuchen. Es ist möglich, dass die vorherige Anordnung nicht ganz richtig ist. Sollte eine Nachricht wissen, wer ihre Empfänger sind? Was ist, wenn Sie es an ein anderes Gespräch weiterleiten möchten? Außerdem wird Ihr vielleicht MessageSenderbesser als so etwas wie ein beschrieben MessageBoard. Möglicherweise führt das Message Board eine Teilnehmerliste (wie Sie sie gerade anrufen Conversation), die Nachricht selbst jedoch nicht. Vielleicht könnte der Code aus dem vorherigen Beispiel so geschrieben werden:
// in MessageBoard.send($message)
$recipients = $this->participants->getRecipientsFor($message); foreach ($recipients as $recipient) {
//...
}
Jetzt haben Sie eine ziemlich allgemeine Idee eines "Nachrichtenabsenders" in ein tatsächliches Domänenkonzept umgewandelt - es ist jetzt ein explizites Domänenobjekt und auch ein nicht anämisches. Vielleicht macht das mehr Sinn, vielleicht auch nicht.
Ich sage nicht, dass dies der richtige Weg ist, ich versuche nur, Ihnen einige Ideen zu geben, damit Sie über den Tellerrand hinaus denken können.