Go, PostgresQL, GraphQL Logos auf einem Laptop Bildschirm

TL;DR

Wir haben mit den Erfahrungen aus vielen Projekten ein Boilerplate für neue Go API Backends aufgebaut. Dieses entwickeln wir stetig weiter und ergänzen es um neue Erkenntnisse und Ansätze aus den Projekten. Das Ziel ist eine konsistente und stabile Basis ohne die Verwendung eines großen Frameworks.

Das ganze Setup ist als Mono-Repo strukturiert und ist auf github.com/networkteam/go-apibackend-boilerplate verfügbar.

Als generelle Architektur verwenden wir einen leichtgewichtige CQRS-Ansatz, der uns eine klare Trennung von lesenden und schreibenden Operationen ermöglicht und perfekt mit Queries und Mutations in GraphQL harmoniert. Mehr Details und Beispiele dazu gibt es weiter unten.

Für die Persistenz verzichten wir auf einen ORM und setzen auf generierten Code (github.com/networkteam/construct) in Verbindung mit einem auf PostgreSQL optimierten Query-Builder (github.com/networkteam/qrb).

Für GraphQL setzen wir gqlgen ein, welches aus Schema-Dateien die passenden Typen und Resolver generiert und sich als Handler in net/http integrieren lässt.

Dependencies werden im main Package aufgebaut und übergeben - ganz ohne Container und magische Dependency Injection.

Alle Schichten sind vorbereitet für Tests mit dem Go testing Package. Unser Fokus liegt auf funktionalen Tests über die API - ergänzt durch Unit-Tests für Domain-Logik und anderer Bestandteile. Dafür verwenden wir PostgreSQL und nutzen temporäre Schemas, um die Tests parallel auszuführen.

Warum ein Boilerplate und kein Framework?

In vielen Projekten haben wir die Erfahrung gemacht, dass Frameworks zwar gewisse Dinge vereinfachen, aber langfristig das Projekt an die Weiterentwicklung des Frameworks binden und teilweise Änderungen schwierig machen, wenn diese an die Grenzen der Erweiterbarkeit eines Frameworks stoßen.

Ab einer gewissen Komplexität und Anforderungen an eine langfristige Weiterentwicklung eines Projekts müssen wir uns dann immer fragen:

Wie viel Zeitersparnis und Vorteil bringt ein Framework im Vergleich zur Abhängigkeit und evtl. vielen Dependencies? Wollen wir in einem Projekt an Upgrade-Pfade von Framework-Versionen gebunden sein (die wir im Voraus natürlich nicht kennen)?

In der Go Community ist es durchaus üblich Dependencies möglichst zu vermeiden und diese als Trade-Off zu betrachten. Wenn wir nun ein Framework als eine "große Dependency" betrachten, in welcher wir unseren Applikations-Code einbinden, dann wird schnell klar, dass wir uns hier in eine ziemlich große Abhängigkeit begeben.

Meine Erfahrungen mit der langfristigen Weiterentwicklung von Projekten hat mich auch dazu gebracht, dass ich es schätze, wenn nicht nur einfache Dinge schnell gehen, sondern auch komplizierte Features mit einem erwartbaren Aufwand und Komplexität umsetzbar sind.

Ein Framework ist hier ein Trade-Off, bei dem wir am Anfang schneller vorwärts kommen, aber vielleicht auch harmlose Features, wie "ein User kann mehrere Rollen in verschiedenen Organisation haben" zu einer Herausforderung werden, wenn das Security-Modell des Frameworks nicht darauf ausgelegt ist. Es ist nur eine Frage der Zeit, bis eine Anforderung kommt, bei der die Konzepte eines Frameworks und die Anforderungen des Projekts nicht mehr zusammenpassen und wir um das Framework herum entwickeln müssen bzw. die gewünschte Logik auf diverse Erweiterungspunkte verteilen müssen - was das Verständnis des Codes deutlich erschwert.

Complexity of effort vs. feature
In einem Framework sind einfache Dinge häufig leicht, aber schwierige können unerwartet hohe Komplexität beim Aufwand verursachen.

Für gewisse Szenarien ist ein Framework oder fertiges System, welches angepasst und erweitert wird, natürlich die bessere Wahl. Ein CMS oder Shop-System bringt häufig viele fertige Funktionen mit, die den Trade-Off eher Richtung Framework verschieben. Wie immer: Your Mileage May Vary - es gibt kein "richtig" oder "falsch", sondern wir müssen die Vor- und Nachteile abwägen.

Im Boilerplate haben wir also Code, den wir für jedes Projekt kopieren und anpassen können, ohne dass wir um Abstraktionen herum arbeiten müssen. Dabei verwenden wir eine konsistente Basis und passen diese an die Anforderungen des Projekts an. Diese Änderungen machen wir direkt an dem Code im Projekt. Dieser ist also einfach explizit das, was in dem jeweiligen Projekt erforderlich ist.

