Most Go backend projects start the same way. A main.go, a router, a database connection, and then a slow drift into a structure that technically works but becomes painful to extend after the third or fourth feature. I've been there enough times that I finally sat down, extracted the architecture from a production project I was happy with, and published it as an open-source boilerplate.
This post explains the architecture, why Domain-Driven Design (DDD) fits Go surprisingly well, and how the boilerplate is structured so you can hit the ground running on your next project.
GitHub: https://github.com/princerafid01/go-rest-boilerplate
Why DDD in Go?
Domain-Driven Design gets a bad reputation for being a Java/C# thing — verbose, ceremonial, and overkill for most projects. That reputation is mostly deserved for the full tactical DDD toolkit (aggregates, value objects, domain events). But the core idea of DDD is simple and language-agnostic:
The domain model is the center of your application. Everything else — HTTP, SQL, auth — is infrastructure that serves the domain.
Go is actually a great fit for this style because:
- Interfaces are implicit and lightweight, so defining boundaries between layers costs almost nothing
- The package system naturally enforces separation — packages can't have circular imports
- Go's preference for small, focused types maps cleanly to domain entities and service objects
The result is a codebase where your business logic is completely isolated from delivery mechanisms (HTTP, gRPC) and persistence (Postgres, any other DB). You can swap either without touching your domain.
The Architecture at a Glance
HTTP Request
│
▼
Middleware Chain (Preflight → CORS → Logger)
│
▼
Route-Specific Middleware (JWT Auth → injects userID into context)
│
▼
Handler ← parses HTTP request, calls service
│
▼
Service ← business rules, depends only on interfaces (the domain layer)
│
▼
Repo ← SQL implementation, satisfies the repo interface
│
▼
PostgreSQL
The key rule: each layer only depends on the layer below it through an interface. The only file that imports concrete implementations across layers is cmd/serve.go — the application wiring root. Everything else is interface-to-interface.
Project Structure
.
├── main.go
├── cmd/serve.go # Application layer — wires all dependencies
├── config/config.go # Env-based config
├── domain/ # Pure domain entities — no DB, no HTTP
│ ├── user.go
│ └── product.go
├── {feature}/ # Bounded context per domain feature
│ ├── port.go # Service interface + Repo interface
│ └── service.go # Business logic implementation
├── repo/ # Infrastructure — sqlx SQL implementations
│ └── {feature}.go
├── rest/ # Delivery mechanism — HTTP
│ ├── server.go
│ ├── middlewares/
│ └── handlers/{feature}/
│ ├── port.go # What the handler needs from the service
│ ├── handler.go
│ ├── dto.go # Request/response types
│ ├── routes.go
│ └── *.go # One file per action
├── utils/ # Shared helpers (JWT, responses, pagination)
└── migrations/ # Up/down SQL pairs
Let's walk through what each layer actually does.
The Domain Layer
The domain/ package is the heart of the application. It contains plain Go structs that represent your business entities — nothing else.
// domain/user.go
type User struct {
ID int64
Email string
PasswordHash *string
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
No HTTP types. No database tags that bleed framework concerns into your model. No third-party imports. If you need to change your database driver tomorrow, this file doesn't change. That's the point.
The Feature (Bounded Context) Layer
Each domain feature gets its own package. For example, a product feature would have:
product/port.go — defines the contracts this feature exposes and depends on:
// What this feature exposes to its consumers
type Service interface {
productHandler.Service // embeds the handler's Service interface
}
// What this feature needs from the data layer
type ProductRepo interface {
Create(domain.Product) (*domain.Product, error)
Get(id, userID int64) (*domain.Product, error)
List(userID, page, limit int64) ([]*domain.Product, error)
Count(userID int64) (int64, error)
Update(domain.Product) (*domain.Product, error)
Delete(id, userID int64) error
}
The Service interface embeds productHandler.Service — this is a deliberate design decision. The handler defines what it needs from the service layer, and the feature-level Service interface must satisfy that contract. If you add a method to the handler's interface, the Go compiler immediately tells you to implement it everywhere that needs to. No runtime surprises.
product/service.go — implements business logic:
type service struct{ repo ProductRepo }
func NewService(repo ProductRepo) Service {
return &service{repo: repo}
}
func (s *service) Create(p domain.Product) (*domain.Product, error) {
// Business rules go here before delegating to repo
return s.repo.Create(p)
}
The service only knows about domain types and its repo interface. It has no idea whether it's being called from an HTTP handler, a CLI command, or a test.
The Infrastructure Layer (Repo)
The repo/ package is pure infrastructure. It satisfies the repo interfaces defined in the feature layer using sqlx and raw SQL:
func (r *productRepo) Create(p domain.Product) (*domain.Product, error) {
query := `
INSERT INTO products (user_id, name, price)
VALUES (:user_id, :name, :price)
RETURNING id, created_at, updated_at
`
rows, err := r.db.NamedQuery(query, p)
if err != nil {
return nil, err
}
if rows.Next() {
rows.Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
return &p, nil
}
No ORM, no magic. Just SQL. The repo knows about the database, but the domain and service layers don't.
The Delivery Layer (HTTP Handlers)
Handlers are thin. Their only job is to parse the HTTP request, call the service, and write the HTTP response. They don't contain business logic.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(int64)
if !ok {
utils.SendError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.SendError(w, http.StatusBadRequest, "Invalid request body")
return
}
result, err := h.svc.Create(domain.Product{
UserID: userID,
Name: req.Name,
Price: req.Price,
})
if err != nil {
utils.SendError(w, http.StatusInternalServerError, "Internal server error")
return
}
utils.SendData(w, http.StatusCreated, result)
}
Each handler action gets its own file (create.go, get.go, list.go, etc.). This keeps files small and makes navigation obvious.
The Middleware System
The boilerplate uses a small custom Manager with two levels of middleware:
Global — applied to every request, registered in server.go:
manager.Use(middleware.Preflight, middleware.Cors, middleware.Logger)
Per-route — applied to specific routes using manager.With(...):
mux.Handle("POST /api/products", manager.With(
http.HandlerFunc(h.Create),
h.middlewares.AuthenticateJWT,
))
The JWT middleware validates the HS256 signature, decodes the payload, and injects the userID into the request context using a typed context key — avoiding the string key collision problem that plagues many Go middleware implementations.
The Application Layer: cmd/serve.go
This is where all the wiring happens. It's the only place in the codebase where concrete types meet:
func Serve() {
cnf := config.GetConfig()
dbCon, _ := db.NewConnection(cnf.DB)
db.MigrateDB(dbCon, "./migrations")
// Infrastructure
productRepo := repo.NewProductRepo(dbCon)
// Domain services
productSvc := product.NewService(productRepo)
// Delivery
middlewares := middleware.NewMiddlewares(cnf)
prodHandler := productHandler.NewHandler(middlewares, productSvc)
// Server
rest.NewServer(cnf, prodHandler).Start()
}
Everything flows top-down. Dependencies are explicit. There's no magic injection framework — just function calls. If something breaks, you can trace it by reading this file alone.
Adding a New Feature in 7 Steps
This is where the architecture pays for itself. To add a new Order resource:
-
Add
domain/order.go— the entity struct -
Add
rest/handlers/order/port.go— the Service interface the handler needs -
Add
order/port.go— embeds the handler's Service interface, defines OrderRepo -
Add
order/service.go— implements the business logic -
Add
repo/order.go— implements OrderRepo with SQL -
Add
rest/handlers/order/— handler, dto, routes, one file per action -
Wire it in
cmd/serve.go— create repo → service → handler → register routes
Every step is additive. You never modify existing files to add a new feature. That's the DDD principle of bounded contexts in practice.
The Stack
| Concern | Choice |
|---|---|
| HTTP |
net/http (stdlib) |
| Database |
sqlx + lib/pq
|
| Migrations | rubenv/sql-migrate |
| Auth | Hand-rolled HS256 JWT |
| Config |
godotenv + env vars |
| Passwords | golang.org/x/crypto/bcrypt |
No heavy framework. The stdlib HTTP mux has supported path parameters since Go 1.22, which covers the routing needs of most REST APIs without pulling in gorilla/mux or chi.
Getting Started
git clone https://github.com/princerafid01/go-rest-boilerplate my-project
cd my-project
# Rename the module
find . -type f -name "*.go" | xargs sed -i 's/boilerplate/my-project/g'
go mod edit -module my-project
# Configure environment
cp .env.example .env
# Edit .env with your DB credentials and JWT secret
go mod tidy
go run main.go
Migrations run automatically on startup. The example feature included in the repo gives you a working reference for the full CRUD pattern while you build out your own features.
Final Thoughts
DDD in Go doesn't have to mean heavyweight ceremony. At its core it's just one rule: keep your domain clean, and treat everything else as infrastructure. Go's interface system and package model make this remarkably natural to implement.
If you've been looking for a Go backend structure that scales past the first few features without becoming a mess — give this a try. And if you have ideas for improvements, PRs are very welcome.
Top comments (0)