← Back to list

Isolating Business Logic from Infrastructure: A Practical Guide to Hexagonal Architecture in Go

In software development, one of the biggest challenges is preventing business logic from becoming tightly coupled to database drivers, HTTP…

Mazlum Tekin · 2026-07-05 15:00 · 0 claps · 4.2 min read
#golang #hexagonal-architecture #architecture #ports-and-adapters #infrastructure-as-code
Open on Medium ↗
Wiki topics: 💻 · Programming 🏛️ · Architecture

Isolating Business Logic from Infrastructure: A Practical Guide to Hexagonal Architecture in Go

In software development, one of the biggest challenges is preventing business logic from becoming tightly coupled to database drivers, HTTP frameworks, or third-party libraries over time. When a change in database technology or an integration requirement starts affecting the heart of your system, technical debt becomes inevitable.

Hexagonal Architecture (also known as Ports and Adapters) is a powerful design pattern created to break this chain of dependencies, placing your business logic at the center and isolating it from the outside world.

1. Short Definition: What is Hexagonal Architecture?

Hexagonal Architecture logically divides an application into two main parts: the Inside (Core/Domain) and the Outside (Infrastructure/Adapters).

  • Core: Contains the business rules that dictate what the application actually does. It is completely technology-agnostic.
  • Ports: Interfaces provided by the core to communicate with the outside world.
  • Adapters: The technical details (HTTP handlers, gRPC servers, PostgreSQL repositories, message queues) that implement or use these ports.

2. Project Structure and Layer Separation

In this Go microservice project, layers are separated by strict boundaries. The basic structure looks like this:

├── domain/       # Pure business rules and objects (Entities, Aggregates)
├── app/          # Application layer where use cases are orchestrated
├── ports/        # Inbound and outbound interfaces
└── adapters/     # Databases, HTTP servers, gRPC clients, etc.

In this architecture, the dependency direction is always inward. This means the domain layer knows absolutely nothing about the layers above it or any external libraries. Meanwhile, the adapters layer depends solely on the interfaces defined in the ports layer.

3. Practical Implementation Through Code

To make this concept concrete, let’s look at an order management system. The following code examples will show how we build the system layer by layer, keeping everything connected yet loosely coupled.

Step 1: The Domain Object (The Center)

First and foremost, we define our domain entity, completely independent of the outside world.

package domain

import "errors"

var ErrInvalidAmount = errors.New("order amount must be greater than zero")

// Order is the domain model at the heart of our system.
type Order struct {
 ID         string
 CustomerID string
 Total      float64
 Status     string
}

// NewOrder creates a new order object by validating business rules.
func NewOrder(id, customerID string, total float64) (*Order, error) {
 if total <= 0 {
  return nil, ErrInvalidAmount
 }
 return &Order{
  ID:         id,
  CustomerID: customerID,
  Total:      total,
  Status:     "PENDING",
 }, nil
}

Step 2: Defining Ports (Interfaces)

We determine how the core will communicate with the outside world using interfaces in the ports layer. There are two types of ports: Inbound (Driving) and Outbound (Driven).

package ports

import "context"
import "your-project/domain"

// OrderRepository is an Outbound Port. It allows the core to write data outward (e.g., to a DB).
type OrderRepository interface {
 Save(ctx context.Context, order *domain.Order) error
}

// OrderUseCase is an Inbound Port. It allows the outside world (HTTP/gRPC) to trigger the core.
type OrderUseCase interface {
 CreateOrder(ctx context.Context, customerID string, total float64) (*domain.Order, error)
}

Step 3: Application Layer (Connecting the Ports)

This is where business logic is orchestrated. Use cases are executed independently of the underlying infrastructure.

package app

import (
 "context"
 "github.com/google/uuid"
 "your-project/domain"
 "your-project/ports"
)

type OrderService struct {
 repo ports.OrderRepository
}

// NewOrderService accepts the port via dependency injection (DI).
func NewOrderService(repo ports.OrderRepository) ports.OrderUseCase {
 return &OrderService{repo: repo}
}

