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.