Why This Architecture Matters
Enterprise Go services usually begin small: one handler, one database call, one workflow, and a few structs. That can work for a prototype, but it becomes painful when the system grows. The common failure is not that the code has too many packages. The failure is that the most important rules of the business become tangled with the least stable parts of the system.
Databases change. Search engines change. HTTP frameworks change. Message brokers change. Deployment styles change. Business rules should change only when the business changes. Hexagonal Architecture gives you a way to keep those two worlds separate.
Hexagonal Architecture, also called ports and adapters architecture, puts the business domain at the center of the application. The domain is the part of the system that explains what the software is really responsible for. Around it are adapters that connect the domain to the outside world: HTTP, gRPC, databases, message brokers, metrics, command-line runners, and background workers.
The practical goal is simple: your core business logic should not know whether data came from PostgreSQL, an in-memory fake, a message stream, or a future storage tool. It should depend on behavior, not on technology.
The Problem
Imagine a dispatch service for a delivery company. The service receives a request to assign a van to a route. To make that decision, the system needs several inputs:
- The van ID selected for the route
- The route ID that needs service
- Current van status and capacity
- Route requirements
- Current time, because availability can depend on schedules
- A place to persist the resulting dispatch decision
The expected output is either a successful dispatch record or a clear error explaining why the assignment cannot happen.
A tightly coupled implementation might do everything in one HTTP handler:
- Read request fields.
- Query the database directly.
- Check route rules.
- Update several tables.
- Return an HTTP response.
That feels fast at first, but it creates serious long-term problems. If the database changes, the handler changes. If the HTTP contract changes, business logic is touched. If you want to run the same assignment logic from a background consumer, you duplicate it. If you want to test one rule, you need infrastructure setup.
Hexagonal Architecture prevents this by giving every responsibility a clear home.
The Shape of a Hexagonal Go Service
At a high level, the service has a stable center and replaceable edges.
External callers and tools
|
| HTTP adapter, gRPC adapter, worker adapter, CLI adapter
v
Application service
|
| Ports, expressed as Go interfaces
v
Models and domain rules
^
|
| Database adapter, cache adapter, message adapter, clock adapter
|
External systems
The direction matters. The inner code defines what it needs. The outer code provides concrete implementations. Technical details depend on the business core, not the other way around.
A practical Go implementation usually has four important internal areas:
| Layer | Main responsibility | Should not contain |
|---|---|---|
| Models | Domain types, identifiers, and small rules tied to those types | HTTP handlers, database clients, framework objects |
| Ports | Interfaces that describe what the service needs from outside | Concrete database or transport logic |
| Services | Use cases, orchestration, validation, and decisions | Direct calls to external tools |
| Adapters | Concrete implementations for databases, transports, messaging, and tooling | Business decisions |
This split is not ceremony. It is how you keep freedom to change tools without rewriting the reason the software exists.
A Practical Project Layout
Go does not force one official enterprise layout, but the following structure makes boundaries visible. The internal/ directory is useful because Go prevents packages outside the parent module from importing it directly. That keeps implementation details from becoming accidental public contracts.
delivery-platform/
|-- cmd/
| |-- api/
| | `-- main.go
| |-- worker/
| | `-- main.go
| `-- admin-cli/
| `-- main.go
|-- configs/
|-- internal/
| |-- models/
| |-- ports/
| |-- service/
| |-- adapters/
| | |-- http/
| | |-- grpc/
| | |-- db/
| | |-- events/
| | `-- metrics/
| `-- util/
`-- go.mod
Each folder has a job:
cmd/apistarts the HTTP process and wires dependencies for that executable.cmd/workerstarts a background consumer or scheduled worker and wires its own dependencies.internal/modelscontains plain domain types such asVan,Route, andDispatch.internal/portsdefines interfaces such as repositories, clocks, and event publishers.internal/servicecontains use cases, such as assigning a van to a route.internal/adapterscontains implementation details, such as HTTP handlers, database repositories, and event producers.internal/utilshould stay generic. If a helper imports a framework or client library, it probably belongs in an adapter instead.
The important rule is that cmd/* is the composition root. That means each executable builds the dependency graph at startup. Avoid a shared global main that tries to run every process in one place. A web server, a worker, and an admin command usually have different dependencies, lifecycle rules, and operational responsibilities.
Models: Keep Domain Types Plain
Models describe the things your business talks about. They can include identifiers, state, timestamps, and small methods that operate on their own data. They should not know how they are stored or transported.
Here is a simplified model package for a delivery dispatch system. It is intentionally independent from handlers, database clients, or messaging libraries.
package models
import (
"errors"
"time"
)
type VanID string
type RouteID string
type Van struct {
ID VanID
Seats int
Active bool
BusyUntil time.Time
}
type Route struct {
ID RouteID
RequiredSeats int
StartsAt time.Time
}
type Dispatch struct {
VanID VanID
RouteID RouteID
StartsAt time.Time
}
func (v Van) CanServe(route Route, now time.Time) error {
switch {
case !v.Active:
return errors.New("van is not active")
case v.Seats < route.RequiredSeats:
return errors.New("van capacity is too small")
case v.BusyUntil.After(now):
return errors.New("van is already assigned")
default:
return nil
}
}
This model knows how to answer a local domain question: can this van serve this route right now? It does not load itself from storage. It does not parse HTTP. It does not publish events. That restraint is what keeps the model reusable.
Ports: Define What the Core Needs
Ports are Go interfaces. They describe what the service layer needs from the outside world. The service does not ask for a PostgreSQL client, a broker client, or a specific framework type. It asks for behavior.
package ports
import (
"context"
"time"
"example.com/delivery/internal/models"
)
type VanStore interface {
FindVan(ctx context.Context, id models.VanID) (models.Van, error)
SaveVan(ctx context.Context, van models.Van) error
}
type RouteStore interface {
FindRoute(ctx context.Context, id models.RouteID) (models.Route, error)
}
type DispatchStore interface {
SaveDispatch(ctx context.Context, dispatch models.Dispatch) error
}
type Clock interface {
Now() time.Time
}
These interfaces are small on purpose. A broad interface such as DeliveryRepository with many unrelated methods makes tests harder and couples use cases together. Smaller ports let each service depend only on the behavior it actually uses.
A useful test for a port is this question: if the implementation changed tomorrow, would this interface still describe the business need? If the answer is yes, the port is probably stable. If the interface exposes query details, table names, transport status codes, or framework objects, it is leaking adapter concerns inward.
Services: Put Use Cases in One Clear Place
The service layer is where workflows live. It coordinates models, calls ports, handles errors, and enforces business rules. It should be easy to read because it represents the behavior the application exists to provide.
package service
import (
"context"
"example.com/delivery/internal/models"
"example.com/delivery/internal/ports"
)
type DispatchService struct {
vans ports.VanStore
routes ports.RouteStore
dispatches ports.DispatchStore
clock ports.Clock
}
func NewDispatchService(
vans ports.VanStore,
routes ports.RouteStore,
dispatches ports.DispatchStore,
clock ports.Clock,
) *DispatchService {
return &DispatchService{
vans: vans,
routes: routes,
dispatches: dispatches,
clock: clock,
}
}
func (s *DispatchService) AssignVan(ctx context.Context, vanID models.VanID, routeID models.RouteID) (models.Dispatch, error) {
van, err := s.vans.FindVan(ctx, vanID)
if err != nil {
return models.Dispatch{}, err
}
route, err := s.routes.FindRoute(ctx, routeID)
if err != nil {
return models.Dispatch{}, err
}
now := s.clock.Now()
if err := van.CanServe(route, now); err != nil {
return models.Dispatch{}, err
}
dispatch := models.Dispatch{
VanID: van.ID,
RouteID: route.ID,
StartsAt: route.StartsAt,
}
if err := s.dispatches.SaveDispatch(ctx, dispatch); err != nil {
return models.Dispatch{}, err
}
return dispatch, nil
}
Notice what is missing. There is no database connection. There is no HTTP request. There is no framework-specific context. There is no message broker client. The service only speaks in domain models and ports.
That gives you two practical benefits:
- You can test business behavior without starting infrastructure.
- You can replace infrastructure without changing the workflow.
Adapters: Keep Technical Work at the Edge
Adapters implement ports. They are allowed to know about databases, files, network protocols, external payloads, retry strategies, caching, and observability. Their job is to translate between the outside world and the inside model.
An in-memory adapter is useful for tests, demos, or local workflows. It implements the same port as a real database adapter.
package memoryadapter
import (
"context"
"errors"
"example.com/delivery/internal/models"
)
type VanStore struct {
vans map[models.VanID]models.Van
}
func NewVanStore(vans []models.Van) *VanStore {
index := make(map[models.VanID]models.Van, len(vans))
for _, van := range vans {
index[van.ID] = van
}
return &VanStore{vans: index}
}
func (s *VanStore) FindVan(ctx context.Context, id models.VanID) (models.Van, error) {
van, ok := s.vans[id]
if !ok {
return models.Van{}, errors.New("van not found")
}
return van, nil
}
func (s *VanStore) SaveVan(ctx context.Context, van models.Van) error {
s.vans[van.ID] = van
return nil
}
A production adapter can use a real persistence tool while keeping the same port. The service layer does not change because it still receives a ports.VanStore.
package dbadapter
import (
"context"
"database/sql"
"errors"
"example.com/delivery/internal/models"
)
type VanRepository struct {
db *sql.DB
queries VanQueries
}
type VanQueries struct {
LoadByID string
Save string
}
func NewVanRepository(db *sql.DB, queries VanQueries) *VanRepository {
return &VanRepository{db: db, queries: queries}
}
func (r *VanRepository) FindVan(ctx context.Context, id models.VanID) (models.Van, error) {
row := r.db.QueryRowContext(ctx, r.queries.LoadByID, string(id))
var van models.Van
err := row.Scan(&van.ID, &van.Seats, &van.Active, &van.BusyUntil)
if errors.Is(err, sql.ErrNoRows) {
return models.Van{}, errors.New("van not found")
}
if err != nil {
return models.Van{}, err
}
return van, nil
}
func (r *VanRepository) SaveVan(ctx context.Context, van models.Van) error {
_, err := r.db.ExecContext(ctx, r.queries.Save, van.ID, van.Seats, van.Active, van.BusyUntil)
return err
}
This adapter handles persistence concerns. It maps rows into domain models and returns errors. It does not decide whether a van is allowed to serve a route. That rule belongs in the model and service layers.
Adapters can also be composed. For example, you might wrap a database adapter with a caching adapter or a retrying adapter. As long as the wrapper implements the same port, the service remains unchanged.
DispatchService
|
v
ports.VanStore
|
v
CachingVanStore
|
v
RetryingVanStore
|
v
DatabaseVanRepository
The service sees one interface. The adapter chain handles technical strategy.
Wiring the Application in cmd
The cmd/* package is where concrete choices are made. This is the correct place to say, "for this executable, use this database adapter, this route store, this clock, and this HTTP handler."
package main
import (
"log"
"net/http"
"example.com/delivery/internal/adapters/dbadapter"
"example.com/delivery/internal/adapters/httpadapter"
"example.com/delivery/internal/service"
)
func main() {
cfg := mustLoadConfig()
db := mustOpenDatabase(cfg)
vanRepo := dbadapter.NewVanRepository(db, mustLoadVanQueries())
routeRepo := dbadapter.NewRouteRepository(db, mustLoadRouteQueries())
dispatchRepo := dbadapter.NewDispatchRepository(db, mustLoadDispatchQueries())
clock := systemClock{}
dispatcher := service.NewDispatchService(vanRepo, routeRepo, dispatchRepo, clock)
handler := httpadapter.NewDispatchHandler(dispatcher)
server := &http.Server{
Addr: cfg.HTTPAddress,
Handler: handler.Routes(),
}
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
The startup code knows about everything because it builds the application. That is acceptable. The danger starts when every package knows about everything. Keep the complete dependency graph in the composition root and keep the inner packages focused.
Adapter DTOs Must Not Become Domain Models
A Data Transfer Object, often shortened to DTO, is a shape used to move data across a boundary. HTTP request bodies, gRPC messages, event records, and database rows are all boundary shapes. They are not the same as domain models.
A common mistake is to place transport fields directly into core models because it saves mapping code. That shortcut becomes expensive. Once a domain type contains transport tags, external field names, framework annotations, or persistence-only fields, the business core begins to depend on external contracts.
Keep mapping code in adapters:
HTTP request payload
-> HTTP adapter mapper
-> models.Dispatch command or service arguments
-> service use case
-> models.Dispatch result
-> HTTP adapter mapper
-> HTTP response payload
Mapping feels boring, but it protects the most valuable part of the system. When the external contract changes, the adapter changes. The service does not.
Testing Hexagonal Layers
Testing becomes simpler because the architecture gives each layer a clear responsibility. You do not test every layer in the same way.
| Layer | Best test style | What to verify |
|---|---|---|
| Models | Fast unit tests | Local rules, validation, and value behavior |
| Services | Unit tests with fake ports | Workflows, decisions, error handling, dependency failures |
| Ports | Usually no direct tests | Contracts are exercised through service tests and adapter tests |
| Adapters | Integration tests when connected to real tools | Mapping, configuration, query behavior, schema compatibility, external failures |
The service layer deserves the most attention because it contains the main business workflow. It should be tested without real databases or message brokers. Use fakes that implement the same ports.
package service_test
import (
"context"
"testing"
"time"
"example.com/delivery/internal/adapters/memoryadapter"
"example.com/delivery/internal/models"
"example.com/delivery/internal/service"
)
type fixedClock struct {
value time.Time
}
func (c fixedClock) Now() time.Time {
return c.value
}
func TestAssignVanRejectsInactiveVehicle(t *testing.T) {
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
vans := memoryadapter.NewVanStore([]models.Van{
{ID: "van-17", Seats: 8, Active: false},
})
routes := memoryadapter.NewRouteStore([]models.Route{
{ID: "route-5", RequiredSeats: 4, StartsAt: now.Add(time.Hour)},
})
dispatches := memoryadapter.NewDispatchStore()
svc := service.NewDispatchService(vans, routes, dispatches, fixedClock{value: now})
_, err := svc.AssignVan(context.Background(), "van-17", "route-5")
if err == nil {
t.Fatal("expected inactive van to be rejected")
}
}
A test like this runs quickly because it does not need infrastructure. It verifies behavior: an inactive van cannot be assigned. That is the kind of confidence you want around business logic.
Adapter tests are different. A database adapter cannot be fully trusted until it talks to the actual database shape it expects. Those tests are slower and fewer. That is fine. Their job is to catch integration problems, not to retest every business rule.
go test ./internal/models ./internal/service
go test ./internal/adapters/...
In many teams, the first command runs constantly during development. The second command may run in a dedicated integration stage because adapters are closer to external systems.
A Migration Example: Search Without Rewriting the Core
One strong reason to use ports is migration safety. Suppose a service starts with one search engine and later moves to another because the new tool fits scaling and analytics needs better.
Without a port, search calls might be scattered through handlers, services, and projection builders. Every call site knows the old client library. Every query shape leaks into business code. Migration becomes a broad rewrite.
With a port, the core asks for search behavior:
package ports
import (
"context"
"example.com/delivery/internal/models"
)
type RouteSearch interface {
SearchAvailableRoutes(ctx context.Context, input RouteSearchInput) ([]models.Route, error)
}
type RouteSearchInput struct {
City string
MinimumVanSeats int
}
One adapter can use the original search tool. Another adapter can use the replacement. During migration, a feature toggle can choose which adapter is active at startup. Branch by Abstraction means both implementations can exist behind the same interface while the team migrates safely.
ports.RouteSearch
|-- OldSearchAdapter
`-- NewSearchAdapter
Feature toggle chooses one adapter during startup.
Service code stays the same.
The important part is not the specific search technology. The important part is that the business service never became dependent on one vendor or one client library.
Query-Centered Design Is a Trap
A database is a servant of the application, not the architect of the application. Problems start when query shapes become the organizing principle of the system.
Watch for these warning signs:
- Business rules are implemented inside query strings instead of services or models.
- Handlers build persistence statements directly.
- Domain models contain fields only required by one storage tool.
- Projection builders, service methods, and domain methods all know persistence details.
- Changing storage would require edits across many unrelated packages.
This is dependency spread. It is worse than one obvious dependency because it hides in many places. Hexagonal Architecture does not mean the database is unimportant. It means database details stay behind adapters so the rest of the system can evolve.
Common Mistakes to Avoid
Putting interfaces in the wrong place
In Go, interfaces are often most useful near the consumer. The service should define what behavior it needs through ports. Do not create large interfaces just because a concrete adapter has many methods.
Letting DTOs leak into services
If the service receives HTTP payload structs or generated transport messages, the boundary has already leaked. Convert at the adapter edge.
Creating a generic utilities package with hidden dependencies
A util package can become a dumping ground. If a helper imports framework code, a database driver, or a messaging client, it is not generic. Move it to an adapter package.
Starting infrastructure for service tests
Service tests should use fakes or in-memory adapters. If every service test needs a database, the service is too coupled or the tests are too heavy.
Treating adapters as a place for business logic
Retries, caching, circuit breakers, and mapping are adapter concerns. Pricing rules, assignment rules, validation rules, and workflow decisions are not.
Sharing one main function across unrelated processes
A web server and a worker have different dependencies and shutdown behavior. Let each cmd/* runner wire its own graph.
Step-by-Step Workflow for Applying the Pattern
Use this workflow when adding a new feature to a Go service.
-
Name the use case in business language. Example: assign a van to a route.
-
Define or update the model types. Keep them free of transport and persistence details.
-
Write the service method signature. Use domain identifiers and ordinary Go types, not adapter payloads.
-
List the external behaviors the service needs. These become ports, such as
VanStore,RouteStore,DispatchStore, orClock. -
Implement the service using only models and ports. No direct database calls, no handler types, no broker clients.
-
Add fake or in-memory adapters for service tests. Test the workflow, success path, and dependency failure cases.
-
Add production adapters. Keep mapping and client code at the edge.
-
Wire everything in the relevant
cmd/*package. Each executable chooses the adapters it needs. -
Add integration tests for adapters. Keep these separate from fast service tests when they need external tools.
-
Review imports. The service package should not import adapter packages.
Checklist
Before you call a Go architecture hexagonal, check these points:
- Models do not import adapters, frameworks, or client libraries.
- Services depend on ports and models, not concrete infrastructure.
- Ports describe behavior needed by the core, not implementation details.
- Adapters implement ports and translate external data into domain types.
- DTOs live at the boundary and are mapped before reaching services.
- Each
cmd/*runner wires its own dependencies. - Service tests use fakes or in-memory adapters.
- Adapter tests verify real integration behavior separately.
- Technical policies such as caching and retries are hidden behind ports.
- A database, protocol, or framework can be replaced by writing a new adapter.
Conclusion
Hexagonal Architecture in Go is not about making a project look sophisticated. It is about protecting the part of the system that should stay meaningful when tools change.
Models describe the domain. Ports define what the core needs. Services make business decisions. Adapters handle the outside world. The cmd/* packages assemble the real application.
When these boundaries are clear, the team gains practical advantages: faster service tests, safer migrations, cleaner code reviews, and less fear when replacing technology. The system becomes easier to change because the business rules are not chained to the database, the transport layer, or any single framework.
Good architecture is measured by what it lets you change without panic. Hexagonal Architecture gives Go teams that freedom.