Innovation

Der Entwurf eines Domänenmodells

Dino Esposito

 

Dino EspositoMit der aktuellen Veröffentlichung von Entity Framework 4.1 und dem neuen Code First-Entwicklungsmuster wird das oberste Gebot der Serverentwicklung umgestoßen: keinen Schritt zu tun, bevor die Datenbank eingerichtet ist. Code First weist Entwickler an, sich auf die Geschäftsdomäne zu konzentrieren und sie anhand von Klassen zu strukturieren. Auf gewisse Weise unterstützt Code First den Einsatz von domänengesteuerten Entwurfsprinzipien (Domain-Driven Design, DDD) im .NET-Space. Eine Geschäftsdomäne wird mit verwandten, vernetzten Entitäten gefüllt, deren Daten jeweils als Eigenschaften verfügbar gemacht werden und deren Verhalten über Methoden und Ereignisse gesteuert werden kann. Wichtig ist vor allem, dass jede Entität einen Status haben und an eine möglicherweise dynamische Liste mit Validierungsregeln gebunden werden kann.

Das Entwerfen eines Objektmodells für ein realistisches Szenario wirft Fragen auf, für die es in aktuellen Demos und Lernprogrammen keine Antworten gibt. In diesem Artikel stelle ich mich der Herausforderung und erläutere den Aufbau einer Customer-Klasse. Dabei gehe ich auf eine Reihe von Entwurfsmustern und -methoden ein, beispielsweise das Party-Muster, Aggregatstämme, Factorys und Technologien wie Codeverträge und Enterprise Library Validation Application Block (VAB).

Zu Referenzzwecken sollten Sie einen Blick auf ein Open-Source-Projekt werfen, dessen Code hier auszugsweise besprochen wird. Das von Andrea Saltarello entworfene Starter Kit-Projekt Northwind (nsk.codeplex.com) illustriert effektive Methoden für den Entwurf mehrschichtiger Lösungen.

Objektmodell im Vergleich zum Domänenmodell

Die Diskussion, ob ein Objekt- oder ein Domänenmodell bereitgestellt werden soll, mag sinnlos erscheinen, und im Großen und Ganzen geht es nur um die begriffliche Unterscheidung. Aber eine präzise Terminologie spielt eine große Rolle, wenn sichergestellt werden soll, dass alle Teammitglieder bei der Verwendung bestimmter Begriffe auch dasselbe Konzept meinen.

In der Softwarebranche versteht im Prinzip jeder unter einem Objektmodell eine Sammlung generischer, aber unter Umständen verwandter Objekte. Und was ist dann ein Domänenmodell? Letztendlich ist ein Domänenmodell eine Art Objektmodell. Die Begriffe synonym zu verwenden ist also nicht unbedingt falsch. Aber wenn eine gewisse Betonung auf dem Begriff Domänenmodell liegt, kann der Terminus eine bestimmte Erwartungshaltung in Bezug auf die Form der zugehörigen Objekte mit sich bringen.

Die Verwendung des Begriffs Domänenmodell ist mit der folgenden Definition von Martin Fowler verknüpft: Ein Objektmodell der Domäne, das sowohl Verhalten als auch Daten umfasst. Das Verhalten bezieht sich dabei auf die Regeln und die spezifische Logik (siehe bit.ly/6Ol6uQ).

Mit DDD werden zahlreiche pragmatische Regeln zu einem Domänenmodell hinzugefügt. In dieser Hinsicht unterscheidet sich ein Domänenmodell von einem Objektmodell durch den intensiven Einsatz von Wertobjekten, die an Stelle primitiver Typen verwendet werden. Eine Ganzzahl kann beispielsweise viele Dinge darstellen: eine Temperatur, einen Geldbetrag, eine Größe oder eine Menge. Ein Domänenmodell verwendet für jedes Szenario einen spezifischen Wertobjekttyp.

