Hexagonal Architecture in a Go Monorepo
A practical walkthrough of ports & adapters in Go.
For someone coming from TypeScript environment, it can be hard to understand why Go repo’s have this strange folder structure. It can be intimidating to understand domains, ports, services, repository and handlers
The main motivation behind Hexagonal architecture is to protect business logic from infrastructure details. Along with this, testability comes as an add-on. Without spinning up a real DB instance we can test out every service.
Repo structure
app/
├── cmd/
│ └── api/
│ └── main.go ← wiring only, no logic
├── internal/
│ ├── domain/
│ │ ├── user.go ← entities, value objects
│ │ ├── order.go
│ │ └── ports.go ← interfaces (UserRepository, Mailer...)
│ ├── service/
│ │ ├── user_service.go ← business orchestration
│ │ └── order_service.go
│ ├── repository/
│ │ ├── postgres_user.go ← implements domain.UserRepository
│ │
│ ├── handler/
│ │ └── user_handler.go ← HTTP, gRPC adapters
│
├── pkg/ ← shared utils (if any), safe to import externally
└── go.mod
Domain
Domains are just entities and their logic defined with 0 dependencies and imports. If you find http/net or db imported in a domain file, just know it’s a leak. A domain contains everything from a value object to structs and even domain errors.
A simple example could be:
import (
"errors"
"time"
)
// Entity which has an ID and lives in DB
type User struct{
ID int64
Email string
Name string
CreatedAt time.Time
}
type Money struct{
Amount int64
Currency string
}
// Value object
func (m Money) Add(other Money) (Money,error){
if m.Currency!=other.Currency{
return Money{},errors.New("currency mismatch")
}
return Money{Amount:m.Amount + other.Amount, Currency: m.Currency}, nil
}
// Domain errors
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already taken")
)
Ports
Ports are interfaces defined in the domain/service layer but implemented in the infrastructure layer. These interfaces describe what the service needs from the outside world.
Ports make the whole architecture swappable and testable.
Here’s a simple file demonstrating a port
package domain
type UserRepository interface {
GetByID(id int64) (*User,error)
GetByEmail(email string) (*User,error)
Create(user *User) error
Update(user *User) error
}
Service
This is the business logic layer. It orchestrates the domain rules by calling ports (interfaces). Service layer never touches the SQL directly and it never parses HTTP.
An example:
type UserService struct{
repo domain.UserRepository //interface
}
func (s *UserService) Register(email, name string) (*domain.User,error){
existing,_:=s.repo.GetByEmail(email)
if existing!=nil{
return nil,domain.ErrEmailTaken
}
user:=&domain.User{Email:email, Name: name}
s.repo.Create(user)
return user, nil
}
Service layer knows about the domain structs as well as ports (interfaces)
Repository
This has the implementation of a port that talks to the database. It satisfies the UserRepository interface defined in the domain. So, the service can call it without ever knowing it is Postgres or any other DB. All SQL lives here and nowhere else.
An example:
type PostgresUserRepo struct{
db *sql.DB
}
// Compile time interface satisfaction check to make sure we've implemented all the methods
var _ domain.UserRepository = (*PostgresUserRepo)(nil)
func (r *PostgresUserRepo) GetByID (id int64) (*domain.User,error){
u:=&domain.User{}
err:=r.db.QueryRow(
"SELECT id, email FROM users WHERE id=$1", id
).Scan(&u.ID, &u.Email)
if err==sql.ErrNoRows{
return nil, domain.ErrUserNotFound
}
return u,err
}
Repository layer knows about the domain structs and database/sql
Handler
This is the outermost layer. It is a HTTP adapter that parses incoming requests, calls the service with clean data and writes the response. It knows nothing about the database.
An example:
type UserHandler struct {svc *service.UserService}
func (h * UserHandler) Register (w http.ResponseWriter, r *http.Request){
var req struct{
Email string `json:"email"`
Name string `json:"name"`
}
json.NewDecoder(r.Body).Decode(&req)
user,err:=h.svc.Register(req.Email, req.Name)
if err!=nil{
http.Error(w,err.Error(), 400)
return
}
json.NewEncoder(w).Encode(user)
}
Lifecycle of a HTTP request
Client -> Handler -> Service -> Repository -> Handler
Let’s trace a POST /users/register request:
- Request hits the middleware.
- Handler decodes the raw JSON body into a Go struct and does input validation. It then calls
svc.Register(email,name)and knows nothing beyond it. - The service checks whether the email is already in use and builds the
domain.Userstruct. It then calls the repo to persist it. - The repo receives the domain struct, runs SQL and scans the result back into the struct. If the DB returns an error the repo translates it into a domain error before passing it up.
- The return value goes back up through service -> handler. The handler encodes the user as JSON and writes a
201 Createdresponse.
How all these layers wire together
Client -> Middleware -> Handler -> Service -> Repository -> Handler -> Client
Each layer only knows about the layer to its left.
- The repository will never import the handler.
- The service will never import the repo directly. It only imports the interface(port)
- The domain imports nothing from the codebase at all.
Main.go
package main
import (
"database/sql"
"net/http"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
"app/internal/config"
"app/internal/handler"
"app/internal/middleware"
"app/internal/repository"
"app/internal/service"
)
func main() {
// Load all config from environment variables
cfg := config.Load()
// Create the raw infrastructure clients
db, err := sql.Open("postgres", cfg.DBURL)
if err != nil {
panic(err)
}
// Wrap infra clients in structs that satisfy the domain ports
// userRepo satisfies domain.UserRepository (checked at compile time)
userRepo := repository.NewPostgresUserRepo(db)
// DEPENDENCY INJECTION
// Inject the ports into the service
userSvc := service.NewUserService(userRepo)
// Inject the service into the handler
userH := handler.NewUserHandler(userSvc)
// Register routes and start the server
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Auth(cfg.JWTSecret))
r.Post("/users/register", userH.Register)
r.Get("/users/{id}", userH.GetByID)
http.ListenAndServe(cfg.Port, r)
}
When should you use this
It makes sense to use Hexagonal architecture when you have a team, when you want to test the business logic without infrastructure, etc. Initially this could slow you down but for a large project it will eventually speed you up. It is an overkill for a simple CRUD app since its pretty boilerplate heavy.
Here are some repos to further explore the Hexagonal architecture: