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:

MethodPathDescriptionAuth
POST/api/registerCreate accountNo
POST/api/loginGet JWT tokenNo
GET/api/notesList user’s notesYes
POST/api/notesCreate a noteYes
GET/api/notes/:idGet a noteYes
PUT/api/notes/:idUpdate a noteYes
DELETE/api/notes/:idDelete a noteYes
GET/healthHealth checkNo

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(&note)

    if err != nil {
        return nil, err
    }
    return &note, nil
}

func (r *NoteRepository) GetByUserID(ctx context.Context, userID int) ([]model.Note, error) {
    var notes []model.Note
    err := r.db.SelectContext(ctx, &notes,
        `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, &note,
        `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 &note, 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(&note)

    if err != nil {
        return nil, err
    }
    return &note, 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:

ConceptTutorial
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:

GO-25 Source Code on GitHub

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.