Repositorys - wie Aggregate persistiert werden

Ein Repository ist dafür verantwortlich, Aggregate zu persistieren bzw. diese aus der Persistenz zu lesen. Jedes Aggregate hat genau eine Repository-Implementierung und jedes Repository ist für genau ein Aggregat zuständig. Ein Repository händigt das Aggregate Root mit den darunterliegenden Objekten aus bzw. nimmt diese entgegen. Über das Repository wird wie oben beschrieben die Konsistenz des Aggregats sichergestellt. Das ist passend zum bereits beschriebenen Validierungskonzept, um die Aggregat-Konsistenz hinsichtlich der Persistenz zu prüfen (mittels  ISupportsValidation.ValidateRegardingPersistence()).

Jedes Repository erhält im Namen als Präfix den Namen der Aggregate Root und den Postfix Repository. Es gilt demnach folgende Namenskonvention:

<Name des Aggregats>Repository

Für das Aggregat Ausleihe heißt das Repository entsprechend AusleiheRepository. 

Repositorys sind in einer weiter außen liegende Schale angesiedelt, der Infrastructure. Hierüber werden generell externe Ressourcen angebunden. Dies unterstreicht, dass nicht die Datenbank, sondern das Domain Model Zentrum der Implementierung ist!

Die Schale Infrastructure dient der Anbindung externer Quellen.

Die nachfolgende Abbildung zeigt die Abbildung auf Projekte

Die Implementierung eines Repositorys in Schleupen.CS 3.0 ist einfach, wie das folgende Beispiel zeigt.

  public sealed class AusleiheRepository : Repository<Ausleihe, AusleiheId>, IAusleiheRepository
  {
    public AusleiheRepository(INHibernateSessionFactory nhibernateSessionFactory, IAusleiheRepositoryConfigurator configurator)
      : base(nhibernateSessionFactory, configurator)
    {
    }

    public async Task<IAusleihen> QueryByIdAsync(IEnumerable<AusleiheId> ausleiheIds)
    {
                  ...
    }
    ...
  }

Die Implementierung der Abfragemethoden ist abhängig von der gewählten bzw. durch den OR-Mapper unterstützten Abfragesprache. Im Allgemeinen ist es sinnvoll in C# Linq zu verwenden, da dies weniger technologie-spezifisch als beispielsweise HQL (eine der Abfragesprachen von NHibernate) ist. Unserer Erfahrung nach ist die Linq-Unterstützung durch NHibernate nicht ausreichend gegeben, so dass in vielen Abfragen HQL als flexibelste Abfragesprache in Schleupen.CS zur Anwendung kommt.

Abfragen sollten im Repository implementiert werden, denn 

  1. andernfalls würde dies zu einer hohen Kopplung des Usecase Controllers zum OR-Mapper führen
  2. die Kapselung des Repositorys würde verletzt
  3. der nutzende Usecase Controller würde schwerer testbar.

NHibernate

Unter der Oberfläche eines Repositorys wird in Schleupen.CS der objektrelationale Mapper NHibernate verwendet, der Objektstrukturen in relationale Strukturen abbildet. Hier kommen einige Schleupen.CS-Erweiterungen zum Einsatz, von den denen im Folgenden einige exemplarisch erläutert werden.

Übersetzung von Objektstrukturen zu relationalen Strukturen

Das Mapping wird NHibernate-typisch mit FluentNHibernate in sogenannten ClassMaps beschrieben.

public class AusleiheMap : ClassMap<Ausleihe>
{
  public AusleiheMap()
  {
    Schema(DatabaseInfo.DatabaseSchema);
    Id(x => ((IAusleiheData)x).Id);

    Map(x => ((IAusleiheData)x).AusgeliehenAm).Not.Nullable();
    Map(x => ((IAusleiheData)x).RueckgabeAm).Column("ausgeliehenBis");

    Map(x => ((IAusleiheData)x).Buch).Column("Buch_Id").Not.Nullable();
    Map(x => ((IAusleiheData)x).Benutzerkonto).Column("Benutzerkonto_Id").Not.Nullable();
    Map(x => ((IAusleiheData)x).Ausleihfrist);

    HasMany(x => ((IAusleiheData)x).Vermerke)
        .Not.KeyNullable()
        .Not.KeyUpdate()
        .Cascade.AllDeleteOrphan();
  }
}

