Go Repository und Transaktionen

Go Repository und Transaktionen

Im Internet finden sich zahlreiche Beispiele und Anleitungen zur Implementierung des Repository-Patters in Go/Golang. Die meisten folgen dabei einem ähnlichen Ansatz:

  • Repository-Interface: Es wird ein Interface definiert, das die grundlegenden Operationen für den Zugriff auf die Daten abstrahiert (z.B. CRUD - Create, Read, Update und Delete).

  • Konkrete Implementierung: Für jede konkrete Datenquelle (z.B. eine PostgreSQL-Datenbank oder ein In-Memory-Speicher) wird eine Implementierung des Repository-Interfaces erstellt.

  • Datenbankverbindung: Die konkrete Implementierung des Repository-Interface enthält eine Referenz zur Datenbank bzw. zur Datenbankanbindungm (wie z.B. eine GORM Anbindung), die dann innerhalb der einzelnen Methoden verwendet wird, um die tatsächlichen Datenbankoperationen auszuführen.

  • Clients verwendet Repository-Interface: Clients, wie z. B. ein Service, nutzen das Repository-Interface um auf die Daten zuzugreifen.

Folgendes UML-Diagramm zeigt eine mögliche Aufteilung der einzelnen Komponenten in Go-Packages:

Repository Pattern in Go

Repository Pattern in Go

Eine entsprechende Implementierung des Repository-Interfaces könnte in Go ungefähr so aussehen:

type Repository struct {
	db *gorm.DB
}
func (r *Repository) Create(ctx context.Context, seminar Seminar) error{
	return r.db.WithContext(ctx).Create(seminar).Error
}
...

Vielleicht könnte noch etwas mehr Logging hinzu kommen, aber im Grunde ist dieser Code, wenn GORM korrekt initialisiert wurde, für die Anlage eines neuen Datensatzes in der Datenbank ausreichend.

Mit einer solchen Umsetzung eines Repository können bereits viele Anwendungsfälle erfolgreich umgesetzt werden. Allerdings wird in diesem Beispiel jede Methode innerhalb einer separaten Datenbank-Transaktion ausgeführt. Das heißt, dass jeder Aufruf der Repository-Implementierung als separate, unabhängige Aktion in der Datenbank betrachtet wird.

Bei der Ausführung mehrerer Repository-Methoden durch eine Service-Implementierung besteht das Problem, dass im Fehlerfall keine automatische Rückgängigmachung (Rollback) der vorherigen Aktionen erfolgt. Das kann zu ernstzunehmenden Problemen führen.

Transaktionen und Konsistenz

Einmal etwas ausgeholt: Ganz grundsätzlich sind Transaktionen eine Abfolge von Operationen, die entweder vollständig oder gar nicht ausgeführt werden, um eine Datenkonsistenz zu gewährleisten. Wobei Konsistenz einfach gesagt bedeutet, dass die Daten korrekt sind und keine Widersprüche enthalten. Änderungen an den Daten passieren geordnet und nachvollziehbar, sodass alle immer die gleiche, valide Version sehen.

Ich muss (leider) zugeben, dass ich bei meinen ersten Tests mit dem Spring-Framework vor ca. 20 Jahren auch etwas verwundert war und eine Lektion lernen musste: Da wurde mir versprochen, dass ich mit einer einfachen Annotation im Java-Code eine Transaktion um meine Methode legen kann.

“Supports Spring declarative transaction management” - Spring 1.0 Doku

Spring Doku 1.0

Spring Doku 1.0

Den genauen Code habe ich nicht mehr, aber es war wohl sowas wie das hier:

@Transaction
public void doSth(Article article) {
	article.setColor("blue");
	throw new RuntimeException("PENG");
}

Na ja, hat irgendwie nicht funktioniert…. Ich war mir sicher, dass alles richtig konfiguriert war! Der Zustand meines Objektes wurde aber bei dem erzwungenen und erhofftem Rollback nicht zurückgesetzt! Was war los?

