REST API with Clean Architecture in Go
/ 9 min read
Table of Contents
I’ll be honest—my first REST API in Go was a disaster. Everything lived in main.go: database queries, business logic, HTTP handlers, all tangled together in a 1,500-line monstrosity. Adding a feature meant scrolling through endless code, hoping I wouldn’t break something unrelated.
Then I discovered Clean Architecture, and it changed everything. This is the story of how I rebuilt my inventory management API with proper separation of concerns, making it actually maintainable. No fluff, no theory-only content—just the real lessons from building BMG-Go-Backend.
The Problem: Why I Needed Better Architecture
Picture this: you need to add OAuth login to your API. In my original spaghetti code, I’d have to:
- Hunt through
main.gofor where users are created - Hope the password hashing wasn’t hardcoded somewhere random
- Add OAuth logic… where? Next to the database code? In a new file? Who knows!
- Cross your fingers that you didn’t break existing email/password login
Sound familiar? That pain drove me to redesign everything with layers.
The Solution: Clean Architecture in Go
Here’s the key insight that clicked for me: separate what something does from how it does it.
Instead of:
// ❌ BAD: Everything mixed togetherfunc CreateUser(w http.ResponseWriter, r *http.Request) { var user User json.NewDecoder(r.Body).Decode(&user) // HTTP stuff
if user.Email == "" { // Validation http.Error(w, "bad email", 400) return }
db.Exec("INSERT INTO users...") // Database stuff
json.NewEncoder(w).Encode(user) // More HTTP stuff}We do this:
// ✅ GOOD: Each layer does ONE thing// Handler: HTTP concerns onlyfunc (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { var dto CreateUserDTO parseJSON(r.Body, &dto)
user, err := h.userService.Create(ctx, dto) writeJSON(w, 201, user)}
// Service: Business logic onlyfunc (s *UserService) Create(ctx context.Context, dto CreateUserDTO) (*User, error) { if err := validateEmail(dto.Email); err != nil { return nil, err } return s.repo.Create(ctx, userFromDTO(dto))}
// Repository: Database onlyfunc (r *UserRepo) Create(ctx context.Context, user *User) error { _, err := r.db.Exec("INSERT INTO users...", user.Email, user.Name) return err}Now, when PM asks for OAuth? I just add a new method in UserService. The handler and repository don’t even know it happened. Beautiful.
The Architecture: A Tour Through the Layers
Let me walk you through how requests flow through BMG. I’ll use a real example: creating an inventory item.
Layer 1: Handler (The HTTP Bouncer)
Job: Convert HTTP requests into something the business logic can understand.
func (app *application) createItemHandler(w http.ResponseWriter, r *http.Request) { // 1. Parse JSON from HTTP request var input CreateItemDTO err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return }
// 2. Ask the service to do the work item, err := app.itemService.Create(r.Context(), input) if err != nil { app.serverErrorResponse(w, r, err) return }
// 3. Convert back to JSON and send HTTP response app.writeJSON(w, http.StatusCreated, item, nil)}Notice what’s NOT here:
- ❌ No business rules (“quantity can’t be negative”)
- ❌ No SQL queries
- ❌ No password hashing or complex logic
Just: receive HTTP → call service → return HTTP. That’s it.
Layer 2: Service (The Brain)
Job: Enforce business rules, orchestrate complex operations.
func (s *ItemService) Create(ctx context.Context, dto CreateItemDTO) (*Item, error) { // Business rule: can't create items with negative quantity if dto.Quantity < 0 { return nil, ErrInvalidQuantity }
// Business rule: prices must be positive if dto.Price <= 0 { return nil, ErrInvalidPrice }
// Transform DTO into domain model item := &Item{ Name: dto.Name, Description: dto.Description, Quantity: dto.Quantity, Price: dto.Price, CreatedAt: time.Now(), }
// Ask repository to save it return s.repo.Create(ctx, item)}This layer knows what should happen, but not how it happens. It doesn’t care if we’re using PostgreSQL, MongoDB, or a text file—that’s the repository’s problem.
Layer 3: Repository (The Database Whisperer)
Job: Talk to the database. Nothing else.
func (r *ItemRepository) Create(ctx context.Context, item *Item) (*Item, error) { query := ` INSERT INTO items (name, description, quantity, price, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at `
err := r.db.QueryRowContext(ctx, query, item.Name, item.Description, item.Quantity, item.Price, item.CreatedAt, ).Scan(&item.ID, &item.CreatedAt)
return item, err}All the SQL lives here. If we switch from PostgreSQL to MySQL tomorrow, we only change this file. The service and handler don’t even know we use a database.
The Secret Sauce: DTOs (Data Transfer Objects)
Here’s something that confused me for months: why not just use domain models everywhere?
Bad idea. Here’s why:
// Domain model: internal representationtype User struct { ID string Email string PasswordHash string // ⚠️ We DO NOT want this in API responses! CreatedAt time.Time LastLoginAt *time.Time}
// DTO: what we actually send over the wiretype UserResponseDTO struct { ID string `json:"id"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` // Notice: no password hash!}DTOs give you:
- Security: Don’t accidentally leak sensitive fields
- Flexibility: API shape doesn’t force database schema
- Versioning: Support multiple API versions easily
I learned this the hard way when I accidentally returned password hashes in /users endpoint. Good times.
Middleware: The Pipeline Pattern
Middleware in Go is elegant. Each request passes through a chain of functions before hitting your handler:
Request → Logger → CORS → RateLimit → Auth → Handler → ResponseHere’s how simple it is with Chi router:
router := chi.NewRouter()
// Apply to ALL routesrouter.Use(middleware.Logger)router.Use(middleware.CORS)router.Use(middleware.RateLimiter)
// Protected routes onlyrouter.Group(func(r chi.Router) { r.Use(middleware.Auth) // JWT validation
r.Post("/items", app.createItemHandler) r.Put("/items/{id}", app.updateItemHandler)})
// Public routes (no auth needed)router.Get("/healthcheck", app.healthcheckHandler)The Middleware I Wish I’d Built Earlier
1. Request Logger
func Logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start)) })}Seeing “POST /items 245ms” in logs saved my bacon when debugging slow requests.
2. Rate Limiter
func RateLimiter(next http.Handler) http.Handler { limiter := rate.NewLimiter(10, 20) // 10 req/s, burst of 20
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if (!limiter.Allow()) { http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) })}Prevented a junior dev’s infinite loop from killing the server. True story.
Error Handling: The Go Way
Go’s error handling gets mocked, but I’ve grown to love it. Here’s my approach:
// Domain errors (business rules violated)var ( ErrInvalidQuantity = errors.New("quantity cannot be negative") ErrInvalidPrice = errors.New("price must be positive") ErrNotFound = errors.New("item not found"))
// Service layerfunc (s *ItemService) Create(ctx context.Context, dto CreateItemDTO) (*Item, error) { if dto.Quantity < 0 { return nil, ErrInvalidQuantity // Business rule }
item, err := s.repo.Create(ctx, item) if err != nil { return nil, fmt.Errorf("create item: %w", err) // Wrap for context }
return item, nil}
// Handler layerfunc (app *application) createItemHandler(w http.ResponseWriter, r *http.Request) { item, err := app.itemService.Create(r.Context(), input) if err != nil { switch { case errors.Is(err, ErrInvalidQuantity): app.badRequestResponse(w, r, err) // 400 case errors.Is(err, ErrNotFound): app.notFoundResponse(w, r) // 404 default: app.serverErrorResponse(w, r, err) // 500 } return }
app.writeJSON(w, http.StatusCreated, item, nil)}The key: errors flow up, decisions flow down. Services return errors, handlers decide HTTP status codes.
Database: Connection Pooling That Actually Works
This took me embarrassingly long to get right:
// ❌ BAD: Creating connection per requestfunc createItem(w http.ResponseWriter, r *http.Request) { db, _ := sql.Open("postgres", "...") // Creates new connection! defer db.Close() db.Exec("INSERT...")}// ✅ GOOD: Pool created once at startupfunc main() { db, err := sql.Open("postgres", connectionString) if err != nil { log.Fatal(err) }
// Configure the pool db.SetMaxOpenConns(25) // Max 25 connections db.SetMaxIdleConns(5) // Keep 5 idle db.SetConnMaxLifetime(5 * time.Minute)
// Share across all handlers app := &application{ db: db, }}Went from 200ms queries to 15ms just by fixing this. Connection overhead is real.
Testing: What Actually Gets Tested
I don’t test everything. Hot take, I know. Here’s what I do test:
1. Business Logic (Service Layer)
func TestItemService_Create_InvalidQuantity(t *testing.T) { service := &ItemService{repo: &mockRepo{}}
_, err := service.Create(ctx, CreateItemDTO{ Name: "Widget", Quantity: -5, // Invalid! })
if !errors.Is(err, ErrInvalidQuantity) { t.Errorf("expected ErrInvalidQuantity, got %v", err) }}2. Repository Integration Tests
func TestItemRepo_Create(t *testing.T) { db := setupTestDB(t) defer db.Close()
repo := NewItemRepository(db) item := &Item{Name: "Test", Quantity: 10}
created, err := repo.Create(context.Background(), item)
assert.NoError(t, err) assert.NotEmpty(t, created.ID)}What I DON’T test: Handlers. They’re just glue code. If the service works and the repository works, the handler will work.
Performance: The Numbers That Matter
Here’s what I learned from production:
Before Optimization
- Avg Response Time: 450ms
- P95: 1.2s
- Throughput: ~100 req/s
After Optimization
- Avg Response Time: 45ms (10x improvement!)
- P95: 180ms
- Throughput: ~800 req/s
What made the difference:
- Connection pooling (biggest win)
- Context timeouts (prevents slow queries from piling up)
- Proper indexing (added indexes on frequently queried columns)
- Middleware ordering (auth before expensive operations)
Deployment: From Local to Production
The beauty of this architecture? It deploys anywhere.
Local development:
export DATABASE_URL="postgres://localhost/bmginventory"go run cmd/api/main.goDocker:
FROM golang:1.25-alpine AS builderWORKDIR /appCOPY . .RUN go build -o api cmd/api/main.go
FROM alpine:latestCOPY --from=builder /app/api /apiEXPOSE 4000CMD ["/api"]Kubernetes/Cloud: Same binary, different config. That’s the power of the 12-factor app.
Lessons Learned (The Hard Way)
1. Start with layers from day one
Refactoring spaghetti code is 10x harder than starting clean.
2. DTOs are worth the boilerplate
Yes, it’s extra typing. No, it’s not premature optimization. Saved me from leaking sensitive data.
3. Middleware ordering matters
// ✅ GOOD: Auth after rate limitingrouter.Use(RateLimit)router.Use(Auth)
// ❌ BAD: Auth before rate limiting// Attackers can spam your auth DB!router.Use(Auth)router.Use(RateLimit)4. Context is your friend
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)defer cancel()
item, err := app.itemService.Create(ctx, input)Prevents runaway queries from killing your server.
5. Don’t over-abstract
My first attempt had 5 layers. Ridiculous. Three is plenty: handler, service, repository.
What’s Next?
This is just v1. The architecture makes it easy to add:
- OAuth2 integration (just add a new auth method in service)
- Caching with Redis (add a cache layer, repository doesn’t change)
- Event-driven features (publish events from service layer)
- gRPC endpoints (reuse the service layer!)
That last one is key: good architecture is protocol-agnostic. Want both REST and gRPC? Just add handlers. Your business logic stays the same.
The Code Structure
Everything’s organized cleanly:
cmd/api/ # HTTP server entrypointinternal/ ├── handler/ # HTTP → Service ├── service/ # Business logic ├── repository/ # Service → Database ├── domain/ # Core entities └── dto/ # API contractsQuick start:
make migrate-up # Setup DBmake run # Start servercurl localhost:4000/v1/healthcheckFinal Thoughts
Clean architecture isn’t about following rules religiously. It’s about making your future self’s life easier.
When you get that 3 AM support call because something broke, you want to know exactly where to look. Handler? Service? Repository? Clear boundaries = faster debugging.
When PM wants “just a small feature” that turns into refactoring half the codebase, you want layers that prevent cascading changes.
When you’re onboarding a new dev, you want a structure so obvious they can contribute on day one.
That’s what this architecture gave me. Hope it helps you too.
Links & Resources
- GitHub Repository - Full source code and examples
- UploadStream gRPC Project - Same layered approach for file streaming
- Read on DEV.to - Original article and discussion
Questions? Disagree with my approach? I’m especially curious about how others handle testing—I know my approach is minimal, and I’d love to hear alternatives.
Found this useful? Star the repo and follow me for more backend deep dives. Next up: adding OAuth2 to this exact API.