← Alle Beiträge

Go Microservices Architektur — Patterns, die wirklich funktionieren

Matthias Bruns · · 7 Min. Lesezeit
go microservices architektur backend

Der Microservices Reality Check

Alle reden über Microservices. Wenige reden darüber, warum die meisten Microservices-Architekturen in der Praxis scheitern — nicht wegen der Technologie, sondern weil die Grenzen an den falschen Stellen gezogen werden.

Go ist wohl die beste Sprache für Microservices heute. Aber Go allein rettet Dich nicht vor einem verteilten Monolithen. Was zählt, ist wie Du Services strukturierst, wie sie miteinander kommunizieren und wie Du mit Fehlern umgehst.

Dieser Post behandelt Patterns, die wir in Produktion einsetzen. Keine reinen Theorie-Konzepte. Kein “kommt drauf an” ohne Konsequenz.

Warum Go zu Microservices passt

Go wurde praktisch dafür gebaut:

  • Single-Binary-Deployment. Keine Runtime, keine Dependency-Hölle. Ein 15 MB Docker-Image, das in Millisekunden startet.
  • First-Class Concurrency. Goroutinen und Channels bilden natürlich ab, wie man parallele Requests über Services hinweg bearbeitet.
  • Schnelle Kompilierung. CI-Pipelines, die 20 Services in unter einer Minute bauen.
  • Explizites Error Handling. In einem verteilten System kann jeder Aufruf fehlschlagen. Go zwingt Dich, damit umzugehen.
// Ein typisches Service-Binary: klein, eigenständig, schnell startend
func main() {
    cfg := config.Load()
    db := database.Connect(cfg.DatabaseURL)
    defer db.Close()

    svc := order.NewService(db, inventory.NewClient(cfg.InventoryURL))
    srv := server.New(cfg.Port, svc)

    if err := srv.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

Vergleich mit einem Spring Boot Service: 200+ MB Image, 15 Sekunden Startup, Classpath-Konflikte, Annotation-Magie, die den halben Kontrollfluss versteckt. Go-Services sind transparent und schnell.

Service-Grenzen: Falsch gezogen, alles falsch

Die wichtigste Entscheidung ist, wo Du schneidest. Falsche Grenzen erzeugen Services, die sich ständig gegenseitig aufrufen müssen — ein verteilter Monolith mit Netzwerk-Latenz als Gratiszugabe.

Regeln, die wir befolgen

1. Ein Service besitzt eine Business-Fähigkeit.

Nicht “ein Service pro Datenbank-Tabelle”. Nicht “ein Service pro Team”. Ein Service pro Business-Fähigkeit: Bestellungen, Inventar, Abrechnung, Benachrichtigungen. Wenn zwei Konzepte sich immer gemeinsam ändern, gehören sie in denselben Service.

2. Services kommunizieren über Events, nicht über synchrone Ketten.

Wenn Service A Service B aufruft, der Service C aufruft, der Service D aufruft — dann hast Du keine Microservices. Du hast einen verteilten Funktionsaufruf mit vier Fehlerpunkten.

// Schlecht: synchrone Kette
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    // Schlägt fehl, wenn Inventory down ist
    stock, err := s.inventoryClient.CheckStock(ctx, req.ProductID)
    if err != nil {
        return fmt.Errorf("check stock: %w", err)
    }
    // Schlägt fehl, wenn Billing down ist
    payment, err := s.billingClient.ChargeCard(ctx, req.PaymentInfo)
    if err != nil {
        return fmt.Errorf("charge card: %w", err)
    }
    // Drei Services müssen gleichzeitig laufen
    return s.repo.SaveOrder(ctx, stock, payment)
}
// Besser: Event publizieren, Consumer reagieren lassen
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    order, err := s.repo.CreatePendingOrder(ctx, req)
    if err != nil {
        return fmt.Errorf("create order: %w", err)
    }
    // Andere Services reagieren asynchron
    return s.events.Publish(ctx, events.OrderCreated{
        OrderID:   order.ID,
        ProductID: req.ProductID,
        Amount:    req.Amount,
    })
}

3. Geteilte Datenbanken sind verboten.

Wenn zwei Services aus derselben Tabelle lesen, sind sie ein Service, der so tut, als wäre er zwei. Jeder Service besitzt seine Daten. Punkt.

Kommunikations-Patterns

gRPC für Service-zu-Service

Für synchrone Aufrufe zwischen Services (ja, manchmal braucht man sie) ist gRPC mit Protocol Buffers die Standardwahl in Go:

  • Typsichere Contracts. Proto-Dateien sind gleichzeitig API-Dokumentation und Code-Generierungsquelle.
  • Streaming-Support. Bidirektionales Streaming für Echtzeit-Datenflüsse.
  • Performance. Binäre Serialisierung ist 5-10x schneller als JSON. Zählt bei Skalierung.
// inventory/v1/inventory.proto
service InventoryService {
  rpc GetStock(GetStockRequest) returns (GetStockResponse);
  rpc ReserveStock(ReserveStockRequest) returns (ReserveStockResponse);
}

message GetStockRequest {
  string product_id = 1;
}

message GetStockResponse {
  int32 available = 1;
  int32 reserved = 2;
}

Go’s gRPC-Tooling (google.golang.org/grpc) generiert Server- und Client-Code aus Proto-Dateien. Typsicher, versioniert, kein Raten.

NATS oder Kafka für Events

Für asynchrone Kommunikation hängt die Wahl von Deiner Skalierung ab:

  • NATS: Leichtgewichtig, einfach zu betreiben, JetStream für Persistenz. Ideal für die meisten Workloads.
  • Apache Kafka: Kampferprobt bei massiver Skalierung, aber operativ aufwändig. Nur verwenden, wenn Du es wirklich brauchst.