Hätte ich mal die Doku vorher zu Ende gelesen oder einfach mehr über Transaktionen gewusst… Was ich auf jeden Fall gelernt habe ist, dass es gloable und lokale Transaktionen gibt, die immer von einer Ressource abgebildet bzw. unterstützt werden müssen. Klassischerweise ist so eine Ressource z. B. eine Datenbank; aber eben kein Plain-Old-Java-Object (POJO)! Das funktioniert einfach nicht.

Wird im Kontext einer laufenden Transaktion nur eine Ressource angesprochen, kann das Transaktionsmanagement von dieser Ressource selbst als sogenannte lokale Transaktion durchgeführt werden. Werden allerdings mehrere Ressourcen während einer laufenden Transaktion eingesetzt und die entsprechenden Zugriffe sollen transaktional geschützt werden, wird ein externer, sogenannter Transaktionsmanager nötig. Dieser bringt die einzelnen lokalen Transaktionen zu einer Gesamt-Transaktion, einer globalen oder verteilten Transaktion, zusammen. Bei Java kann das dann ein sogenannter JTA-TransactionManager (Java-Transaction-API) sein.

TX-Manager passt auf DB auf

TX-Manager passt auf DB auf

Als ich mein Java-Beispiel um den Zugriff auf eine Datenbank erweitert und den Zustand in der Datenbank gespeichert hatte, haben plötzlich auch meine Transaktionstests, wie z. B. das Zurückrollen von Änderungen, funktioniert.

Lessons learned!

  • Transaktionen benötigen Ressourcen

  • Lokale Transaktionen werden beim Zugriff auf eine Ressource eingesetzt

  • Globale Transaktionen fassen mehrere lokale Transaktionen zusammen und benötigen einen externen Transaktionsmanager

Nachdem ich das Prinzip verstanden hatte, konnte ich auch für meine Repository-Implementierungen in Java/Spring eine (lokale) Transaktion um mehrere Repository Aufrufe legen und von den ganzen Vorteilen, wie dem automatischen Zurückrollen von Änderungen im Fehlerfall, profitieren. Die lokale Transaktion in der Datenbank wurde automatisch durch das Spring-Framework verwaltet.

In diesem Fall reichte eine lokale Transaktion aus, da nur mit einer Datenbank bzw. einem Repository gearbeitet wurde:

public class Service {
	...
	@Transactional
	public void myBusinessProcess(Seminar seminar) {
		Seminar createdSeminar = repository.create(seminar);
		var val = calculate(...);
		createdSeminar.setVal(val);
		repository.update(createdSeminar);
	}
}

Go-Repository und Transaktionen

Zurück zum obigen Go-Repository Beispiel und mit dem Wissen aus dem Spring Beispiel betrachtet:

Für jeden Methodenaufruf wird von der GORM-Bibliothek eine neue lokale Transaktion geöffnet und anschließend wieder geschlossen. Für die gezeigte Create-Funktion ist das kein Problem, da nur ein Aufruf in der Repository-Implementierung vorhanden ist. Würde die Methode allerdings mehrere Zugriffe auf die Datenbank umsetzen, würde das bereits zu Konsistenzproblemen führen, da die einzelnen Zugriffe bereits in separaten Transaktionen ausgeführt werden würden.

In der GORM-Doku findet sich ein Kapitel zu Transaktionen mit Anleitungen, wie mehrere Aufrufe innerhalb einer Transaktion ausgeführt werden können.

Das folgende Beispiel stammt aus der Doku:

db.Transaction(func(tx *gorm.DB) error {
  // do some database operations in the transaction (use 'tx' from this point, not 'db')
  if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil {
    // return any error will rollback
    return err
  }

  if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil {
    return err
  }

  // return nil will commit the whole transaction
  return nil
})