Was beinhaltet das Boilerplate?

Das Boilerplate ist als Mono-Repo aufgebaut und enthält den Code für ein API-Backend mit Go. Dabei sind folgende "Features" bereits eingebaut:

  • Leichtgewichtige CQRS-Architektur
  • Authentifizierung
  • Autorisierung
  • GraphQL API mit gqlgen
  • Geringe Abstraktion im Datenzugriff mit networkteam/construct und networkteam/qrb
  • Datenbank-Migrationen mit pressly/goose
  • Versand von E-Mails mit Templates
  • Vollständig testbar mit funktionalen Tests für API und Datenbank-Fixtures
  • HTTP-Server mit "graceful shutdown"
  • Instrumentierung mit OpenTelemetry für Metriken und Tracing

Auf alle Themen im Detail einzugehen würde etwas den Rahmen dieses Blog-Posts sprengen, aber ich möchte hier vor allem noch einmal die Architektur erklären und welche Vorteile diese bietet.

Mehr Informationen haben wir auch in der Dokumentation des Boilerplates zusammengefasst. Dieses ist auch jeweils Bestandteil des Mono-Repos und wird für das jeweilige Projekt angepasst und ergänzt.

CQRS Architektur

Die Abkürzung ist vielleicht nicht jedem geläufig und wir verwenden es etwas einfacher als es teilweise propagiert wird. Für uns bedeutet CQRS, dass wir die Operationen auf Daten in zwei Kategorien einteilen:

  1. Commands - Schreibende Operationen, die Daten verändern
  2. Queries - Lesende Operationen, die Daten abfragen

Diese Trennung ermöglicht es uns, die Logik für schreibende Operationen und lesende Operationen klar zu trennen. Dabei können auch völlig andere Models oder auch gar keine Models mit direktem SQL verwendet werden. Auch ein Read-Model für spezielle Queries ist einfach umsetzbar und muss nicht mit Entities in einem Domain Model übereinstimmen.

Dabei sind Commands und Queries ganz einfache Datenstrukturen (struct) mit ein paar Methoden zum Erstellen und Validieren. Zudem sind sie unabhängig von der Schnittstelle und können in einer CLI, GraphQL oder JSON API verwendet werden.

Wo werden die Commands und Queries verarbeitet?

  1. Handler ist ein Typ mit Methoden für jedes Command und führt diese aus. Dabei hat der Handler Dependencies für die Datenbank und andere Services (z.B. Mailversand, File-Storage, Job-Queue). Dabei wird immer nur ein Command ausgeführt - was auch eine transaktionale Boundary bildet. Eine Handler Methode gibt dabei nur einen error zurück und keine Daten. Dies ermöglicht bei Bedarf eine asynchrone Verarbeitung.
  2. Finder ist ein Typ mit Methoden für jede Query und führt diese aus. Auch hier sind Dependencies für Datenbank und andere Services vorhanden. Für den Datenzugriff werden Repository-Funktionen verwendet, die auf tieferer Ebene den Datenbankzugriff kapseln. Durch die Architektur können auch andere Datenquellen auf dem gleichen Weg integriert werden.

In beiden Fällen werden die Commands und Queries validiert und autorisiert. Sie bilden also den Übergang von Schnittstellen zur Domain-Logik und tieferen Schichten.

Beispiel für ein Command mit Handler

Wie sieht nun das Zusammenspiel von der API bis zur Datenbank aus? Hier ein Beispiel für ein Command, das einen neuen Account anlegt:

account_create_cmd.go
package command

// ... imports

type AccountCreateCmd struct {
	AccountID      uuid.UUID
	EmailAddress   string
	Role           types.Role
	OrganisationID uuid.NullUUID
	password       string
}

func NewAccountCreateCmd(emailAddress string, role types.Role, password string) (AccountCreateCmd, error) {
	accountID, err := uuid.NewV7()
	if err != nil {
		return AccountCreateCmd{}, errors.Wrap(err, "generating account id")
	}

	return AccountCreateCmd{
		AccountID:    accountID,
		EmailAddress: strings.ToLower(strings.TrimSpace(emailAddress)),
		Role:         role,
		password:     strings.TrimSpace(password),
	}, nil
}

func (c AccountCreateCmd) Validate() error {
	if isBlank(c.EmailAddress) {
		return types.FieldError{
			Field: "emailAddress",
			Code:  types.ErrorCodeRequired,
		}
	}
  // ...
  return nil
}

