DEV Community

Mahmud Hasan Rafid
Mahmud Hasan Rafid

Posted on

Building a Domain-Driven Go REST API (And Why It Scales Better Than Flat Structure)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Per-route — applied to specific routes using manager.With(...):

mux.Handle("POST /api/products", manager.With(
    http.HandlerFunc(h.Create),
    h.middlewares.AuthenticateJWT,
))
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add domain/order.go — the entity struct
  2. Add rest/handlers/order/port.go — the Service interface the handler needs
  3. Add order/port.go — embeds the handler's Service interface, defines OrderRepo
  4. Add order/service.go — implements the business logic
  5. Add repo/order.go — implements OrderRepo with SQL
  6. Add rest/handlers/order/ — handler, dto, routes, one file per action
  7. 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
Enter fullscreen mode Exit fullscreen mode

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.

https://github.com/princerafid01/go-rest-boilerplate

Top comments (0)