Dieser Ansatz löst das Problem, wenn sich mehrere Datenbankzugriffe innerhalb einer Repository-Implementierung befinden. Sobald allerdings die Aufrufe innerhalb einer z. B. Service-Implementierung zusammengefasst werden, heißt, dass jede Repository-Methode weiterhin als separate Transaktion durchgeführt wird und es auch keine Möglichkeit gibt mehrere Aufrufe zu einer gemeinsamen Transaktion zusammen zu fassen.

Sequenzdiagramm TX

Sequenzdiagramm TX

Auch hier findet man einige Vorschläge wie man so etwas umsetzen kann bzw. den Hinweis, dass das gar nicht nötig ist. Mich erschrecken einige vorgeschlagene Ansätze durch ihre Komplexität und durch die Verschachtelung mehrerer Funktionsaufrufe. Das Spring-Beispiel von oben zeigt eigentlich schön, dass es auch einfacher gehen muss. Eine generelle Notwendigkeit möchte ich auch nicht in Frage stellen.

Eine Modifizierung der Implementierung des Repository-Patterns in Go kann dementsprechend notwendig sein, jedoch unter Beibehaltung folgender Ziele:

  • Einfacher Einsatz mit wenig Komplexität

  • Business-Logik soll weiterhin frei von Technik sein

  • Methodenaufrufe des Repositories sollen transaktional gesichert sein

Datenbank-Transaktionen in der Business Schicht?

Ein Ziel besteht darin die Business-Logik frei von Technik zu halten. Also keine technischen Abhängigkeiten in diese Schicht mit aufzunehmen. Ist jetzt eine Transaktion eine technische Abhängigkeit?

A transaction is an abstract unit of work processed by the system. This is not the same as a database transaction. A single unit of work might encompass many database transactions. (Michael T. Nygard - Release It!)

Die Geschäftslogik muss klar definieren, wie das System auf fehlgeschlagene Aktionen reagiert und welche Aktionen als unteilbare Einheiten (Units of Work) behandelt werden müssen. Die Definition einer Unit of Work in der Business-Schicht ist zunächst unabhängig von technischen Implementierungen. Erst die konkrete Umsetzung als Datenbanktransaktion führt zu einer technischen Abhängigkeit.

Eine Unit of Work beschreibt ein logisches Konzept, während die Datenbanktransaktion eine spezifische technische Realisierung darstellt.

Folgendes Beispiel zeigt Geschäftslogik, die eine technischen Abhängigkeit besitzt:

import "database/sql"
...
func (s *StoreServiceImpl) OrderItem(ctx context.Context, itemId int) error {
	// Direkte Abhängigkeit zu sql-Package! Schlecht!
	var tx  sql.Tx = db.BeginDatabaseTx(ctx)
	// Weitergabe der SQL-Transaktion in der Schnittstelle!
	err := s.repo.UpdateAvailability(ctx, tx)
	if err != nil {
		return err
	}
}
...

In diesem Code-Schnipsel besteht eine direkte Abhängigkeit der Geschäftslogik vom database/sql-Paket, also einem technischen Paket. Diese enge Kopplung erschwert den Einsatz alternativer Datenhaltungstechnologien, z.B. NoSQL-Datenbanken oder In-Memory-Datenbanken, und behindert die Testbarkeit der Geschäftslogik. Für Unit-Tests der Geschäftslogik müsste nun eine Datenbankverbindung aufgebaut und eine Transaktion gestartet werden, was die Tests langsamer, komplexer oder im schlimmsten Fall unmöglich macht.

Zudem erschwert diese Kopplung die Wartbarkeit und Erweiterbarkeit des Codes, da Änderungen an der Datenhaltungsschicht potentiell Auswirkungen auf die Geschäftslogik haben können.

Ein solcher Ansatz kann die Lesbarkeit, Wartbarkeit und die Architektur des Codes negativ beeinflussen und widerspricht somit den Prinzipien des Clean Code und der Clean Architecture.

Clean Code ist ein Programmierstil, der Wert auf Lesbarkeit, Verständlichkeit und Wartbarkeit legt. Ziel ist es, Code zu schreiben, der nicht nur funktioniert, sondern auch von anderen Entwicklern (und einem selbst in der Zukunft) leicht verstanden und angepasst werden kann.

