📝 TODO List Implementation¶
This guide walks you through implementing a complete TODO list feature from scratch, demonstrating all layers of the clean architecture used in GRAB.
🎯 What You'll Build¶
A fully functional TODO list API with: - ✅ Create, Read, Update, Delete operations - ✅ User ownership and authentication - ✅ Database migrations - ✅ Swagger documentation - ✅ Complete CRUD endpoints
📋 Prerequisites¶
- GRAB project set up and running
- Basic understanding of Go
- Familiarity with REST APIs
🚀 Implementation Steps¶
Step 1: Create Directory Structure¶
mkdir -p internal/todo
touch internal/todo/model.go
touch internal/todo/dto.go
touch internal/todo/repository.go
touch internal/todo/service.go
touch internal/todo/handler.go
Step 2: Define Model (internal/todo/model.go
)¶
package todo
import (
"time"
"gorm.io/gorm"
)
// Todo represents a task in the system
type Todo struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Completed bool `gorm:"default:false" json:"completed"`
UserID uint `gorm:"not null;index" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for GORM
func (Todo) TableName() string {
return "todos"
}
Key Points: - gorm:"primaryKey"
- Defines the primary key - gorm:"not null"
- Makes field required - gorm:"index"
- Creates database index for faster queries - DeletedAt
- Enables soft deletes - json:"-"
- Excludes field from JSON responses
Step 3: Create Migration Files¶
migrations/000002_create_todos_table.up.sql
:
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
CONSTRAINT fk_todos_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE INDEX idx_todos_user_id ON todos(user_id);
CREATE INDEX idx_todos_deleted_at ON todos(deleted_at);
migrations/000002_create_todos_table.down.sql
:
DROP INDEX IF EXISTS idx_todos_deleted_at;
DROP INDEX IF EXISTS idx_todos_user_id;
DROP TABLE IF EXISTS todos;
Create migration:
Step 4: Define DTOs (internal/todo/dto.go
)¶
package todo
import "time"
// CreateTodoRequest represents the request to create a new todo
type CreateTodoRequest struct {
Title string `json:"title" binding:"required,min=1,max=255"`
Description string `json:"description" binding:"max=1000"`
}
// UpdateTodoRequest represents the request to update a todo
type UpdateTodoRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=255"`
Description *string `json:"description" binding:"omitempty,max=1000"`
Completed *bool `json:"completed"`
}
// TodoResponse represents a todo in API responses
type TodoResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Completed bool `json:"completed"`
UserID uint `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TodoListResponse represents a list of todos
type TodoListResponse struct {
Todos []TodoResponse `json:"todos"`
Total int `json:"total"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
// Helper function to convert model to response
func toTodoResponse(todo *Todo) TodoResponse {
return TodoResponse{
ID: todo.ID,
Title: todo.Title,
Description: todo.Description,
Completed: todo.Completed,
UserID: todo.UserID,
CreatedAt: todo.CreatedAt,
UpdatedAt: todo.UpdatedAt,
}
}
Key Points: - binding:"required"
- Field is mandatory - binding:"omitempty"
- Field is optional - Use pointers for optional fields in updates - Separate request and response DTOs
Step 5: Create Repository (internal/todo/repository.go
)¶
package todo
import (
"gorm.io/gorm"
)
// TodoRepository defines the interface for todo data operations
type TodoRepository interface {
Create(todo *Todo) error
FindByID(id uint) (*Todo, error)
FindByUserID(userID uint) ([]Todo, error)
Update(todo *Todo) error
Delete(id uint) error
}
// GormTodoRepository implements TodoRepository using GORM
type GormTodoRepository struct {
db *gorm.DB
}
// NewRepository creates a new todo repository
func NewRepository(db *gorm.DB) TodoRepository {
return &GormTodoRepository{db: db}
}
// Create inserts a new todo into the database
func (r *GormTodoRepository) Create(todo *Todo) error {
return r.db.Create(todo).Error
}
// FindByID retrieves a todo by its ID
func (r *GormTodoRepository) FindByID(id uint) (*Todo, error) {
var todo Todo
if err := r.db.First(&todo, id).Error; err != nil {
return nil, err
}
return &todo, nil
}
// FindByUserID retrieves all todos for a specific user
func (r *GormTodoRepository) FindByUserID(userID uint) ([]Todo, error) {
var todos []Todo
if err := r.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&todos).Error; err != nil {
return nil, err
}
return todos, nil
}
// Update updates an existing todo
func (r *GormTodoRepository) Update(todo *Todo) error {
return r.db.Save(todo).Error
}
// Delete soft deletes a todo by ID
func (r *GormTodoRepository) Delete(id uint) error {
return r.db.Delete(&Todo{}, id).Error
}
Key Points: - Interface defines contract - GORM handles SQL generation - Use parameterized queries (automatic with GORM) - Soft delete with DeletedAt
Step 6: Implement Service (internal/todo/service.go
)¶
package todo
import (
"errors"
"gorm.io/gorm"
)
// TodoService handles business logic for todos
type TodoService struct {
repo TodoRepository
}
// NewService creates a new todo service
func NewService(repo TodoRepository) *TodoService {
return &TodoService{repo: repo}
}
// CreateTodo creates a new todo for a user
func (s *TodoService) CreateTodo(userID uint, req CreateTodoRequest) (*Todo, error) {
todo := &Todo{
Title: req.Title,
Description: req.Description,
Completed: false,
UserID: userID,
}
if err := s.repo.Create(todo); err != nil {
return nil, err
}
return todo, nil
}
// GetUserTodos retrieves all todos for a user
func (s *TodoService) GetUserTodos(userID uint) ([]Todo, error) {
return s.repo.FindByUserID(userID)
}
// GetTodo retrieves a specific todo and verifies ownership
func (s *TodoService) GetTodo(todoID, userID uint) (*Todo, error) {
todo, err := s.repo.FindByID(todoID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("todo not found")
}
return nil, err
}
// Verify ownership
if todo.UserID != userID {
return nil, errors.New("unauthorized")
}
return todo, nil
}
// UpdateTodo updates an existing todo
func (s *TodoService) UpdateTodo(todoID, userID uint, req UpdateTodoRequest) (*Todo, error) {
// Get and verify ownership
todo, err := s.GetTodo(todoID, userID)
if err != nil {
return nil, err
}
// Update fields if provided
if req.Title != nil {
todo.Title = *req.Title
}
if req.Description != nil {
todo.Description = *req.Description
}
if req.Completed != nil {
todo.Completed = *req.Completed
}
if err := s.repo.Update(todo); err != nil {
return nil, err
}
return todo, nil
}
// DeleteTodo deletes a todo
func (s *TodoService) DeleteTodo(todoID, userID uint) error {
// Verify ownership
_, err := s.GetTodo(todoID, userID)
if err != nil {
return err
}
return s.repo.Delete(todoID)
}
Key Points: - Business logic lives here - Always verify ownership - Handle errors appropriately - Keep functions focused and small
Step 7: Create Handlers (internal/todo/handler.go
)¶
package todo
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// TodoHandler handles HTTP requests for todos
type TodoHandler struct {
service *TodoService
}
// NewHandler creates a new todo handler
func NewHandler(service *TodoService) *TodoHandler {
return &TodoHandler{service: service}
}
// getUserID is a helper to extract user ID from context
func (h *TodoHandler) getUserID(c *gin.Context) (uint, error) {
userID, exists := c.Get("user_id")
if !exists {
return 0, errors.New("user not authenticated")
}
return userID.(uint), nil
}
// CreateTodo godoc
// @Summary Create a new todo
// @Description Create a new todo item for the authenticated user
// @Tags todos
// @Accept json
// @Produce json
// @Param todo body CreateTodoRequest true "Todo data"
// @Success 201 {object} TodoResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/todos [post]
func (h *TodoHandler) CreateTodo(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
var req CreateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
todo, err := h.service.CreateTodo(userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusCreated, toTodoResponse(todo))
}
// GetTodos godoc
// @Summary Get all todos
// @Description Get all todos for the authenticated user
// @Tags todos
// @Produce json
// @Success 200 {object} TodoListResponse
// @Failure 401 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/todos [get]
func (h *TodoHandler) GetTodos(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
todos, err := h.service.GetUserTodos(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
responses := make([]TodoResponse, len(todos))
for i, todo := range todos {
responses[i] = toTodoResponse(&todo)
}
c.JSON(http.StatusOK, TodoListResponse{
Todos: responses,
Total: len(responses),
})
}
// GetTodo godoc
// @Summary Get a todo by ID
// @Description Get a specific todo by ID for the authenticated user
// @Tags todos
// @Produce json
// @Param id path int true "Todo ID"
// @Success 200 {object} TodoResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/todos/{id} [get]
func (h *TodoHandler) GetTodo(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid todo ID"})
return
}
todo, err := h.service.GetTodo(uint(todoID), userID)
if err != nil {
if err.Error() == "todo not found" {
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
} else if err.Error() == "unauthorized" {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
} else {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
}
return
}
c.JSON(http.StatusOK, toTodoResponse(todo))
}
// UpdateTodo godoc
// @Summary Update a todo
// @Description Update an existing todo for the authenticated user
// @Tags todos
// @Accept json
// @Produce json
// @Param id path int true "Todo ID"
// @Param todo body UpdateTodoRequest true "Todo update data"
// @Success 200 {object} TodoResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/todos/{id} [put]
func (h *TodoHandler) UpdateTodo(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid todo ID"})
return
}
var req UpdateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
todo, err := h.service.UpdateTodo(uint(todoID), userID, req)
if err != nil {
if err.Error() == "todo not found" {
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
} else if err.Error() == "unauthorized" {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
} else {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
}
return
}
c.JSON(http.StatusOK, toTodoResponse(todo))
}
// DeleteTodo godoc
// @Summary Delete a todo
// @Description Delete a todo for the authenticated user
// @Tags todos
// @Produce json
// @Param id path int true "Todo ID"
// @Success 204
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/todos/{id} [delete]
func (h *TodoHandler) DeleteTodo(c *gin.Context) {
userID, err := h.getUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid todo ID"})
return
}
if err := h.service.DeleteTodo(uint(todoID), userID); err != nil {
if err.Error() == "todo not found" {
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
} else if err.Error() == "unauthorized" {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
} else {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
}
return
}
c.Status(http.StatusNoContent)
}
Key Points: - Swagger annotations for documentation - Proper HTTP status codes - Error handling for different scenarios - Extract user ID from JWT context
Step 8: Register Routes (internal/server/router.go
)¶
Add to your router setup:
// Add to imports
import (
"github.com/vahiiiid/go-rest-api-boilerplate/internal/todo"
)
// In SetupRouter function, after initializing user handler:
func SetupRouter(userHandler *user.UserHandler, authService *auth.AuthService, todoHandler *todo.TodoHandler) *gin.Engine {
// ... existing code ...
// Protected routes (requires authentication)
protected := v1.Group("")
protected.Use(auth.AuthMiddleware(authService))
{
// User routes
protected.GET("/users/:id", userHandler.GetUser)
protected.PUT("/users/:id", userHandler.UpdateUser)
protected.DELETE("/users/:id", userHandler.DeleteUser)
// Todo routes (NEW)
protected.POST("/todos", todoHandler.CreateTodo)
protected.GET("/todos", todoHandler.GetTodos)
protected.GET("/todos/:id", todoHandler.GetTodo)
protected.PUT("/todos/:id", todoHandler.UpdateTodo)
protected.DELETE("/todos/:id", todoHandler.DeleteTodo)
}
return router
}
Step 9: Update Main (cmd/server/main.go
)¶
// Add to imports
import (
"github.com/vahiiiid/go-rest-api-boilerplate/internal/todo"
)
func main() {
// ... existing code ...
// Run migrations
log.Println("Running database migrations...")
if err := database.AutoMigrate(&user.User{}, &todo.Todo{}); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
log.Println("Migrations completed successfully")
// Initialize services
authService := auth.NewService()
userRepo := user.NewRepository(database)
userService := user.NewService(userRepo)
userHandler := user.NewHandler(userService, authService)
// Todo services (NEW)
todoRepo := todo.NewRepository(database)
todoService := todo.NewService(todoRepo)
todoHandler := todo.NewHandler(todoService)
// Setup router with todo handler
router := server.SetupRouter(userHandler, authService, todoHandler)
// ... rest of code ...
}
Step 10: Run Migrations¶
# If using AutoMigrate (default)
# Just restart the app, it will auto-create the table
# If using golang-migrate
make migrate-docker-up
Step 11: Test the API¶
Create a Todo:
# First, register and get token
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","password":"secret123"}' \
| jq -r '.token')
# Create a todo
curl -X POST http://localhost:8080/api/v1/todos \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Buy groceries",
"description": "Milk, eggs, bread"
}'
Get All Todos:
Update a Todo:
curl -X PUT http://localhost:8080/api/v1/todos/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"completed": true
}'
Delete a Todo:
Step 12: Regenerate Swagger Docs¶
Visit http://localhost:8080/swagger/index.html
to see the new TODO endpoints!
🎉 Congratulations!¶
You've successfully implemented a complete TODO list feature following GRAB's clean architecture! This same pattern can be applied to any new feature you want to add.
📚 What You Learned¶
- ✅ Creating models with GORM
- ✅ Writing database migrations
- ✅ Defining DTOs with validation
- ✅ Implementing repositories
- ✅ Writing business logic in services
- ✅ Creating HTTP handlers
- ✅ Registering routes
- ✅ Adding Swagger documentation
- ✅ Testing with curl
🔄 Next Steps¶
- Add pagination to the list endpoint
- Add filtering (completed/incomplete)
- Add search functionality
- Write unit tests
- Add due dates to todos
- Implement todo categories
💡 Tips¶
- Always verify ownership in services
- Use proper HTTP status codes
- Add Swagger annotations
- Test each layer independently
- Keep functions small and focused
- Handle errors appropriately
Happy Coding! 🚀
For more details on the architecture and patterns, see the Development Guide.