// Event publizieren mit NATS JetStream
func (p *Publisher) Publish(ctx context.Context, event events.OrderCreated) error {
    data, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal event: %w", err)
    }
    _, err = p.js.Publish(ctx, "orders.created", data)
    if err != nil {
        return fmt.Errorf("publish orders.created: %w", err)
    }
    return nil
}

Fehlerbehandlung über Service-Grenzen

Im Monolithen wirfst Du eine Exception und irgendwas fängt sie. Bei Microservices überqueren Fehler Netzwerkgrenzen. Go’s explizites Error Handling hilft hier tatsächlich.

Pattern: Strukturierte Fehlercodes

// Geteilte Fehlertypen über Services hinweg
type ServiceError struct {
    Code    ErrorCode `json:"code"`
    Message string    `json:"message"`
    Service string    `json:"service"`
}

type ErrorCode string

const (
    ErrNotFound     ErrorCode = "NOT_FOUND"
    ErrConflict     ErrorCode = "CONFLICT"
    ErrUnavailable  ErrorCode = "UNAVAILABLE"
    ErrInternal     ErrorCode = "INTERNAL"
)

Diese auf gRPC-Statuscodes in der Transport-Schicht mappen. Business-Logik bleibt sauber, Transport-Belange bleiben am Rand.

Circuit Breaker

Wenn ein Downstream-Service ausfällt, hör auf, ihn anzurufen. sony/gobreaker ist der Standard-Circuit-Breaker in Go:

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name:        "inventory-service",
    MaxRequests: 3,
    Interval:    10 * time.Second,
    Timeout:     30 * time.Second,
    ReadyToTrip: func(counts gobreaker.Counts) bool {
        return counts.ConsecutiveFailures > 5
    },
})

stock, err := cb.Execute(func() (any, error) {
    return inventoryClient.GetStock(ctx, productID)
})

Projektstruktur

Jeder Go-Microservice in unseren Projekten folgt dem gleichen Layout:

service-name/
├── cmd/
│   └── server/
│       └── main.go          # Einstiegspunkt
├── internal/
│   ├── domain/              # Business-Typen, keine Abhängigkeiten
│   ├── service/             # Business-Logik
│   ├── repository/          # Datenzugriff (Postgres, Redis)
│   └── transport/           # HTTP/gRPC-Handler
├── proto/                   # Protocol-Buffer-Definitionen
├── migrations/              # SQL-Migrationen
├── Dockerfile
└── go.mod

Zentrale Regeln:

  • internal/domain hat null Imports aus anderen Paketen. Reine Business-Typen.
  • internal/service hängt nur von Interfaces ab, nie von konkreten Implementierungen.
  • internal/transport ist die einzige Schicht, die HTTP oder gRPC kennt.
  • cmd/ verdrahtet alles.

Das ist nicht neu. Es ist Hexagonale Architektur angewandt auf Go. Der Punkt ist Konsistenz: Jeder Service sieht gleich aus, jeder Entwickler weiß, wo er Dinge findet.

Observability: Nicht verhandelbar

Du kannst keine Microservices ohne ordentliche Observability betreiben. Drei Säulen, keine Ausnahmen:

Strukturiertes Logging

// slog verwenden (Standardbibliothek seit Go 1.21)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("order created",
    slog.String("order_id", order.ID),
    slog.String("customer_id", order.CustomerID),
    slog.Duration("latency", time.Since(start)),
)

Distributed Tracing

OpenTelemetry ist der Standard. Trace-Context durch jeden Service-Aufruf propagieren:

ctx, span := tracer.Start(ctx, "CreateOrder")
defer span.End()

span.SetAttributes(
    attribute.String("order.product_id", req.ProductID),
    attribute.Int("order.quantity", req.Quantity),
)

Metriken

Prometheus-Metriken aus jedem Service exponieren. Request-Rate, Error-Rate und Latenz tracken (die RED-Methode):

var requestDuration = promauto.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request duration in seconds",
        Buckets: prometheus.DefBuckets,
    },
    []string{"method", "path", "status"},
)

Wann Du keine Microservices brauchst

Wenn Dein Team weniger als 5 Backend-Entwickler hat, starte mit einem modularen Monolithen. Im Ernst.

Microservices lösen organisatorische Skalierungsprobleme — unabhängige Teams, die unabhängig deployen. Wenn Du dieses Problem nicht hast, brauchst Du keine Microservices. Du brauchst gute Modul-Grenzen innerhalb eines Monolithen.

Go macht das ebenfalls einfach. Nutze internal/-Packages, um Grenzen durchzusetzen. Wenn Du tatsächlich splitten musst, sind die Grenzen schon da.

Fazit

Go Microservices funktionieren gut, wenn Du:

  1. Service-Grenzen um Business-Fähigkeiten ziehst, nicht um technische Schichten.
  2. Standardmäßig asynchrone Kommunikation (Events) nutzt, synchrone Aufrufe (gRPC) nur wenn nötig.
  3. Observability als Anforderung behandelst, nicht als Nachgedanke.
  4. Jeden Service einfach, eigenständig und unabhängig deploybar hältst.
  5. Nicht mit Microservices startest, wenn Teamgröße und Deployment-Anforderungen es nicht rechtfertigen.

Das Tooling existiert. Die Patterns sind bewährt. Der schwierige Teil ist Disziplin — und Go’s Einfachheit macht Disziplin leichter einzuhalten.

Lesebarkeit

Schriftgröße