Transaktion als Unit-Of-Work

Wenn wir den Ansatz einer Unit of Work konsequent weiterdenken und die Transaktion in eine solche abstrahieren, eröffnen sich für den Einsatz in einem Service interessante Möglichkeiten:

  • Flexibilität bei der Datenhaltung - Durch die Abstraktion der Transaktion in eine Unit of Work wird die Geschäftslogik unabhängig von der konkreten Implementierung der Datenhaltung. Der Service kann nun mit verschiedenen Persistenzmechanismen arbeiten, ohne dass die Geschäftslogik angepasst werden muss.

  • Verbesserte Testbarkeit - Die Abstraktion der Transaktion erleichtert das Testen der Geschäftslogik. In Unit-Tests kann die Unit of Work durch ein Mock-Objekt ersetzt werden, das die Interaktion mit der Datenhaltung simuliert. Dadurch können die Tests isoliert von der Datenbank und ihren Seiteneffekten durchgeführt werden, was sie schneller und zuverlässiger macht.

  • Unterstützung verschiedener Transaktionsmodelle - Die Abstraktion der Transaktion ermöglicht die Unterstützung verschiedener Transaktionsmodelle, wie z. B. ACID-Transaktionen, Eventuelle Konsistenz oder Kompensationstransaktionen.

Es lohnt sich also, das genauer zu betrachten… und wie lässt sich das nun in Go umsetzen?

Unit of Work in Go Geschäftslogik

In folgendem Beispiel wurde die Verwaltung der Unit of Work innerhalb des Service in einer UnitOfWorkCoordinator abstrahiert und ausgelagert. Dieser stellt die Möglichkeiten zur Verfügung eine Unit of Work zu starten und zu beenden. Entweder erfolgreich oder fehlerhaft, was zu einem Abbruch führt.

Die Schnittstelle sieht dementsprechend so aus:

type UnitOfWorkCoordinator interface {
	BeginUnitOfWork(ctx context.Context) context.Context
	CommitUnitOfWork(ctx context.Context) error
	EnsureUnitOfWorkEnd(ctx context.Context) error
}

Die Arbeitsweise ist leicht erklärt: Beim Start wird der übergebene context.Context mit Informationen zur aktuellen Unit of Work erweitert und durch die Anwendung weiter propagiert. So kann die Information eines zusammenhängenden Prozesses Go-typisch in allen aufgerufenen Methoden genutzt werden.

Der Einsatz innerhalb des Service sieht dann wie in folgendem Codebeispiel aus:

func (s *StoreServiceImpl) OrderItem(ctx context.Context, itemId int) error {
	innerCtx := s.unitOfWorkCoordinator.BeginUnitOfWork(ctx)
	defer s.unitOfWorkCoordinator.EnsureUnitOfWorkEnd(innerCtx)

	err := s.repo.UpdateAvailability(innerCtx)
	if err != nil {
		return err
	}

	err = s.sender.SendApplicationEvent(innerCtx, internal.OrderEvent{})
	if err != nil {
		return err
	}

	return s.unitOfWorkCoordinator.CommitUnitOfWork(innerCtx)

}

Den relevanten Teil einer Beispielanwendung zeigt das zugehörige UML-Diagramm:

Go mit Coordinator

Go mit Coordinator

Im Falle einer Datenbankanbindung kann ein ensprechender DBCoordinator umgesetzt werden, der eine lokale Transaktion über mehrere Aufrufe ermöglicht. Er legt die entsprechende Information im context.Context ab und macht diese damit über mehrere Aufrufe hinweg nutzbar.

Der Sourcecode eines solches DBCoordinators sieht dann (in Auszügen) so aus:

type Database struct {
	db *gorm.DB
}
func (d *Database) Init() error {
	...
}

type DBCoordinator struct {
	database Database
}