Bei der Definition von Mappings verwenden wir in Schleupen.CS zahlreiche Konventionen. So ist beispielsweise der Tabellenname gleich dem Namen der Entität (Beispiel: Ausleihe). Analog ist per Default ein Property-Name der Name einer Tabellenspalte (Beispiel: AusgeliehenAm).

Diese ClassMaps werden dem Repository mithilfe eines sogenannten NHibernateConfigurators bekannt gemacht:

public class AusleiheRepositoryConfigurator : NHibernateConfigurator, IAusleiheRepositoryConfigurator
{
  ...
  protected override IEnumerable<Type> MappingTypes
  {
    get
    {
      yield return typeof(AusleiheMap);
      ...
    }
  }
}

Locking als Lösung für den konkurrierenden Zugriff auf Aggregate

Da Aggregate beispielsweise von unterschiedlichen Nutzern editiert werden, ist es wichtig, diese Konkurrenzsituation sinnvoll aufzulösen. So ist es meist nicht sinnvoll, dass die letzte Speicherung gewinnt, da zwischenzeitlich Änderungen durch einen anderen Nutzer persistiert sein können. Hierzu gibt es unter anderem die Lösungsstrategien des optimistischen und pessismistischen Sperrens. Optimistische Sperren werden in Schleupen.CS insbesondere im UI-Kontext verwendet und pessimistische Sperren primär in der Hintergrundverarbeitung.

Pessimistische Sperren arbeiten blockierend für den Aufrufer - hier gedanklich der Service-Client. Wenn man eine pessimistische Sperre verwendet, sollte diese mit einem Timeout versehen werden, da prinzipiell sonst unendlich geblockt wird (vgl. Resilienz). Aber auch mit einem Timeout vergrößert sich das Risiko für den Service-Nutzer in ein eigenes Timeout zu laufen. Daher sind Server-seitige optimistische Sperren mit Client-seitigen Retrys und damit notwendigerweise Server-seitig zu implementierender Idempotenz meist eine bessere resiliente Implementierung.

Für optimistische Sperren von Aggregaten wird in Schleupen.CS eine Erweiterung des Standardmusters implementiert, bei dem jede Entität des Aggregats separat optimistisch gesperrt wird: Bei dem sogenannten Coarse Grained Lock wird die optimistische Sperre nur am Aggregate Root gesetzt und das gesamte Aggregat gesperrt - auch wenn eine Änderung am Aggregat-Kind erfolgt ist.

Durch dieses Muster wird insbesondere berücksichtigt, dass Datenverträge im Allgemeinen nicht 1:1 zu den Domänenobjekten gemappt werden können. Insbesondere das Mapping von Versionsnummern ist nicht trivial wenn jedes Objekt separat per Versionsnummer gelockt wird.

Die Strategie des optimistischen Lockings bei einer Änderung ist wie folgt: Wird ein Objekt geändert, so erfolgt das Propagieren von Änderungen an das jeweils nächste Elternteil-Objekt bis hin zum Aggregate Root. Diese Navigation erfolgt durch eine Schleupen-Erweiterung von NHibernate. Damit die Navigation erfolgen kann, besitzt jeder Typ unterhalb des Aggregate Roots eine ParentId um das Elternteil-Objekt zu finden. Für das Aggregate Root wird dann der Standard für optimistische Locking von NHibnerate mittels Id verwendet.  

Navigation von Kind zu Aggregate Root

Im Domänenmodell wird das Aggregate Root-Objekt um eine Versionseigenschaft ergänzt und das NHibernate-Mapping entsprechend angepasst. Wird eine Änderung am Aggregate Root-Objekt oder an einem der Kind-Objekte durchgeführt, so wird also schlussendlich die Version am Aggregate Root-Objekt erhöht.

public partial class Buch : AggregateRoot<BuchId>, IAggregateRoot, ...
{
  private BuchId buchId;
  private long version;
  private Inhaltsangabe inhaltsangabe;
  ...
}

Zur Navigation wird die Id des jeweiligen Elternteils aus der Persistenz mitgelesen und gespeichert. Durch Implementierung der Schnittstelle IAggregateChild<TParent> wird ParentId dem Framework zur Verfügung gestellt.