Außerdem sollte ein Domänenmodell Aggregatstämme identifizieren. Ein Aggregatstamm ist eine Entität, die durch das gemeinsame Verfassen anderer Entitäten zustande kommt. Objekte im Aggregatstamm sind außerhalb des Stamms bedeutungslos, d. h. es gibt keine Anwendungsfälle, in denen sie verwendet werden, ohne dass sie vom Stammobjekt übergeben werden. Das Paradebeispiel für einen Aggregatstamm ist die Order-Entität. „Order“ enthält „OrderItem“ als Aggregat, aber nicht „Product“. Es ist kaum vorstellbar (obwohl das natürlich gänzlich an Ihren Spezifikationen liegt), dass Sie ein OrderItem-Objekt verwenden müssen, ohne dass es von einer Order-Entität stammt. Andererseits gibt es bestimmt Anwendungsfälle, in denen Sie mit Product-Entitäten, aber nicht mit Order-Entitäten arbeiten. Aggregatstämme sorgen dafür, dass der gültige Status für die untergeordneten Objekte beibehalten wird.

Und bestimmte Domänenmodellklassen stellen möglicherweise öffentliche Factorymethoden an Stelle von Konstruktoren zum Erstellen neuer Instanzen bereit. Das Verwenden einfacher Konstruktoren ist bei vorwiegend eigenständigen Klassen akzeptabel, die nicht wirklich zu einer Hierarchie gehören. Dies gilt auch für Klassen, bei denen die zum Erstellen verwendeten Schritte für den Client von Interesse sind. Bei komplexen Objekten, zum Beispiel Aggregatstämmen, ist jedoch eine zusätzliche Abstraktionsebene über der Instanziierung erforderlich. DDD führt Factoryobjekte (oder einfacher ausgedrückt Factorymethoden für bestimmte Klassen) ein, um Clientanforderungen von internen Objekten und deren Beziehungen und Regeln zu entkoppeln. Eine besonders klare und überschaubare Einführung zu DDD finden Sie unter bit.ly/oxoJD9.

Das Party-Muster

Wenden wir uns jetzt einer Customer-Klasse zu. Angesichts der obigen Ausführungen ist Folgendes eine mögliche Signatur:

public class Customer : Organization, IAggregateRoot
{
  ...
}

Wer ist Ihr Kunde? Eine Einzelperson? Eine Organisation? Eine Kombination aus beiden? Das Party-Muster besagt, dass zwischen den beiden Gruppen unterschieden werden muss und dass Sie klar zu definieren haben, welche Eigenschaften für beide gelten und welche Eigenschaften sich nur auf Einzelpersonen bzw. auf Organisationen beziehen. Der Code in Abbildung 1 ist auf eine Person bzw. eine Organisation beschränkt. Sie können ihn noch weiter aufsplitten, indem Sie Organisationen in gemeinnützige und kommerzielle Unternehmen unterteilen, falls dies für Ihre Geschäftsdomäne erforderlich ist.

Abbildung 1 – Klassen nach Party-Muster

public abstract class Party
{
  public virtual String Name { get; set; }
  public virtual PostalAddress MainPostalAddress { get; set; }
}
public abstract class Person : Party
{
  public virtual String Surname { get; set; }
  public virtual DateTime BirthDate { get; set; }
  public virtual String Ssn { get; set; }
}
public abstract class Organization : Party
{
  public virtual String VatId { get; set; }
}

Es ist nie eine schlechte Idee, sich ins Gedächtnis zu rufen, dass man sich beim Entwerfen eines Modells eng an der tatsächlichen Geschäftsdomäne und nicht an einer abstrakten Darstellung des Geschäfts orientieren sollte. Wenn Kunden in Ihrem Szenario stets Einzelpersonen sind, muss das Party-Muster nicht unbedingt angewendet werden, obwohl es eine Grundlage für die zukünftige Erweiterbarkeit schafft. 

„Customer“ als Aggregatstammklasse

Ein Aggregatstamm ist eine Klasse im Modell, die eine eigenständige Entität darstellt, also eine Entität, deren Existenz nicht von der Beziehung zu anderen Entitäten abhängt. Meistens handelt es sich bei Aggregatstämmen um einzelne Klassen ohne untergeordnete Objekte oder um Klassen, die einfach auf den Stamm anderer Aggregate verweisen. In Abbildung 2 werden weitere Details der Customer-Klasse aufgezeigt.

Abbildung 2 – Customer-Klasse als Aggregatstamm