func NewDBCoordinator(database Database) *DBCoordinator {
	return &DBCoordinator{database: database}
}

func (d *DBCoordinator) BeginUnitOfWork(ctx context.Context) context.Context {

	tx := d.database.db.Begin()
	innerCtx := context.WithValue(ctx, txKey, tx)

	return innerCtx
}
func (d *DBCoordinator) CommitUnitOfWork(ctx context.Context) error {

	var v any
	if v = ctx.Value(txKey); v == nil {
		return errors.New("transaction context is missing")
	}
	return v.(*gorm.DB).Commit().Error

}
...

Mit zusätzlichen Hilfsmethoden kann dieser Context innerhalb der Repository-Implementierung genutzt werden. In diesen Methoden wird dann entschieden ob eine neue Transkation geöffnet werden muss oder nicht.

  • beginWork - Stelle sicher, dass eine Transaktion geöffnet wurde
  • endWork - Beendet die Aufgabe und prüft ob ein Commit stattfinden muss, oder ob der umschließende Coordinator diese Aufgabe übernimmt.
  • ensureTxEnd - Stellt sicher, dass im Bedarfsfall am Ende die Transaktion abgeschlossen wird.

Eine Repository-Methode kann dann so umgesetzt werden:

func (s StoreRepository) UpdateAvailability(ctx context.Context) error {

	db := beginWork(ctx, s.database)
	defer ensureTxEnd(ctx, db)

	err := db.Create(&Article{Code: "1", Price: 1}).Error
	if err != nil {
		return fmt.Errorf("could not create article %w", err)
	}

	return endWork(ctx, db)
}

Die Implementierung der Hilfmethoden ist nicht aufwändig:

func beginWork(ctx context.Context, database Database) *gorm.DB {

	var v any
	if v = ctx.Value(txKey); v == nil {
		tx := database.db.Begin()
		return tx
	}
	return v.(*gorm.DB)

}

func endWork(ctx context.Context, db *gorm.DB) error {

	if v := ctx.Value(txKey); v == nil {
		commitError := db.Error
		if db.Error != nil {
			return errors.Join(commitError, db.Rollback().Error)
		}
		return db.Commit().Error
	}
	//coordinator will do the work
	return nil
}

func ensureTxEnd(ctx context.Context, db *gorm.DB) error {

	var v any
	if v = ctx.Value(txKey); v == nil {
		if committer, ok := db.Statement.ConnPool.(gorm.TxCommitter); ok {
			return committer.Rollback()
		}
		return db.Error
	}
	return nil

}

Auf diese Weise kann man die Aufrufe zu mehrerer Repository-Methoden aus einem Service heraus in einer lokalen Transaktion durchführen.

Flexibilität in der technologischen Anbindung

Jede einzelne technologische Anbindung kann mit einer maßgeschneiderten Implementierung realisiert werden. Das bedeutet, dass für jede Technologie, mit der eine Verbindung hergestellt werden soll, eine spezifische Lösung entwickelt werden kann.

Im vorliegenden Beispiel wurde eine Implementierung für GORM, eine beliebte ORM-Bibliothek für Go, erstellt. Dieser Ansatz ist jedoch nicht auf GORM beschränkt. Er lässt sich ebenso gut auf andere Technologien übertragen, sei es eine andere Datenbank-Technologie oder eine externe Anbindung über eine Bibliothek.

Die Möglichkeit, für jede technologische Anbindung eine eigene Implementierung zu erstellen, bietet ein hohes Maß an Flexibilität und ermöglicht es, optimale Lösungen für jede spezifische Anforderung zu entwickeln. Dies gilt insbesondere für globale Transaktionen, die sich über mehrere Technologien hinweg erstrecken. Mit dem gezeigten Ansatz können Transaktionen so gestaltet werden, dass sie die Konsistenz der Daten über alle beteiligten Systeme hinweg gewährleisten.

17.02.2025

 

Der Author auf LinkedIn: Kristian Köhler und Mastodon: @kkoehler@mastodontech.de

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com