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:
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.
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
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.
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);
}
}
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.
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
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.
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?
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:
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 DBCoordinator
s 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 wurdeendWork
- 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.
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
Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.
zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)
Source Fellows GmbH
Lerchenstraße 31
72762 Reutlingen
Telefon: (0049) 07121 6969 802
E-Mail: info@source-fellows.com