public class Customer : Organization, IAggregateRoot
{
  public static Customer CreateNewCustomer(
    String id, String companyName, String contactName)
  {
    ...
  }
 
  protected Customer()
  {
  }
 
  public virtual String Id { get; set; }
    ...
 
  public virtual IEnumerable<Order> Orders
  {
    get { return _Orders; }
  }
   
  Boolean IAggregateRoot.CanBeSaved
  {
    get { return IsValidForRegistration; }
  }
 
  Boolean IAggregateRoot.CanBeDeleted
  {
    get { return true; }
  }
}

Wie Sie sehen wird durch die Customer-Klasse die (benutzerdefinierte) IAggregateRoot-Schnittstelle implementiert. So sieht die Schnittstelle aus:

public interface IAggregateRoot
{
  Boolean CanBeSaved { get; }
  Boolean CanBeDeleted { get; }
}

Was ist unter einem Aggregatstamm zu verstehen? Ein Aggregatstamm erzielt Persistenz für die untergeordneten aggregierten Objekte und setzt Invariantenbedingungen für die Gruppe durch. Es hat sich gezeigt, dass ein Aggregatstamm in der Lage sein sollte, zu überprüfen, ob der gesamte Stapel gespeichert oder gelöscht werden kann. Ein eigenständiger Aggregatstamm gibt ohne weitere Überprüfung den Wert „true“ zurück.

Factory und Konstruktor

Ein Konstruktor ist typenspezifisch. Wenn es nur einen Objekttyp gibt, also keine Aggregate und keine komplexe Initialisierungslogik, kann man problemlos einen einfachen Konstruktor verwenden. Im Allgemeinen stellt eine Factory jedoch eine nützliche zusätzliche Abstraktionsschicht dar. Eine Factory kann eine einfache statische Methode für die Entitätsklasse oder eine separate Komponente sein. Die Verwendung einer Factorymethode sorgt auch für bessere Lesbarkeit, da sie verdeutlicht, warum Sie die jeweilige Instanz erstellen. Mit Konstruktoren haben Sie weniger Möglichkeiten, verschiedene Instanziierungsszenarien zu berücksichtigen. Schließlich handelt es sich bei Konstruktoren nicht um benannte Methoden und sie können nur anhand der Signatur unterschieden werden. Insbesondere bei langen Signaturen kann es später schwierig sein, herauszufinden, warum eine bestimmte Instanz abgerufen wird. Abbildung 3 zeigt die Factorymethode für die Customer-Klasse.

Abbildung 3 – Factorymethode für die Customer-Klasse

public static Customer CreateNewCustomer(
  String id, String companyName, String contactName)
{
  Contract.Requires<ArgumentNullException>(
           id != null, "id");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(id), "id");
  Contract.Requires<ArgumentNullException>(
           companyName != null, "companyName");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(companyName), "companyName");
  Contract.Requires<ArgumentNullException>(
           contactName != null, "contactName");               
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(contactName), "contactName");
 
  var c = new Customer
              {
                Id = id,
                Name = companyName,
                  Orders = new List<Order>(),
                ContactInfo = new ContactInfo
                              {
                                 ContactName = contactName
                              }
              };
  return c;
}

Eine Factorymethode ist unteilbar, ruft Eingabeparameter ab, erledigt ihre Arbeit und gibt eine neue Instanz eines bestimmten Typs zurück. Die zurückgegebene Instanz müsste normalerweise immer einen gültigen Status haben. Die Factory ist dafür verantwortlich, dass alle definierten, internen Validierungsregeln erfüllt werden.

Außerdem muss die Factory Eingabedaten überprüfen. Die Verwendung von Vorbedingungen aus Codeverträgen zu diesem Zweck sorgt dafür, dass der Code rein und lesbar bleibt. Sie können auch mit Hilfe von Nachbedingungen sicherstellen, dass die zurückgegebene Instanz einen gültigen Status hat. So geht's:

Contract.Ensures(Contract.Result<Customer>().IsValid());

