Go: DDD + Hexagonal Architecture
A practical Go architecture that combines Domain-Driven Design (DDD) with Hexagonal Architecture, while keeping a clean path toward microservices.
Folder-separated where it improves ownership, but not split so far that every file gets its own directory.
Project layout
project/
ā
āāā cmd/
ā āāā api/
ā āāā main.go
ā
āāā internal/
ā ā
ā āāā inbound/
ā ā ā
ā ā āāā http/
ā ā ā āāā router.go
ā ā ā ā
ā ā ā āāā middleware/
ā ā ā ā āāā auth.go
ā ā ā ā āāā cors.go
ā ā ā ā āāā logging.go
ā ā ā ā āāā recovery.go
ā ā ā ā āāā request_id.go
ā ā ā ā
ā ā ā āāā handler/
ā ā ā āāā user_handler.go
ā ā ā āāā product_handler.go
ā ā ā āāā order_handler.go
ā ā ā
ā ā āāā grpc/
ā ā āāā server.go
ā ā ā
ā ā āāā middleware/
ā ā ā āāā auth.go
ā ā ā āāā logging.go
ā ā ā āāā recovery.go
ā ā ā
ā ā āāā handler/
ā ā āāā user_handler.go
ā ā āāā product_handler.go
ā ā āāā order_handler.go
ā ā
ā āāā domain/
ā ā ā
ā ā āāā user/
ā ā ā āāā model.go
ā ā ā āāā repository.go
ā ā ā āāā service.go
ā ā ā
ā ā āāā product/
ā ā ā āāā model.go
ā ā ā āāā repository.go
ā ā ā āāā service.go
ā ā ā
ā ā āāā order/
ā ā āāā model.go
ā ā āāā repository.go
ā ā āāā service.go
ā ā
ā āāā outbound/
ā ā
ā āāā db/
ā ā āāā postgres.go
ā ā
ā āāā cache/
ā ā āāā redis.go
ā ā
ā āāā repository/
ā ā āāā user_repository.go
ā ā āāā product_repository.go
ā ā āāā order_repository.go
ā ā
ā āāā external/
ā āāā payment.go
ā āāā email.go
ā
āāā migrations/
ā āāā 001_create_users.sql
ā āāā 002_create_products.sql
ā āāā 003_create_orders.sql
ā
āāā config/
ā āāā config.go
ā
āāā go.mod
āāā go.sumArchitecture diagram
āāāāāāāāāāāāāāāāāāāāāāāā
ā INBOUND ā
ā ā
ā HTTP / gRPC ā
ā Handlers ā
ā Middleware ā
āāāāāāāāāāāā¬āāāāāāāāāāāā
ā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāā
ā DOMAIN ā
ā ā
ā user ā
ā product ā
ā order ā
ā ā
ā Models ā
ā Services ā
ā Repository Ports ā
āāāāāāāāāāāā¬āāāāāāāāāāāā
ā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāā
ā OUTBOUND ā
ā ā
ā PostgreSQL ā
ā Redis ā
ā External APIs ā
ā Repository Adapters ā
āāāāāāāāāāāāāāāāāāāāāāāāLayer responsibilities
inbound/
How requests enter the application.
domain/
What the business does.
outbound/
How the application talks to external systems.The domain defines the port:
domain/user/repository.goOutbound provides the adapter:
outbound/repository/user_repository.goUser Repository Interface
ā
ā port
ā¼
PostgreSQL Repository
ā
ā adapter
ā¼
PostgreSQLThe domain does not depend on HTTP, PostgreSQL, Redis, or any external provider. Dependency direction always points inward.
What goes in each file
cmd/api/main.go
Wire dependencies only: load config, open DB, construct repositories, services, handlers, start the server. No business logic.
internal/inbound/
| Path | Responsibility |
|---|---|
http/router.go |
Route registration, global middleware |
http/middleware/ |
Cross-cutting HTTP concerns (auth, logging, recovery) |
http/handler/ |
Parse request, call domain service, write response |
grpc/ |
Same split for gRPC if the service exposes both |
Handlers are thin. They validate input shape, call the domain service, map errors to status codes.
internal/domain/{entity}/
| File | Responsibility |
|---|---|
model.go |
Entity structs, value objects, domain errors, status constants |
repository.go |
Interface (port) for persistence. No implementation here |
service.go |
Business rules, orchestration, transactions at domain level |
// domain/user/repository.go
type Repository interface {
GetByID(ctx context.Context, id uuid.UUID) (*User, error)
Create(ctx context.Context, user *User) error
}
// domain/user/service.go
type Service struct {
repo Repository
}
func (s *Service) Register(ctx context.Context, email string) (*User, error) {
// business rules live here
}internal/outbound/
| Path | Responsibility |
|---|---|
db/postgres.go |
Connection pool, health check |
cache/redis.go |
Cache client setup |
repository/ |
Implements domain repository interfaces |
external/ |
Third-party API clients (Stripe, email, etc.) |
// outbound/repository/user_repository.go
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*user.User, error) {
// SQL here; maps rows to domain/user.User
}config/
Environment loading, validation, defaults. Fail fast on missing required vars at startup.
migrations/
Numbered SQL files. See Naming Conventions for migration rules.
Dependency rules
- Domain imports nothing from
inbound/oroutbound/. - Inbound imports domain only (plus framework: Fiber, gRPC).
- Outbound imports domain to implement interfaces and return domain types.
mainimports everything and passes concrete adapters into constructors.
inbound āāāŗ domain āāā outbound
ā²
ā
cmdMicroservice extraction
Because each domain is already isolated, a domain can later become an independent service.
Extracting user:
user-service/
ā
āāā cmd/
ā āāā api/
ā āāā main.go
ā
āāā internal/
ā ā
ā āāā inbound/
ā ā āāā http/
ā ā ā āāā router.go
ā ā ā āāā middleware/
ā ā ā ā āāā auth.go
ā ā ā ā āāā cors.go
ā ā ā ā āāā logging.go
ā ā ā ā āāā recovery.go
ā ā ā ā āāā request_id.go
ā ā ā āāā handler/
ā ā ā āāā user_handler.go
ā ā ā
ā ā āāā grpc/
ā ā āāā server.go
ā ā āāā middleware/
ā ā ā āāā auth.go
ā ā ā āāā logging.go
ā ā ā āāā recovery.go
ā ā āāā handler/
ā ā āāā user_handler.go
ā ā
ā āāā domain/
ā ā āāā user/
ā ā āāā model.go
ā ā āāā repository.go
ā ā āāā service.go
ā ā
ā āāā outbound/
ā āāā db/
ā ā āāā postgres.go
ā āāā cache/
ā ā āāā redis.go
ā āāā repository/
ā ā āāā user_repository.go
ā āāā external/
ā āāā email.go
ā
āāā migrations/
ā āāā 001_create_users.sql
ā
āāā config/
ā āāā config.go
ā
āāā Dockerfile
āāā go.mod
āāā go.sumThe same pattern applies to product-service/ and order-service/. The architecture stays the same. Only the deployment boundary changes.
MODULAR MONOLITH
āāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā API ā
ā ā
ā User ā Product ā Order ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
MICROSERVICES
āāāāāāāāāāāā āāāāāāāāāāāā āāāāāāāāāāāā
ā User ā ā Product ā ā Order ā
ā Service ā ā Service ā ā Service ā
āāāāāāāāāāāā āāāāāāāāāāāā āāāāāāāāāāāāMicroservice extraction becomes an incremental engineering operation, not a full rewrite.
When to extract a service
Extract a domain to its own deployable only when there is a concrete reason:
- Independent scaling (CPU-heavy workload)
- Separate release cadence or team ownership
- Different SLAs or data residency
- Fault isolation that justifies the operational cost
Until then, keep domains in one repo with clear package boundaries. Premature splitting adds network latency, distributed transactions, and deployment complexity without benefit.
Code2b alignment
New Go services at Code2b should start with this layout. Existing services (enrichabl microservices, docvalidation, vistalink) may use older folder names (handler, usecase, repository) but follow the same dependency direction: handlers call business logic, business logic depends on interfaces, SQL and HTTP clients sit at the edges.
When adding a feature to an existing service, extend the matching domain folder first. Add a port in domain/, implement the adapter in outbound/, expose it through inbound/. Do not skip straight from handler to SQL.