func (c AccountCreateCmd) NewAccount(config domain.Config) (model.Account, error) {
	accountSecret, err := model.NewAccountSecret()
	if err != nil {
		return model.Account{}, errors.Wrap(err, "generating account secret")
	}
	passwordHash, err := helper.GenerateHashFromPassword([]byte(c.password), config.HashCost)
	if err != nil {
		return model.Account{}, errors.Wrap(err, "hashing password")
	}
	account := model.Account{
		ID:             c.AccountID,
		EmailAddress:   c.EmailAddress,
		Secret:         accountSecret,
		PasswordHash:   passwordHash,
		Role:           c.Role,
		OrganisationID: c.OrganisationID,
	}
	return account, nil
}

Das ist auch schon eins der umfangreichsten Commands die wir haben, viele bestehen auch einfach aus dem struct-Typ und einer Validate-Methode. Die NewAccount-Methode ist in diesem Fall nützlich, um die Erstellung des Account-Models zu kapseln - das Passwort kann hier völlig intern verarbeitet werden. In jedem Fall generieren wir aber den Identifier bereits im Command - dafür eignen sich UUIDs sehr gut, da sie nicht auf Sequenzen oder Auto-Increment-Felder angewiesen sind.

Der Handler für dieses Command sieht dann so aus:

account_create_handler.go
package handler

// ... imports

func (h *Handler) AccountCreate(ctx context.Context, cmd command.AccountCreateCmd) error {
	slog.Debug("Handling account create command", "component", "handler", "cmd", cmd)

	if err := cmd.Validate(h.config); err != nil {
		return err
	}

	authCtx := authentication.GetAuthContext(ctx)
	if err := authorization.NewAuthorizer(authCtx).AllowsAccountCreateCmd(cmd); err != nil {
		return err
	}

	err := repository.Transactional(ctx, h.db, func(tx *sql.Tx) error {
		account, err := cmd.NewAccount(h.config)
		if err != nil {
			return err
		}
		err = repository.InsertAccount(ctx, tx, repository.AccountToChangeSet(account))
		if err != nil {
			if constraintErr := repository.AccountConstraintErr(err); constraintErr != nil {
				return constraintErr
			}
			return errors.Wrap(err, "inserting account")
		}

		return nil
	})
	if err != nil {
		return err
	}

	slog.Info("Created account", "component", "handler", "accountID", cmd.AccountID)

	return nil
}

Über einen context.Context wird der Authentifizierungs-Context übergeben und die Autorisierung geprüft. Der Rest des Codes bedient sich Funktionen im repository Package, um den Account in einer Transaktion einzufügen. Handler Methoden bieten auch einen guten Punkt, um Logging und weitere Telemetrie zentral zu erfassen.

Ein Account kann nun sowohl per GraphQL API als auch per CLI mit der gleichen Logik erstellt werden.

admin.resolvers.go
package graph

// ... imports

func (r *mutationResolver) CreateAccount(
	ctx context.Context,
	role domain_model.Role,
	emailAddress string,
	password string,
	organisationID *uuid.UUID,
) (*model.Account, error) {
	cmd, err := command.NewAccountCreateCmd(emailAddress, role, password)
	if err != nil {
		return nil, err
	}

	err = r.handler.AccountCreate(ctx, cmd)
	if err != nil {
		return nil, err
	}

	record, err := r.finder.QueryAccount(ctx, query.AccountQuery{
		AccountID: cmd.AccountID,
	})
	if err != nil {
		return nil, err
	}
	return helper.MapToAccount(record), nil
}

Fazit

Zusammenfassend kann man sagen, dass wir mit dem Boilerplate eine gute Basis für neue Go API Backends haben, die auch bereits in vielen Projekten eingesetzt wird. Die Ansätze haben sich bewährt und mit der CQRS-Architektur konnten wir bisher viele Anforderungen in einer sauberen und verständlichen Art und Weise umsetzen.

Durch die gute Anpassbarkeit und geringere Abstraktion können auch zukünftige Anforderungen zuverlässig umgesetzt werden, ohne dass wir an die Grenzen eines Frameworks stoßen.

Natürlich kommen auch diese Projekte nicht ohne Dependencies aus - aber wir versuchen diese auf ein Minimum zu reduzieren und nur dort einzusetzen, wo sie wirklich einen Mehrwert bieten.

Ändern sich Ansätze mit der Zeit ist es möglich diese auch auf bestehende Projekte anzuwenden, dies kann aber z.B. zusammen mit größeren Änderungen eingeplant werden und muss sich nicht an den Release-Zyklen eines Frameworks orientieren.

Wer das ganze selber ausprobieren möchte, kann das Boilerplate gerne verwenden (MIT License): Das Boilerplate ist auf github.com/networkteam/go-apibackend-boilerplate zu finden und kann als Basis für eigene Projekte verwendet werden.