func (s *OrderService) CreateOrder(ctx context.Context, customerID string, total float64) (*domain.Order, error) {
 orderID := uuid.New().String()

 // Business rules are triggered when creating the domain model
 order, err := domain.NewOrder(orderID, customerID, total)
 if err != nil {
  return nil, err
 }

 // Data is saved via the outbound port
 if err := s.repo.Save(ctx, order); err != nil {
  return nil, err
 }

 return order, nil
}

Step 4: Adapter Layer (Technical Details)

Now we can write the database details using PostgreSQL, GORM, or pgx. Even if this layer changes entirely in the future, none of the code above will be affected.

package adapters

import (
 "context"
 "gorm.io/gorm"
 "your-project/domain"
)

type PostgresOrderRepository struct {
 db *gorm.DB
}

func NewPostgresOrderRepository(db *gorm.DB) *PostgresOrderRepository {
 return &PostgresOrderRepository{db: db}
}

// Save maps the domain model to the database schema and persists it.
func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
 // Database modeling and mapping happens here
 return r.db.WithContext(ctx).Table("orders").Create(order).Error
}

4. Why Dependency Direction Matters & How It Boosts Testability

Unlike traditional layered architectures, in a Hexagonal design, the business logic does not depend on the database; rather, the database depends on the business logic. This reversal of dependencies (Dependency Inversion) makes writing tests incredibly easy.

When we want to test the application layer, we don’t need to spin up a real PostgreSQL instance or drown in complex mock libraries. By writing a simple “stub” (a fake data provider) that satisfies the ports.OrderRepository interface, we can create working unit tests in seconds.

package app_test

import (
 "context"
 "testing"
 "your-project/app"
 "your-project/domain"
)

// We define a fake repository adapter
type stubOrderRepository struct {
 SavedOrder *domain.Order
}

func (s *stubOrderRepository) Save(ctx context.Context, order *domain.Order) error {
 s.SavedOrder = order
 return nil
}

func TestOrderService_CreateOrder_Valid(t *testing.T) {
 stubRepo := &stubOrderRepository{}
 service := app.NewOrderService(stubRepo)

 _, err := service.CreateOrder(context.Background(), "user-123", 150.0)

 if err != nil {
  t.Fatalf("Did not expect an error: %v", err)
 }
 if stubRepo.SavedOrder.Total != 150.0 {
  t.Errorf("Expected total 150.0, got: %f", stubRepo.SavedOrder.Total)
 }
}

5. Practical Takeaways

  • Resistance to Change: If you decide to switch from a REST API to gRPC tomorrow, or from GORM to raw SQL (pgx), all you have to do is create a new file in the adapters layer. You don't touch the domain or app layers at all.
  • Parallel Development: Because the interfaces (Ports) are defined early on, one developer can build HTTP endpoints while another simultaneously develops the database logic without stepping on each other’s toes.

Key Lessons to Take from This Article

  1. Don’t center your app around frameworks: As often emphasized in the Go community, frameworks are not your application; they are simply tools to deliver it.
  2. The power of interfaces: The implicit interface structure in the Go language maximizes the flexibility of Hexagonal Architecture. Thanks to ports, your business logic remains completely isolated and safe.
  3. Fast and reliable tests: Abstracting infrastructure dependencies simplifies your testing architecture, speeding up your CI/CD pipelines and naturally improving code quality.

Let’s Connect!

I hope this practical guide to Hexagonal Architecture in Go provides useful insights for your own projects. Decoupling core logic is a cornerstone of building scalable and maintainable systems.

I’d love to hear your thoughts and experiences with architectural patterns in Go. Feel free to reach out, ask questions, or share your feedback in the comments!


메타데이터
post_id
f02b5badd692
slug
isolating-business-logic-from-infrastructure-a-practical-guide-to-hexagonal-architecture-in-go-f02b5badd692
url
https://medium.com/@mazlum.pr/isolating-business-logic-from-infrastructure-a-practical-guide-to-hexagonal-architecture-in-go-f02b5badd692
canonical_url
https://medium.com/@mazlum.pr/isolating-business-logic-from-infrastructure-a-practical-guide-to-hexagonal-architecture-in-go-f02b5badd692
author_url
https://medium.com/@mazlum.pr
status
ok
fetched_at
2026-07-30 02:57:19