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.