In Bezug auf die Verwendung von Invarianten in der gesamten Klasse hat uns die Erfahrung gelehrt, dass sie sich nicht immer auszahlen. Invarianten können zu invasiv sein, besonders in großen, komplexen Modellen. Codevertragsinvarianten sind gewissermaßen zu sehr an den Regelsatz gebunden, und manchmal braucht man mehr Flexibilität im Code. Daher macht es mehr Sinn, die Bereiche zu beschränken, in denen Invarianten durchgesetzt werden müssen.

Validierung

Eigenschaften für eine Domänenklasse müssen aller Wahrscheinlichkeit nach überprüft werden, damit sichergestellt wird, dass keine erforderlichen Felder leer sind, Text in Containern mit Eingabebeschränkungen nicht zu lang ist, Werte in die entsprechenden Wertebereiche fallen usw. Dann wären da noch die eigenschaftsübergreifenden Validierungen und komplexen Geschäftsregeln. Wie sähe Ihr Validierungscode aus? 

Bei der Validierung geht es um bedingten Code, also müssen letztendlich einige IF-Anweisungen zusammengestellt und boolesche Werte zurückgegeben werden. Eine Validierungsebene mit reinem Code und ohne Framework oder Technologie zu erstellen, kann funktionieren, ist aber keine gute Idee. Der resultierende Code wäre nicht sonderlich lesbar und würde sich nicht gut weiterentwickeln lassen (obwohl dies dank einiger Fluent-Bibliotheken mittlerweile einfacher wäre). Die Validierung kann angesichts bestimmter Geschäftsregeln äußerst instabil sein, dies muss in Ihrer Implementierung berücksichtigt werden. Fazit ist, dass Sie nicht einfach Code zur Validierung schreiben können. Sie müssen Code erstellen, der in der Lage ist, dieselben Daten auf verschiedene Regeln zu überprüfen.

Bei Validierungen soll manchmal lautstark darauf aufmerksam gemacht werden, dass ungültige Daten weitergegeben werden. Und manchmal sollen einfach nur Fehler gesammelt und anderen Codeebenen gemeldet werden. Denken Sie daran, dass Codeverträge keine Validierungen durchführen. Sie prüfen Bedingungen und lösen eine Ausnahme aus, wenn eine Bedingung nicht erfüllt wird. Die Verwendung eines zentralen Fehlerhandlers ermöglicht die Wiederherstellung nach Ausnahmefehlern und die ordnungsgemäße Herunterstufung. Im Allgemeinen empfehle ich die Verwendung von Codeverträgen in einer Domänenentität nur, um potenziell schwerwiegende Fehler zu erkennen, die zu inkonsistenten Zuständen führen können. Die Verwendung von Codeverträgen in einer Factory macht Sinn, denn wenn übergebene Daten ungültig sind, muss in diesem Fall der Code eine Ausnahme auslösen. Ob Sie Codeverträge in den Setter-Methoden von Eigenschaften verwenden, ist ganz Ihnen überlassen. Ich bevorzuge einen zurückhaltenderen Ansatz und führe die Validierung mit Attributen durch. Aber mit welchen Attributen?

Datenanmerkungen im Vergleich zu VAB

Der DataAnnotations-Namespace und Enterprise Library VAB sind sich sehr ähnlich. Beide Frameworks basieren auf Attributen und sind mit Hilfe von benutzerdefinierten Klassen, die benutzerdefinierte Regeln darstellen, erweiterbar. In beiden Fällen können Sie eine eigenschaftsübergreifende Validierung definieren. Außerdem besitzen beide Frameworks eine Validierungs-API, die eine Instanz bewertet und die Liste mit Fehlern zurückgibt. Worin besteht der Unterschied?

Datenanmerkungen sind Teil von Microsoft .NET Framework und müssen nicht separat heruntergeladen werden. Enterprise Library hingegen erfordert einen separaten Download. Kein Riesenproblem für ein großes Projekt, aber es kann aufwändig sein, wenn zum Beispiel in einem Unternehmen entsprechende Genehmigungen eingeholt werden müssen. Enterprise Library kann einfach über NuGet installiert werden (siehe Artikel „Verwalten von Projektbibliotheken mit NuGet“ in dieser Ausgabe).