public partial class Inhaltsangabe : Entity<InhaltsangabeId>, IAggregateChild<Buch>, ...
{
  private ISet seiten;
  private BuchId parentId;

  public Inhaltsangabe(InhaltsangabeId id, BuchId parentId, IEnumerable seiten)
    : base(id)
  {
     ...
     this.parentId = parentId ?? throw new ArgumentNullException(nameof(parentId));
  }

  object IAggregateChild<Buch>.ParentId
  {
    get => parentId;
    set => parentId = (BuchId)value ?? throw new ArgumentNullException(nameof(parentId));
  }
  ...
}

Das Aktivieren des optimistic-Lockings erfolgt im Repository durch eine einfache Konfiguration im NHibernateConfigurator:

public class BuchRepositoryConfigurator : NHibernateConfigurator, IBuchRepositoryConfigurator
{
   public BuchRepositoryConfigurator ()
   {
      LockingBehavior = LockingBehavior.CoarseGrainedLock;
   }
   ...
}

Für eine möglichst performante Navigation ist es hilfreich, die jeweilige ParentId zu persistieren, da das dynamische Abfragen per Formula zu SELECT N+1-Problemen führen kann!

Changelog - Umsetzung einer Compliance-Anforderung

Der Anschluss des Datenänderungsprotokolls erfolgt mithilfe sogenannter NHibernate-Listener. Auch hier wird das Aggregat-Konzept konsequent fortgeführt: Mit dem Datenänderungsprotokoll können Änderungen an Aggregaten protokolliert werden. Es muss für jedes zu protokollierende Aggregat ein oder mehrere sogenannte ChangeLogEventBuilder erstellt werden, die die entsprechenden Datenänderungseinträge in Form von ChangeLogAggregateEvents und ChangeLogEntityEvents erstellen. Das folgende Snippet zeigt exemplarisch eine Implementierung:

public sealed class BuchChangeLogAggregateEventBuilder : ChangeLogAggregateEventBuilder<Buch>
{
  protected override ChangeLogAggregateEvent WhenAggregateChanged(Buch rootEntity, OperationType rootEntityOperationType)
  {
    if (rootEntity == null) { throw new ArgumentNullException(nameof(rootEntity)); }

    ISessionTokenProvider sessionTokenProvider = IocContainer.Resolve<ISessionTokenProvider>();
    string sessionToken = sessionTokenProvider.Token;
    return new ChangeLogAggregateEvent(
            new BusinessEntity("BuchId", rootEntity.Id.Value, ((IBuchData)rootEntity).Titel), "Änderung am Geschäftsobjekt BuchId.")
    {
      SessionToken = sessionToken
    };
  }
  ...
}

Zum Anschluss des Datenänderungsprotokolls muss das Repository des entsprechenden Aggregats im NHibernateConfigurator konfiguriert werden.

public class BuchRepositoryConfigurator : NHibernateConfigurator, IBuchRepositoryConfigurator
{
  private readonly IBuchChangeLogConfigurator buchChangeLogConfigurator;
  ...
  public BuchRepositoryConfigurator(IBuchChangeLogConfigurator buchChangeLogConfigurator, ...)
  {
    this.buchChangeLogConfigurator = buchChangeLogConfigurator ?? throw new ArgumentNullException(nameof(buchChangeLogConfigurator));
    ...
  }
  ...
  protected override IEnumerable<Type> ConventionTypes
  {
    get
    {
      List<Type> conventionTypes = new();
      conventionTypes.AddRange(base.ConventionTypes);
      conventionTypes.AddRange(buchChangeLogConfigurator.ConventionTypes
      ...
      return conventionTypes;
    }
  }
  protected override IEnumerable<INHibernateExtender> Extenders
  {
    get
    {
      List<INHibernateExtender> nHibernateExtenders = new();
      nHibernateExtenders.AddRange(base.Extenders);
      nHibernateExtenders.AddRange(buchChangeLogConfigurator.Extenders);
      ...
      return nHibernateExtenders;
    }
  }
}

Das folgende Sequenzdiagramm zeigt den grundlegenden Ablauf zur Laufzeit:

Ablauf der Verarbeitung der Datenänderungen durch das Changelog.
Cookie Consent mit Real Cookie Banner