In the previous tutorial, you learned Docker for Go. Now let’s put everything together and build a complete microservice.
This is a project tutorial. You will build a notes API from scratch using the skills from the entire series: Gin for routing, PostgreSQL with sqlx for storage, JWT for authentication, validation for input, slog for logging, and Docker for deployment.
What We Are Building
A notes microservice with these endpoints:
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/register | Create account | No |
| POST | /api/login | Get JWT token | No |
| GET | /api/notes | List user’s notes | Yes |
| POST | /api/notes | Create a note | Yes |
| GET | /api/notes/:id | Get a note | Yes |
| PUT | /api/notes/:id | Update a note | Yes |
| DELETE | /api/notes/:id | Delete a note | Yes |
| GET | /health | Health check | No |
Project Structure
notes-api/
cmd/
server/
main.go # Entry point
internal/
handler/
auth.go # Auth handlers (register, login)
notes.go # Note CRUD handlers
middleware.go # JWT middleware
model/
user.go # User struct
note.go # Note struct
repository/
user.go # User database operations
note.go # Note database operations
service/
auth.go # Auth business logic
go.mod
go.sum
Dockerfile
docker-compose.yml
This follows the clean architecture from Go Tutorial #10.
Models
Define the data structures:
// internal/model/user.go
package model
import "time"
type User struct {
ID int `json:"id" db:"id"`
Email string `json:"email" db:"email"`
Password string `json:"-" db:"password"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
Email string `json:"email"`
}
// internal/model/note.go
package model
import "time"
type Note struct {
ID int `json:"id" db:"id"`
UserID int `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateNoteRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Content string `json:"content" validate:"max=10000"`
}
type UpdateNoteRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Content string `json:"content" validate:"max=10000"`
}
The db tags are for sqlx. The json:"-" on Password means it is never included in JSON responses.
Repository Layer
The repository handles all database operations:
// internal/repository/user.go
package repository
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/kemalcodes/notes-api/internal/model"
)
type UserRepository struct {
db *sqlx.DB
}
func NewUserRepository(db *sqlx.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, email, hashedPassword string) (*model.User, error) {
var user model.User
err := r.db.QueryRowxContext(ctx,
`INSERT INTO users (email, password) VALUES ($1, $2)
RETURNING id, email, password, created_at`,
email, hashedPassword,
).StructScan(&user)
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
var user model.User
err := r.db.GetContext(ctx, &user,
`SELECT id, email, password, created_at FROM users WHERE email = $1`,
email,
)
if err != nil {
return nil, err
}
return &user, nil
}
// internal/repository/note.go
package repository
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/kemalcodes/notes-api/internal/model"
)
type NoteRepository struct {
db *sqlx.DB
}
func NewNoteRepository(db *sqlx.DB) *NoteRepository {
return &NoteRepository{db: db}
}
func (r *NoteRepository) Create(ctx context.Context, userID int, title, content string) (*model.Note, error) {
var note model.Note
err := r.db.QueryRowxContext(ctx,
`INSERT INTO notes (user_id, title, content)
VALUES ($1, $2, $3)
RETURNING id, user_id, title, content, created_at, updated_at`,
userID, title, content,
).StructScan(¬e)
if err != nil {
return nil, err
}
return ¬e, nil
}
func (r *NoteRepository) GetByUserID(ctx context.Context, userID int) ([]model.Note, error) {
var notes []model.Note
err := r.db.SelectContext(ctx, ¬es,
`SELECT id, user_id, title, content, created_at, updated_at
FROM notes WHERE user_id = $1 ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
return notes, nil
}
func (r *NoteRepository) GetByID(ctx context.Context, id, userID int) (*model.Note, error) {
var note model.Note
err := r.db.GetContext(ctx, ¬e,
`SELECT id, user_id, title, content, created_at, updated_at
FROM notes WHERE id = $1 AND user_id = $2`,
id, userID,
)
if err != nil {
return nil, err
}
return ¬e, nil
}
func (r *NoteRepository) Update(ctx context.Context, id, userID int, title, content string) (*model.Note, error) {
var note model.Note
err := r.db.QueryRowxContext(ctx,
`UPDATE notes SET title = $1, content = $2, updated_at = NOW()
WHERE id = $3 AND user_id = $4
RETURNING id, user_id, title, content, created_at, updated_at`,
title, content, id, userID,
).StructScan(¬e)
if err != nil {
return nil, err
}
return ¬e, nil
}
func (r *NoteRepository) Delete(ctx context.Context, id, userID int) error {
result, err := r.db.ExecContext(ctx,
`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
id, userID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
var ErrNotFound = errors.New("not found")
Add "errors" to the imports for this file.
Auth Service
The service layer handles business logic like password hashing and JWT tokens:
// internal/service/auth.go
package service
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
jwtSecret []byte
}
func NewAuthService(jwtSecret string) *AuthService {
return &AuthService{jwtSecret: []byte(jwtSecret)}
}
func (s *AuthService) HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (s *AuthService) CheckPassword(hashedPassword, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}
func (s *AuthService) GenerateToken(userID int, email string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"email": email,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *AuthService) ValidateToken(tokenString string) (int, string, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.jwtSecret, nil
})
if err != nil {
return 0, "", err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return 0, "", errors.New("invalid token")
}
userID := int(claims["user_id"].(float64))
email := claims["email"].(string)
return userID, email, nil
}
JWT Middleware
Protect routes with JWT authentication:
// internal/handler/middleware.go
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/kemalcodes/notes-api/internal/service"
)
func AuthMiddleware(authService *service.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
c.Abort()
return
}
userID, email, err := authService.ValidateToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
c.Abort()
return
}
c.Set("user_id", userID)
c.Set("user_email", email)
c.Next()
}
}
Handlers
The handlers connect HTTP requests to the business logic:
// internal/handler/auth.go
package handler
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/kemalcodes/notes-api/internal/model"
"github.com/kemalcodes/notes-api/internal/repository"
"github.com/kemalcodes/notes-api/internal/service"
)
type AuthHandler struct {
userRepo *repository.UserRepository
authService *service.AuthService
validate *validator.Validate
}
func NewAuthHandler(userRepo *repository.UserRepository, authService *service.AuthService) *AuthHandler {
return &AuthHandler{
userRepo: userRepo,
authService: authService,
validate: validator.New(),
}
}
func (h *AuthHandler) Register(c *gin.Context) {
var req model.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
if err := h.validate.Struct(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password (min 8 chars) are required"})
return
}
hashedPassword, err := h.authService.HashPassword(req.Password)
if err != nil {
slog.Error("failed to hash password", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
user, err := h.userRepo.Create(c.Request.Context(), req.Email, hashedPassword)
if err != nil {
slog.Error("failed to create user", "error", err)
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
return
}
token, err := h.authService.GenerateToken(user.ID, user.Email)
if err != nil {
slog.Error("failed to generate token", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusCreated, model.AuthResponse{Token: token, Email: user.Email})
}
func (h *AuthHandler) Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
if err := h.validate.Struct(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
user, err := h.userRepo.GetByEmail(c.Request.Context(), req.Email)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
return
}
if !h.authService.CheckPassword(user.Password, req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
return
}
token, err := h.authService.GenerateToken(user.ID, user.Email)
if err != nil {
slog.Error("failed to generate token", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, model.AuthResponse{Token: token, Email: user.Email})
}
// internal/handler/notes.go
package handler
import (
"log/slog"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/kemalcodes/notes-api/internal/model"
"github.com/kemalcodes/notes-api/internal/repository"
)
type NoteHandler struct {
noteRepo *repository.NoteRepository
validate *validator.Validate
}
func NewNoteHandler(noteRepo *repository.NoteRepository) *NoteHandler {
return &NoteHandler{
noteRepo: noteRepo,
validate: validator.New(),
}
}
func (h *NoteHandler) List(c *gin.Context) {
userID := c.GetInt("user_id")
notes, err := h.noteRepo.GetByUserID(c.Request.Context(), userID)
if err != nil {
slog.Error("failed to list notes", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusOK, notes)
}
func (h *NoteHandler) Create(c *gin.Context) {
userID := c.GetInt("user_id")
var req model.CreateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
if err := h.validate.Struct(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "title is required (max 200 chars)"})
return
}
note, err := h.noteRepo.Create(c.Request.Context(), userID, req.Title, req.Content)
if err != nil {
slog.Error("failed to create note", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
}
c.JSON(http.StatusCreated, note)
}
func (h *NoteHandler) Get(c *gin.Context) {
userID := c.GetInt("user_id")
noteID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid note ID"})
return
}
note, err := h.noteRepo.GetByID(c.Request.Context(), noteID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, note)
}
func (h *NoteHandler) Update(c *gin.Context) {
userID := c.GetInt("user_id")
noteID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid note ID"})
return
}
var req model.UpdateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
if err := h.validate.Struct(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "title is required (max 200 chars)"})
return
}
note, err := h.noteRepo.Update(c.Request.Context(), noteID, userID, req.Title, req.Content)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, note)
}
func (h *NoteHandler) Delete(c *gin.Context) {
userID := c.GetInt("user_id")
noteID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid note ID"})
return
}
if err := h.noteRepo.Delete(c.Request.Context(), noteID, userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "note deleted"})
}
Main Entry Point
Wire everything together:
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/kemalcodes/notes-api/internal/handler"
"github.com/kemalcodes/notes-api/internal/repository"
"github.com/kemalcodes/notes-api/internal/service"
)
func main() {
// Logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Config from environment
port := getEnv("PORT", "8080")
dbURL := getEnv("DATABASE_URL", "postgres://appuser:apppassword@localhost:5432/notesdb?sslmode=disable")
jwtSecret := getEnv("JWT_SECRET", "change-me-in-production")
// Database
db, err := sqlx.Connect("pgx", dbURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer db.Close()
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Run migrations
if err := runMigrations(db); err != nil {
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Dependencies
userRepo := repository.NewUserRepository(db)
noteRepo := repository.NewNoteRepository(db)
authService := service.NewAuthService(jwtSecret)
authHandler := handler.NewAuthHandler(userRepo, authService)
noteHandler := handler.NewNoteHandler(noteRepo)
// Router
r := gin.Default()
// Public routes
r.POST("/api/register", authHandler.Register)
r.POST("/api/login", authHandler.Login)
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Protected routes
api := r.Group("/api", handler.AuthMiddleware(authService))
api.GET("/notes", noteHandler.List)
api.POST("/notes", noteHandler.Create)
api.GET("/notes/:id", noteHandler.Get)
api.PUT("/notes/:id", noteHandler.Update)
api.DELETE("/notes/:id", noteHandler.Delete)
// Server with graceful shutdown
server := &http.Server{
Addr: ":" + port,
Handler: r,
}
go func() {
slog.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down server")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("forced shutdown", "error", err)
}
slog.Info("server stopped")
}
func runMigrations(db *sqlx.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
content TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
`
_, err := db.Exec(schema)
return err
}
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
Docker Setup
# Dockerfile
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM alpine:3.20
RUN adduser -D -g '' appuser
WORKDIR /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server .
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./server"]
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- DATABASE_URL=postgres://appuser:apppassword@db:5432/notesdb?sslmode=disable
- JWT_SECRET=my-secret-key-change-in-production
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=apppassword
- POSTGRES_DB=notesdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d notesdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Testing the API
Start the service:
docker compose up --build
Test the endpoints:
# Register
curl -X POST http://localhost:8080/api/register \
-H "Content-Type: application/json" \
-d '{"email":"alex@example.com","password":"secret123"}'
# {"token":"eyJhbG...","email":"alex@example.com"}
# Save the token
TOKEN="eyJhbG..."
# Create a note
curl -X POST http://localhost:8080/api/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title":"Learn Go","content":"Start with the basics."}'
# List notes
curl http://localhost:8080/api/notes \
-H "Authorization: Bearer $TOKEN"
# Update a note
curl -X PUT http://localhost:8080/api/notes/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title":"Learn Go (updated)","content":"Focus on concurrency."}'
# Delete a note
curl -X DELETE http://localhost:8080/api/notes/1 \
-H "Authorization: Bearer $TOKEN"
# Health check (no auth needed)
curl http://localhost:8080/health
What This Project Covers
This microservice uses concepts from across the entire Go series:
| Concept | Tutorial |
|---|---|
| Structs and methods | #7 Structs |
| Interfaces | #8 Interfaces |
| Project structure | #10 Project Structure |
| Error handling | #14 Error Patterns |
| Gin framework | #16 REST APIs with Gin |
| JWT authentication | #18 Middleware and JWT |
| Database with sqlx | #19 Database |
| Input validation | #21 API Best Practices |
| Docker | #24 Docker for Go |
Common Mistakes
1. Not using context for database operations.
Always pass context.Context from the HTTP request to your database queries. This ensures queries are cancelled when the client disconnects.
2. Storing JWT secrets in code.
Use environment variables for secrets. Never commit secrets to version control.
3. Missing database indexes.
Without an index on user_id, listing notes gets slower as your database grows. Always add indexes for columns used in WHERE clauses.
Source Code
You can find the complete source code for this tutorial on GitHub:
Related Articles
- Go Tutorial #24: Docker for Go — Building production images
- Go Tutorial #16: Building REST APIs with Gin — Gin basics
- Go Tutorial #18: Middleware and JWT — Authentication
- Go Tutorial #19: Database with sqlx — PostgreSQL operations
What’s Next?
In the next and final tutorial, Go Tutorial #26: gRPC in Go, you will learn:
- What gRPC is and when to choose it over REST
- Protocol Buffers for defining APIs
- Server and client implementation
- Streaming and interceptors
This is part 25 of the Go Tutorial series. Follow along to learn Go from scratch.