Enterprise Library VAB ist den Datenanmerkungen in einer Hinsicht überlegen: Es kann über XML-Regelsätze konfiguriert werden. Ein XML-Regelsatz ist ein Eintrag in der Konfigurationsdatei, in dem Sie die gewünschte Validierung beschreiben. Sie können also deklarative Änderungen vornehmen, ohne den Code anzufassen. Abbildung 4 zeigt einen Beispielregelsatz.

Abbildung 4 – Enterprise Library-Regelsätze

<validation>
   <type assemblyName="..." name="ValidModel1.Domain.Customer">
     <ruleset name="IsValidForRegistration">
       <properties>
         <property name="CompanyName">
           <validator negated="false"
                      messageTemplate="The company name cannot be null" 
                      type="NotNullValidator" />
           <validator lowerBound="6" lowerBoundType="Ignore"
                      upperBound="40" upperBoundType="Inclusive" 
                      negated="false"
                      messageTemplate="Company name cannot be longer ..."
                      type="StringLengthValidator" />
         </property>
         <property name="Id">
           <validator negated="false"
                      messageTemplate="The customer ID cannot be null"
                      type="NotNullValidator" />
         </property>
         <property name="PhoneNumber">
           <validator negated="false"
                      type="NotNullValidator" />
           <validator lowerBound="0" lowerBoundType="Ignore"
                      upperBound="24" upperBoundType="Inclusive"
                      negated="false"
                      type="StringLengthValidator" />
         </property>
         <property name="FaxNumber">
           <validator negated="false"
                      type="NotNullValidator" />
           <validator lowerBound="0" lowerBoundType="Ignore"
                      upperBound="24" upperBoundType="Inclusive"
                      negated="false"
                      type="StringLengthValidator" />
         </property>
       </properties>
     </ruleset>
   </type>
 </validation>

In einem Regelsatz sind die Attribute aufgeführt, die auf eine bestimmte Eigenschaft oder einen bestimmten Typ angewendet werden sollen. Im Code sieht eine Regelsatzvalidierung folgendermaßen aus:

public virtual ValidationResults ValidateForRegistration()
{
  var validator = ValidationFactory
          .CreateValidator<Customer>("IsValidForRegistration");
  var results = validator.Validate(this);
  return results;
}

Die Methode wendet die im IsValidForRegistration-Regelsatz aufgelisteten Validierungssteuerelemente auf die angegebene Instanz an.

Ein letzter Hinweis zu Validierungen und Bibliotheken. Ich habe nicht jede populäre Validierungsbibliothek besprochen, aber das macht keinen wirklichen Unterschied. Der entscheidende Faktor ist die Frage, ob sich Ihre Geschäftsregeln häufig ändern und wenn ja, wie häufig. Auf dieser Basis können Sie bestimmen, ob Datenanmerkungen, VAB, Codeverträge oder andere Bibliotheken die beste Lösung sind. Wenn Sie genau wissen, was Sie erreichen möchten, fällt meiner Erfahrung nach die Wahl der „richtigen“ Validierungsbibliothek nicht schwer.

Zusammenfassung

Ein Objektmodell für eine realistische Geschäftsdomäne kann normalerweise keine einfache Sammlung von Eigenschaften und Klassen sein. Und Entwurfsüberlegungen haben Vorrang vor der Technologie. Ein gut aufgebautes Objektmodell gibt jeden erforderlichen Aspekt der Domäne wieder. Meistens bedeutet dies, dass die Klassen einfach zu initialisieren und zu überprüfen sind und eine große Eigenschafts- und Logikvielfalt aufweisen. DDD-Verfahren sollten nicht dogmatisch betrachtet werden, sondern sind als Wegweiser zu verstehen.     

Dino Esposito ist der Verfasser von „Programming Microsoft ASP.NET 4“ (Microsoft Press, 2011) und „Programming Microsoft ASP.NET MVC“ (Microsoft Press, 2011)sowie Mitautor von „Microsoft .NET: Architecting Applications for the Enterprise“ (Microsoft Press 2008). Esposito lebt in Italien und ist ein weltweit gefragter Referent für Branchenveranstaltungen. Sie können ihm auf Twitter unter twitter.com/despos folgen.

Unser Dank gilt den folgenden technischen Experten für das Lektorat dieses Artikels: *Manuel Fahndrich und *Andrea Saltarello