Go Microservices Architektur — Patterns, die wirklich funktionieren
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/domainhat null Imports aus anderen Paketen. Reine Business-Typen.internal/servicehängt nur von Interfaces ab, nie von konkreten Implementierungen.internal/transportist 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:
- Service-Grenzen um Business-Fähigkeiten ziehst, nicht um technische Schichten.
- Standardmäßig asynchrone Kommunikation (Events) nutzt, synchrone Aufrufe (gRPC) nur wenn nötig.
- Observability als Anforderung behandelst, nicht als Nachgedanke.
- Jeden Service einfach, eigenständig und unabhängig deploybar hältst.
- 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.