Aufbau robuster Go-Anwendungen mit hexagonalem Architekturmuster
Min-jun Kim
Dev Intern · Leapcell

Einführung
In der sich ständig weiterentwickelnden Landschaft der Softwareentwicklung ist der Aufbau von Anwendungen, die nicht nur funktional, sondern auch wartbar, testbar und anpassungsfähig an Änderungen sind, von größter Bedeutung. Wenn Projekte komplexer werden, kann die anfängliche Eleganz eines Systems schnell zu einem verwickelten Durcheinander eng gekoppelter Komponenten verfallen, was Änderungen zu einem riskanten Unterfangen macht. Dies ergibt sich oft aus einer Architektur, bei der die Kern-Geschäftslogik stark mit technischen Details wie Datenbanken, Benutzeroberflächen oder externen APIs verknüpft ist. Dieser Blogbeitrag befasst sich mit der hexagonalen Architektur
, einem mächtigen Paradigma, das eine Lösung für diese Herausforderungen bietet, insbesondere im Go-Ökosystem. Wir werden untersuchen, wie dieser Architekturstil, auch bekannt als Ports and Adapters
, es uns ermöglicht, klare Geschäftsabgrenzungen zu definieren, unsere Domäne von Infrastrukturfragen zu entkoppeln und letztendlich robustere und zukunftssichere Go-Anwendungen zu erstellen.
Verständnis des hexagonalen Architekturmusters
Bevor wir uns mit den praktischen Aspekten befassen, wollen wir ein gemeinsames Verständnis der Kernkonzepte hinter dem hexagonalen Architekturmuster schaffen.
Kernterminologie
- Hexagonale Architektur (Ports and Adapters): Ein Architekturmuster, das die Kernanwendungslogik (die "Domäne" oder "Geschäftslogik") von externen Belangen (wie Datenbanken, Benutzeroberflächen oder Drittanbieterdiensten) isoliert. Es betont das Prinzip der "Inversion of Control", damit die Anwendung von verschiedenen externen Akteuren gesteuert werden kann, ohne ihren Kern zu ändern. Das "Hexagon" ist lediglich eine visuelle Metapher, die die verschiedenen Möglichkeiten darstellt, mit einer Anwendung zu interagieren.
- Ports: Schnittstellen, die einen Vertrag definieren, wie die Anwendung mit der Außenwelt interagiert. Sie können "driven ports" (APIs, die von der Kernanwendung für externe Akteure bereitgestellt werden) oder "driving ports" (Dienste, die die Kernanwendung von der Außenwelt benötigt, wie eine Datenbank) sein. Stellen Sie sie sich als die "Buchsen" Ihrer Anwendung vor.
- Adapter: Implementierungen der Ports. Sie übersetzen die spezifische Technologie oder das Protokoll einer externen Komponente in ein Format, das der Kern der Anwendung versteht (für driving ports) oder übersetzen die Antworten der Anwendung in ein Format, das die externe Komponente versteht (für driven ports). Sie sind die "Stecker", die in die Buchsen passen.
- Domäne / Anwendungs-Kern: Dies ist das Herzstück Ihrer Anwendung, das die reine Geschäftslogik und Regeln enthält. Es weiß nichts über Datenbanken, Web-Frameworks oder spezifische UI-Technologien. Es interagiert ausschließlich über Ports.
Prinzipien und Implementierung in Go
Das Hauptziel der hexagonalen Architektur ist es, die Domänenlogik vor externen Änderungen zu schützen. In Go spielen Schnittstellen eine entscheidende Rolle, um diese Entkopplung zu erreichen.
Betrachten wir eine einfache "Benutzerverwaltungs"-Anwendung, bei der Benutzer erstellt und abgerufen werden können.
1. Definieren des Domänen-Kerns
Zuerst definieren wir unsere Kerngeschäftseinheiten und die grundlegenden Operationen. Dieser Teil sollte frei von datenbank- oder web-frameworkspezifischen Dingen sein.
// internal/domain/user.go package domain import "errors" var ErrUserNotFound = errors.New("user not found") type User struct { ID string Name string Email string } // UserRepository definiert die Schnittstelle für die Interaktion mit der Benutzerpersistenz. // Dies ist ein "driving port", da der Anwendungs-Kern Benutzer von der Außenwelt abfragen muss. type UserRepository interface { Save(user User) error FindByID(id string) (User, error) FindByEmail(email string) (User, error) } // UserService definiert die Geschäftsoperationen für die Benutzerverwaltung. // Dies ist auch Teil unseres Domänen-Kerns. type UserService struct { userRepo UserRepository // Hängt vom Port ab } func NewUserService(repo UserRepository) *UserService { return &UserService{userRepo: repo} } func (s *UserService) RegisterUser(name, email string) (User, error) { // Geschäftsregel: Überprüfen, ob ein Benutzer mit dieser E-Mail-Adresse bereits existiert _, err := s.userRepo.FindByEmail(email) if err == nil { return User{}, errors.New("user with this email already exists") } if err != domain.ErrUserNotFound { return User{}, err // Andere Fehler bei der Persistenz } newUser := User{ ID: generateUUID(), // Vereinfacht für das Beispiel Name: name, Email: email, } if err := s.userRepo.Save(newUser); err != nil { return User{}, err } return newUser, nil } func (s *UserService) GetUser(id string) (User, error) { return s.userRepo.FindByID(id) } func generateUUID() string { // Reale Welt: eine UUID-Bibliothek wie "github.com/google/uuid" verwenden return "some-uuid" }
Beachten Sie, dass UserService
nur mit der UserRepository
-Schnittstelle interagiert und nicht mit einer konkreten Datenbankimplementierung. Das ist die Essenz der Entkopplung.
2. Definieren von Ports
Im obigen Beispiel ist UserRepository
ein driving port. Stellen wir uns einen driven port für eine API vor, die das Erstellen von Benutzern ermöglicht.
// internal/application/ports/user_api.go package ports import "example.com/myapp/internal/domain" // UserAPIService ist ein driven port. Externe Akteure "steuern" die Anwendung // über diese Schnittstelle. type UserAPIService interface { RegisterUser(name, email string) (domain.User, error) GetUser(id string) (domain.User, error) }
Der Kern der Anwendung wird diese UserAPIService
-Schnittstelle implementieren.
// internal/application/service.go package application import "example.com/myapp/internal/domain" // ApplicationService implementiert den UserAPIService Port. // Er orchestriert Aufrufe an den Domänendienst. type ApplicationService struct { userService *domain.UserService } func NewApplicationService(userService *domain.UserService) *ApplicationService { return &ApplicationService{userService: userService} } func (s *ApplicationService) RegisterUser(name, email string) (domain.User, error) { return s.userService.RegisterUser(name, email) } func (s *ApplicationService) GetUser(id string) (domain.User, error) { return s.userService.GetUser(id) }
3. Implementieren von Adaptern
Nun erstellen wir Adapter, um unseren Anwendungs-Kern mit spezifischen Technologien zu verbinden.
Datenbank-Adapter (implementiert den "UserRepository" driving port):
// internal/adapters/repository/inmem_user_repo.go package repository import ( "errors" "sync" "example.com/myapp/internal/domain" ) // InMemoryUserRepository ist ein Adapter für eine In-Memory-Datenbank. type InMemoryUserRepository struct { users map[string]domain.User mu sync.RWMutex } func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: make(map[string]domain.User), } } func (r *InMemoryUserRepository) Save(user domain.User) error { r.mu.Lock() defer r.mu.Unlock() r.users[user.ID] = user return nil } func (r *InMemoryUserRepository) FindByID(id string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() user, ok := r.users[id] if !ok { return domain.User{}, domain.ErrUserNotFound } return user, nil } func (r *InMemoryUserRepository) FindByEmail(email string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() for _, user := range r.users { if user.Email == email { return user, nil } } return domain.User{}, domain.ErrUserNotFound }
Wir könnten diesen InMemoryUserRepository
leicht durch einen PostgreSQLUserRepository
oder MongoDBUserRepository
ersetzen, solange diese die Schnittstelle domain.UserRepository
implementieren, ohne die domain.UserService
zu ändern.
Web API Adapter (steuert den "UserAPIService" driven port):
// cmd/main.go (vereinfachter Einstiegspunkt) package main import ( "encoding/json" "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/application/ports" "example.com/myapp/internal/domain" ) type RegisterUserRequest struct { Name string `json:"name"` Email string `json:"email"` } // UserAPIAdapter ist ein driven Adapter (z. B. HTTP-Handler), // der den UserAPIService Port der Anwendung konsumiert. type UserAPIAdapter struct { appService ports.UserAPIService } func NewUserAPIAdapter(service ports.UserAPIService) *UserAPIAdapter { return &UserAPIAdapter{appService: service} } func (a *UserAPIAdapter) RegisterUserHandler(w http.ResponseWriter, r *http.Request) { var req RegisterUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } user, err := a.appService.RegisterUser(req.Name, req.Email) if err != nil { // Unterscheiden Sie Fehlertypen für eine bessere Fehlerbehandlung in echten Apps if err == domain.ErrUserNotFound { http.Error(w, err.Error(), http.StatusNotFound) } else { http.Error(w, err.Error(), http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // ... andere Handler für GetUser usw.
4. Zusammenfügen der Teile
In Ihrer main
-Funktion oder auf Ihrer Dependency-Injection-Ebene setzen Sie die Komponenten zusammen:
// cmd/app/main.go package main import ( "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/domain" ) func main() { // 1. Initialisieren von Adaptern (driving side - repository) userRepo := repository.NewInMemoryUserRepository() // Oder NewPostgreSQLUserRepository() // 2. Initialisieren von Domänendiensten (Kernlogik) userService := domain.NewUserService(userRepo) // 3. Initialisieren von Anwendungsdiensten (implementiert driven ports für externe Akteure) appService := application.NewApplicationService(userService) // 4. Initialisieren von Adaptern (driven side - web API) userAPIAdapter := &UserAPIAdapter{appService: appService} // Implementiert driven port für Web Clients // Web-Routen einrichten http.HandleFunc("/users", userAPIAdapter.RegisterUserHandler) // http.HandleFunc("/users/{id}", userAPIAdapter.GetUserHandler) // Beispiel log.Println("Server startet auf :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Server-Fehler: %v", err) } }
Vorteile und Anwendung
- Entkopplung: Die Kern-Domänenlogik bleibt von Infrastrukturdetails unberührt. Dies macht sie sehr gut isoliert testbar und portabel.
- Testbarkeit: Sie können Ihre Domänen- und Anwendungsdienste leicht mit Mock-Implementierungen Ihrer Ports testen (z. B.
InMemoryUserRepository
fürUserRepository
). Dies fördert schnelle, zuverlässige Unit- und Integrationstests. - Wartbarkeit und Anpassungsfähigkeit: Wenn Sie sich entscheiden, von einer relationalen Datenbank zu einer NoSQL-Datenbank zu wechseln oder Ihre Nachrichtenwarteschlange zu ändern, muss nur der entsprechende Adapter geändert oder ersetzt werden. Die Kern-Geschäftslogik bleibt unberührt.
- Klare Grenzen: Die Architektur erzwingt eine klare Trennung der Zuständigkeiten und erleichtert es Entwicklern zu verstehen, wo neue Logik platziert oder bestehender Code gefunden werden kann.
Fazit
Das hexagonale Architekturmuster, oder Ports and Adapters, bietet eine äußerst effektive Möglichkeit, Go-Anwendungen aufzubauen, die robust, testbar und veränderungsresistent sind. Durch sorgfältige Definition von Schnittstellen als Ports und die Implementierung spezifischer Technologien als Adapter schaffen wir ein flexibles System, bei dem unsere wertvolle Geschäftslogik vor der unbeständigen Natur externer Belange geschützt ist. Dieser Architekturstil ermöglicht es Entwicklern, hochwertige, anpassungsfähige Software zu liefern, die dem Zahn der Zeit standhält und klare Geschäftsabgrenzungen und eine einfache Weiterentwicklung gewährleistet.