The core of Hexagonal Architecture (Ports & Adapters) is dependency direction control. It isolates all external dependencies behind interfaces (Ports) so that business logic never depends on frameworks or databases.
Go’s implicit interfaces and package structure make this pattern a natural fit.
Hexagonal Architecture
This pattern, proposed by Alistair Cockburn, divides an application into three areas.
Domain. The core layer containing business rules. It depends on no external technology.
Port. The interface between the application and the outside world. Two kinds exist:
- Driving port (inbound): Entry points from outside into the application. Defines what the application offers.
- Driven port (outbound): Interfaces through which the application requests external systems. Defines what the application needs.
Adapter. The implementation of a Port. Driving adapters (HTTP handlers, gRPC handlers) receive external requests and call ports. Driven adapters (DB repositories, message brokers) implement port interfaces to communicate with external systems.
Dependencies always point inward: Adapter → Port → Domain. Domain knows nothing about Port, and Port knows nothing about Adapter.
flowchart LR
subgraph Adapter["Adapter"]
DA["Driving Adapter
REST, gRPC"]
DRA["Driven Adapter
DB, Kafka"]
end
subgraph Port["Port"]
DP["Driving Port"]
DRP["Driven Port"]
end
subgraph Core["Domain + Application"]
D["Entity"]
A["UseCase / Service"]
end
DA -->|calls| DP
DP -.->|defines| A
A -->|uses| DRP
DRP -.->|implements| DRA
A -->|contains| D
Go Directory Structure
A directory structure commonly used when applying Hexagonal Architecture in Go:
internal/
├── domain/
│ ├── entity/ # Business entities
│ └── service/ # Domain services
├── port/
│ ├── driving/ # Inbound interfaces
│ └── driven/ # Outbound interfaces
├── application/
│ ├── usecase/ # Business operation units
│ ├── dto/ # Data transfer objects
│ └── mapper/ # entity ↔ dto conversion
└── adapter/
├── driving/ # REST handler, gRPC handler
└── driven/ # DB repository, message broker
The internal/ package prevents direct access from external modules, naturally encapsulating the application’s internals.
Port
Ports are defined as Go interfaces.
Driving Port
Entry points from outside into the application. Defining one interface per use case gives each interface a single responsibility.
// port/driving/messenger.go
type JoinRoomUseCase interface {
Handle(ctx context.Context, req dto.JoinRequest) error
}
type SendMessageUseCase interface {
Handle(ctx context.Context, req dto.SendRequest) error
}
Driven Port
Interfaces through which the application requests external systems.
// port/driven/message.go
type MessageRepository interface {
Create(ctx context.Context, message entity.Message) error
FindByRoom(ctx context.Context, roomID string, cursor string, limit int) ([]entity.Message, error)
}
type MessageBroker interface {
Publish(ctx context.Context, message entity.Message) error
Subscribe(subscriber MessageSubscriber)
}
Implicit Interfaces
Go interfaces are satisfied implicitly. If an adapter has the methods defined by a port interface, it satisfies that interface without any explicit declaration — no implements keyword like Java.
This characteristic suits Hexagonal Architecture well. A driven adapter implementing a driven port does not need to import the port package. Dependencies stay separated at the code level too.
To guarantee interface compliance at compile time, a common convention exists:
var _ driven.MessageRepository = (*MongoMessageRepository)(nil)
This single line verifies at compile time that MongoMessageRepository satisfies driven.MessageRepository.
Adapter
Driving Adapter
An HTTP handler is a typical driving adapter. It receives external requests and calls the driving port (use case).
// adapter/driving/rest/handler.go
type Handler struct {
sendUseCase driving.SendMessageUseCase
}
func NewHandler(uc driving.SendMessageUseCase) *Handler {
return &Handler{sendUseCase: uc}
}
func (h *Handler) Send(c *gin.Context) {
var req dto.SendRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.sendUseCase.Handle(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
The handler depends only on the driving port interface. It has no knowledge of what implementation sits behind it.
Driven Adapter
A DB repository is a typical driven adapter. It implements the driven port interface.
// adapter/driven/persistence/repository.go
type MongoMessageRepository struct {
collection *mongo.Collection
}
func NewMongoMessageRepository(db *mongo.Database) *MongoMessageRepository {
return &MongoMessageRepository{
collection: db.Collection("messages"),
}
}
func (r *MongoMessageRepository) Create(ctx context.Context, message entity.Message) error {
doc := orm.FromMessage(message)
_, err := r.collection.InsertOne(ctx, doc)
if err != nil {
return fmt.Errorf("insert message: %w", err)
}
return nil
}
ORM models and domain entities use separate structs. orm.FromMessage() and ToDomain() methods handle conversion, keeping domain entities independent of the database schema.
Domain and Application
Entity
Domain entities contain business rules. Fields are unexported (lowercase) with getter methods.
// domain/entity/message.go
type Message struct {
id string
roomID string
userID string
body string
sentAt time.Time
}
func NewMessage(roomID, userID, body string) Message {
return Message{
id: uuid.New().String(),
roomID: roomID,
userID: userID,
body: body,
sentAt: time.Now(),
}
}
func (m Message) ID() string { return m.id }
func (m Message) RoomID() string { return m.roomID }
func (m Message) Body() string { return m.body }
Unexported fields prevent direct external modification. Creation only happens through the NewMessage constructor, protecting domain invariants.
UseCase
A use case handles one business operation. It implements a driving port and depends on driven ports.
// application/usecase/send.go
type SendUseCase struct {
repo driven.MessageRepository
broker driven.MessageBroker
}
func NewSendUseCase(repo driven.MessageRepository, broker driven.MessageBroker) *SendUseCase {
return &SendUseCase{repo: repo, broker: broker}
}
func (uc *SendUseCase) Handle(ctx context.Context, req dto.SendRequest) error {
message := entity.NewMessage(req.RoomID, req.UserID, req.Body)
if err := uc.repo.Create(ctx, message); err != nil {
return fmt.Errorf("save message: %w", err)
}
if err := uc.broker.Publish(ctx, message); err != nil {
return fmt.Errorf("publish message: %w", err)
}
return nil
}
The use case depends only on driven port interfaces. Whether the backing store is MongoDB or PostgreSQL, any implementation of MessageRepository can be swapped in.
Dependency Injection
In Go, assembling dependencies directly in the main function without a DI framework is the common approach.
func main() {
// driven adapters
db := mongodb.Connect(os.Getenv("MONGO_URI"))
messageRepo := repository.NewMongoMessageRepository(db)
broker := messaging.NewKafkaBroker(kafkaConfig)
// use case (inject driven ports)
sendUseCase := usecase.NewSendUseCase(messageRepo, broker)
// driving adapter (inject driving port)
handler := rest.NewHandler(sendUseCase)
// start server
server := rest.NewServer(handler)
server.Run(":8080")
}
The dependency graph appears explicitly in one place. Tracing which implementation is injected into which interface requires nothing more than reading the code.
In Java/Spring, @Component and @Autowired let the framework inject dependencies automatically. In Go, this process is manual — but the dependency flow stays explicit and easy to trace.
Summary
Hexagonal Architecture implementation varies by language idiom. In Go, implicit interfaces, the internal package, and manual DI align well with this pattern. Define ports as interfaces, implement them in adapters, and assemble everything in main. Dependency direction appears directly in the code structure, no framework required.