Hexagonal Architecture gives strong guarantees: business logic with no dependencies on chi, pgx, or kafka-go; port interfaces that isolate the infrastructure; a CI test that won't let a pgx import into the core. But these guarantees come at a cost — extra packages, mapper structs between layers, and a separate test for imports. The cost pays off only under certain conditions.
Three levels of Go service complexity
Not every service needs the same structure. In Go projects, three maturity levels are usually distinguished:
Level 1 — the UseCase, Handler, and chi router live in a single internal/<bc>/ package. There are no explicit aggregates, no separate port interfaces. Suitable for CRUD services, prototypes, and first iterations.
Level 2 — internal/<bc>/ is split into separate aggregate/, usecase/, and repository/ packages. The domain is carved out, but there are no separate adapter/in/ and adapter/out/ packages; the infrastructure sits alongside the domain.
Level 3 — Hexagonal. The internal/core/<bc>/, internal/adapter/in/*, internal/adapter/out/*, and bootstrap/ packages. An architecture test in CI. This article is precisely about this level.
Converting a Level 1 service to Hexagonal means adding 4–5 packages, 3–4 mapper structs, and a CI test for the sake of something that is already held together by a verbal agreement. The costs will outweigh the benefit.
When Hexagonal pays off
Here are five signals under which the structure starts to deliver a measurable payoff:
1. The service integrates with two or more external systems. A typical setup is Postgres (sqlc + pgx) plus one or two more external services plus Kafka. Each system has its own data types, its own timeouts, its own failure modes. Without explicit adapter/out/sber/, adapter/out/odna_kassa/ packages, all the IO code accumulates in a single OrderService that becomes hard to test and read.
2. A rich aggregate with business rules. When the Order.Confirm(payment PaymentResult) error method checks five conditions, updates the status, and records the result — you want to test this code without Postgres. A clean core/order/aggregate/ with no dependencies on pgx lets you run the aggregate's unit tests in milliseconds:
// core/order/aggregate/order_test.go
func TestConfirm_InsufficientPayment(t *testing.T) {
order := newPendingOrder(Money{Amount: 5000, Currency: "RUB"})
result := PaymentResult{Amount: Money{Amount: 3000, Currency: "RUB"}}
err := order.Confirm(result)
var e *InsufficientPaymentError
require.ErrorAs(t, err, &e)
}
3. Several entry types for a single domain. A chi router for the client, a chi router for the administrator, a Kafka consumer for events from another service. Each entry type is its own package with its own middleware stack:
// adapter/in/http/user/router.go — client JWT authentication
// adapter/in/http/admin/router.go — mTLS for the internal API
// adapter/in/kafka/order_events.go — consumer without HTTP authentication
Without separation, the authentication middleware starts to overlap.
4. Testing the core requires spinning up a real Postgres. If a handler in core/ directly imports adapter/out/persistence/ or sqlc-generated types, the test drags along the entire pgx stack. Hexagonal with an OrderRepository port interface in core/ lets you substitute the adapter with an in-memory implementation.
5. A team of three or more developers. The architectural boundaries become a shared agreement. The TestCoreHasNoFrameworkImports test will catch the situation "a new colleague added a chi dependency to core/" when code review misses it. On a team of one or two people, a verbal agreement works without an automated test.
If at least three of the five points match — Hexagonal pays off.
When Hexagonal adds extra work
One Postgres, no external systems. If the external world is just a pgx pool, separate adapter/ packages aren't needed. An OrderRepository interface in internal/order/ with an sqlc-based implementation already gives the domain ↔ persistence boundary you need.
One or two developers, a small codebase. The package convention holds up verbally; an architecture test is extra work when the whole core is three files.
The business logic hasn't settled yet. When the aggregate's structure changes every two weeks (the startup phase), mapper structs between layers slow down iterations. Every "let's try it differently" means rewriting OrderRequestMapper, PaymentMapper, and the OrderRepository interface. First you need to find a stable shape, then move to Hexagonal.
No aggregates with business rules. If the domain is a transactions table that is simply read from and written to, Hexagonal protects nothing. An empty Order with all the logic in OrderService, wrapped in a hex shell, is the most expensive variant of this approach.
Two common mistakes
Hexagonal everywhere by default. Sometimes an entire team converts all Go services to a hex layout regardless of complexity. A service of four endpoints with a single Postgres in this structure:
internal/
core/order/{aggregate,port,usecase}/
adapter/in/http/
adapter/out/persistence/
bootstrap/
— that's five packages, four mapper structs, a CI import test — for what? The decision about structure is made per service: a reference-data service — Level 1, an order service with several external systems and a rich domain — Level 3. One team, different levels.
Partial Hexagonal. core/order/ exists, but the handler in adapter/in/http/ injects persistence.OrderRepository directly:
// Partial hex — worse than an honest flat internal/
type OrderHandler struct {
repo *persistence.OrderRepository // the in-adapter sees the out-adapter directly
sber *sber.PaymentAdapter
}
Why this is worse than a monolith:
- Reading the service, a developer thinks every time: "is the boundary respected here or not." That's harder than an honest flat
internal/. - "We'll finish the hex someday" turns into a multi-week refactoring with no visible result for the business, which never fits into the plan.
The rule: either full Hexagonal — the core/, adapter/in/*, adapter/out/*, bootstrap/ packages and an architecture test in CI — or a flat internal/<bc>/. An intermediate state is acceptable only as a short transitional period with an explicit deadline.
In short
- Hexagonal in Go is the third maturity level. Levels 1–2 are simpler and cheaper.
- It's worth switching when at least three of five signs match: 2+ external systems, a rich aggregate with business rules, several entry types, the core test requires Postgres, a team of 3+ people.
- Don't switch: one Postgres, 1–2 developers, the logic is still changing, no real business rules in the aggregate.
- Hexagonal everywhere by default is an antipattern: one service — one deliberate decision.
- Partial Hexagonal (core exists, but the adapters see each other) is worse than an honest flat
internal/. Either the full structure or the flat one.
What to read next
- Package structure — what exactly we build once we've decided to switch.
- The core layer — which stdlib packages are acceptable in
core/, what a rich aggregate looks like. - Ports — an
interfaceincore/<bc>/port/out/, errors as values. - Adapters in — the chi handler maps a request DTO into a command and passes it to the UseCase.
- Adapters out — a compile-time assertion, a mapper for domain ↔ external types.
- Bootstrap / Composition root — the single place where dependencies are assembled.
- Architecture tests —
packages.Load+ a check for forbidden imports.