The Nightmare When Infrastructure “Invades” Business Logic
Imagine you are maintaining a Go project that has been running stably for two years. Suddenly, your boss asks to switch from MySQL to MongoDB to optimize costs, or from SendGrid to AWS SES. You open the code and realize that SQL statements or GORM structs are scattered right inside the order processing functions.
The price to pay is high. You cannot write Unit Tests for discount calculation logic without turning on a real database. Just changing a small column in the storage layer, and the business logic suddenly breaks. This is a clear symptom of a “Big Ball of Mud” – a messy pile that everyone is afraid to touch.
Why Do We Often Make This Mistake?
A classic mistake is choosing a framework (Gin, Echo) or ORM (GORM) as soon as the project is initialized. We accidentally let external libraries shape our core data structures.
When Business Logic knows too much about technical details (like how many columns a table has or what JSON the API returns), it becomes extremely fragile. A good system needs stable core logic. Whether you use PostgreSQL today or Redis tomorrow should not change how revenue is calculated.
The Solution: Hexagonal Architecture (Ports and Adapters)
Hexagonal Architecture, proposed by Alistair Cockburn, aims to isolate Core Logic from external influences. The idea is simple: Place Business Logic at the center and wrap it with Interfaces (Ports). External components (Adapters) will connect to these Ports.
Specifically, this framework consists of three main parts:
- Core (Domain/Service): Where pure business logic resides. It contains no external dependencies.
- Ports (Interfaces): The “contracts” that the Core provides or requires.
- Adapters: The actual implementation. For example: MySQL Adapter, REST API Adapter, or gRPC Adapter.
The Difference: Layered vs. Hexagonal Architecture
Traditional 3-tier architecture usually looks like: UI -> Business -> Data Access. The problem is that the Business layer often depends directly on Data Access.
Hexagonal Architecture completely reverses this dependency (Dependency Inversion). Both the UI and the Database must revolve around the Core through Ports. As a result, the Core becomes an independent “oasis.” You can run Unit Tests for the entire business logic in milliseconds without any infrastructure.
Practical Implementation Guide in Go
Let’s start building a User management module. Here is a standard directory structure to completely separate the layers:
/internal
/core
/domain # Contains business structs (User, Order...)
/ports # Defines Interfaces
/services # Actual business logic
/adapters
/repository # DB Implementation (Gorm, SQLX...)
/handler # HTTP/gRPC Implementation (Gin, Echo...)
1. Setting Up the “Contracts” (Domain and Ports)
First, we define the User object and how different parties communicate with each other.
// internal/core/domain/user.go
package domain
type User struct {
ID int64
Email string
Name string
}
// internal/core/ports/ports.go
package ports
import "project/internal/core/domain"
// Driven Port: Required by the Core for data storage
type UserRepository interface {
Save(user *domain.User) error
GetByID(id int64) (*domain.User, error)
}
// Driving Port: Used by external actors to call into the Core
type UserService interface {
CreateUser(email, name string) error
GetUser(id int64) (*domain.User, error)
}
2. Writing Core Logic (The Heart of the Application)
This service only works with Interfaces. It is completely unaware of whether data is stored in MySQL or a text file.
// internal/core/services/usersrv/service.go
package usersrv
import (
"project/internal/core/domain"
"project/internal/core/ports"
)
type service struct {
repo ports.UserRepository
}
func New(repo ports.UserRepository) ports.UserService {
return &service{repo: repo}
}
func (s *service) CreateUser(email, name string) error {
// Here you can add logic to check email, hash password
user := &domain.User{Email: email, Name: name}
return s.repo.Save(user)
}
3. Implementing Adapters (Infrastructure Details)
Now is the time to write the actual code for MySQL. If you need to switch to MongoDB later, you just need to create a mongodb.go file in the adapters folder without changing a single line of code in the core folder.
// internal/adapters/repository/mysql.go
package repository
import (
"project/internal/core/domain"
"gorm.io/gorm"
)
type mysqlRepo struct {
db *gorm.DB
}
func NewMySQL(db *gorm.DB) *mysqlRepo {
return &mysqlRepo{db: db}
}
func (r *mysqlRepo) Save(user *domain.User) error {
return r.db.Create(user).Error
}
Pro tip: When working with Hexagonal, you will often have to map data back and forth between Domain structs and Database structs. To quickly inspect complex JSON structures, I often use toolcraft.app/en/tools/developer/json-formatter. This tool helps format and spot JSON errors extremely fast, which is more convenient than installing heavy extensions in VS Code.
4. Assembling Components in Main
The main.go function acts like a mechanic, connecting the parts through Dependency Injection.
func main() {
db := initDB() // Initialize real connection
userRepo := repository.NewMySQL(db) // Adapter
userService := usersrv.New(userRepo) // Core (Inject Adapter into Port)
handler := http.NewHandler(userService) // Transport Adapter
handler.Run()
}
Fair Evaluation: Should You Apply It Immediately?
Clear Benefits
- Testing is a breeze: You can completely Mock the Repository to test
CreateUserlogic in just 1-2 seconds. - Fearless changes: Upgrading GORM or changing a logging library is no longer a nightmare.
- Clean and focused code: Business logic is not cluttered with Database or Framework annotations.
Drawbacks to Consider
- More code (Boilerplate): You have to define many interfaces and perform data mapping.
- Over-engineering for small projects: For a simple CRUD project done in a week, this architecture might slow you down.
Conclusion
My experience is: Use Hexagonal for projects expected to run for more than 6 months or those with complex logic. It helps you sleep better whenever the system needs to scale.
If you are starting a new Go project, spend 30 minutes designing the Ports before coding. This separation will bring huge benefits as the project grows and technology change requests arrive.

