Go TODO API with Real Packages: Gin, GORM, Zap, Validator & Fx

Go TODO API with Real Packages: Gin, GORM, Zap, Validator & Fx

Build the same TODO REST API from the native stdlib post — now with Gin, GORM, uber-go/zap, go-playground/validator, and uber-go/fx. See exactly where packages save code and why.

By Omar Flores

The previous post in this series built a fully working TODO REST API using nothing beyond Go’s standard library. Every line of routing, JSON handling, SQL, and logging was written by hand. The code worked. It compiled clean. The tests passed.

But that experience raises a fair question. If you already know Go, you have seen Gin, GORM, and the Uber packages appear in almost every production codebase you touch. They are there for a reason. How do they change the implementation? Where do they add value? And, just as importantly, where are they just trading one kind of complexity for another?

This post answers those questions by rebuilding the exact same TODO API — same Hexagonal Architecture, same DDD domain, same five endpoints — using a curated set of real packages. The domain and application layers will not change at all. You will see that the architecture protected them perfectly. Only the adapters will be rewritten.

By the end you will have a clear map: what stays the same, what changes, what each package actually buys you.


The Packages and Their Purpose

Before writing any code, know what you are bringing in and why.

PackageRoleReplaces
github.com/gin-gonic/ginHTTP router and handler enginenet/http ServeMux + manual routing
gorm.io/gormORM for database accessdatabase/sql + raw SQL
github.com/glebarez/sqlitePure-Go SQLite driver for GORMmodernc.org/sqlite
github.com/pressly/goose/v3SQL migration runnerInline CREATE TABLE schema
github.com/go-playground/validator/v10Struct validation via tagsManual field checks
go.uber.org/zapStructured high-performance logginglog/slog
go.uber.org/fxDependency injection containerManual wiring in main.go

Seven packages. Each one earns its place. Each one also carries a cost in understanding, debugging surface, and dependency lock-in. The goal is to be honest about both sides.


What Does Not Change

The following packages from the previous post are copied unchanged into this project:

  • internal/domain/errors.go — sentinel errors, no imports
  • internal/domain/todo.goTodo entity, Title value object, ID type
  • internal/domain/todo_test.go — domain tests, still pass
  • internal/ports/input/todo_service.goTodoService interface
  • internal/ports/output/todo_repository.goTodoRepository interface
  • internal/application/todo_service.go — use cases
  • internal/application/todo_service_test.go — application tests with mock repo

The architecture earned this. Six files, hundreds of lines of business logic, zero modifications. If you change the database tomorrow, these files do not move. If you add GraphQL next month, these files do not move. That is the value of building behind interfaces.


The New Project Structure

The adapters change. Migrations get their own folder. The wiring in main.go transforms:

todo-api-packages/
├── cmd/
│   └── server/
│       └── main.go
├── db/
│   └── migrations/
│       └── 00001_create_todos.sql
├── internal/
│   ├── domain/         ← identical to the stdlib post
│   ├── ports/          ← identical to the stdlib post
│   ├── application/    ← identical to the stdlib post
│   └── adapters/
│       ├── http/
│       │   ├── dto.go
│       │   ├── handler.go   ← Gin version
│       │   └── middleware.go
│       └── gorm/
│           ├── model.go
│           └── repository.go
├── go.mod
└── go.sum

Step 1 — Initialize the Module

mkdir todo-api-packages
cd todo-api-packages
go mod init github.com/sazardev/todo-api-packages

Create the directories:

mkdir -p cmd/server
mkdir -p db/migrations
mkdir -p internal/domain
mkdir -p internal/ports/input
mkdir -p internal/ports/output
mkdir -p internal/application
mkdir -p internal/adapters/http
mkdir -p internal/adapters/gorm

Step 2 — Install the Packages

go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get github.com/glebarez/sqlite
go get github.com/pressly/goose/v3
go get github.com/go-playground/validator/v10
go get go.uber.org/zap
go get go.uber.org/fx
go mod tidy

After this, your go.mod will list these seven direct dependencies and a handful of transitive ones. This is the moment to internalize the trade-off: you are now responsible for keeping these dependencies updated, auditing their security advisories, and understanding their breaking changes. The stdlib post had one external dependency. This one has seven. Neither number is wrong — the question is whether each one earns its weight.


Step 3 — Copy the Domain and Ports Unchanged

Copy internal/domain/ and internal/ports/ from the previous project. Do not change a single line. The same applies to internal/application/. These packages have no knowledge of Gin, GORM, or any framework. They never will.

If you did not follow the previous post, the domain code is reproduced in a companion gist. The essential types are: domain.Todo, domain.Title, domain.ID, domain.ErrNotFound, domain.ErrEmptyTitle, domain.ErrTitleTooLong, domain.ErrAlreadyCompleted. The ports are input.TodoService and output.TodoRepository.


Step 4 — Write the SQL Migration

The stdlib version applied its schema inline with db.Exec(schema) at startup. That approach has a flaw: it has no history, no rollback, and no way to evolve the schema over time without risking data loss.

Goose solves this with numbered SQL files. Each file has an Up section (applied when migrating forward) and a Down section (applied when rolling back). Goose tracks which migrations have run in a table it creates automatically.

touch db/migrations/00001_create_todos.sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS todos (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL,
    description TEXT    NOT NULL DEFAULT '',
    completed   INTEGER NOT NULL DEFAULT 0,
    created_at  DATETIME NOT NULL DEFAULT (datetime('now')),
    updated_at  DATETIME NOT NULL DEFAULT (datetime('now'))
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS todos;
-- +goose StatementEnd

The -- +goose Up and -- +goose Down comments are directives that Goose parses. Everything between StatementBegin and StatementEnd is a single SQL statement. For simple single-statement migrations you can omit the StatementBegin/End wrappers, but using them is the safe habit — it handles multi-statement migrations without ambiguity.


Step 5 — Define the GORM Model

GORM maps Go structs to database tables. The struct fields become columns. The field names, types, and tags control the mapping. This is the persistence model — it belongs entirely to the GORM adapter and should never cross into the domain or application layers.

touch internal/adapters/gorm/model.go
// Package gormsqlite is the output (driven) adapter that implements
// output.TodoRepository using GORM backed by SQLite.
package gormsqlite

import "time"

// TodoModel is the GORM persistence model for a todo.
// It is NOT a domain object. It exists purely to communicate with the database.
// Mapping between TodoModel and domain.Todo happens in the repository layer.
//
// Why a separate model?
// Using GORM's default conventions (embedded gorm.Model gives you ID, CreatedAt,
// UpdatedAt, DeletedAt) would tie the domain type to a GORM concern: the
// DeletedAt soft-delete field. Keeping a separate model prevents that leakage.
type TodoModel struct {
	ID          uint      `gorm:"primaryKey;autoIncrement"`
	Title       string    `gorm:"not null"`
	Description string    `gorm:"not null;default:''"`
	Completed   bool      `gorm:"not null;default:false"`
	CreatedAt   time.Time `gorm:"not null"`
	UpdatedAt   time.Time `gorm:"not null"`
}

// TableName tells GORM which table to use. Without this method, GORM would
// default to "todo_models" (the pluralized struct name). We want "todos"
// to match the migration we already wrote.
func (TodoModel) TableName() string {
	return "todos"
}

Step 6 — Implement the GORM Repository

Now compare this to the stdlib version. The stdlib adapter was ~140 lines: two scan functions, a reconstitute function, manual SQL for each operation, time parsing. The GORM version handles most of that translation for you.

touch internal/adapters/gorm/repository.go
package gormsqlite

import (
	"context"
	"errors"
	"fmt"

	"gorm.io/gorm"

	"github.com/sazardev/todo-api-packages/internal/domain"
)

// TodoRepository implements output.TodoRepository using GORM.
type TodoRepository struct {
	db *gorm.DB
}

// NewTodoRepository constructs the repository with an open GORM connection.
func NewTodoRepository(db *gorm.DB) *TodoRepository {
	return &TodoRepository{db: db}
}

// Save inserts a new todo and returns the persisted version with its ID.
// GORM populates the ID, CreatedAt, and UpdatedAt fields after Create().
func (r *TodoRepository) Save(ctx context.Context, todo domain.Todo) (domain.Todo, error) {
	model := TodoModel{
		Title:       todo.Title().String(),
		Description: todo.Description(),
		Completed:   todo.IsCompleted(),
		CreatedAt:   todo.CreatedAt(),
		UpdatedAt:   todo.UpdatedAt(),
	}

	// WithContext propagates Go's context cancellation and deadlines into GORM.
	// Without it, an HTTP timeout would not cancel the inflight database call.
	if err := r.db.WithContext(ctx).Create(&model).Error; err != nil {
		return domain.Todo{}, fmt.Errorf("gorm: save todo: %w", err)
	}

	return toDomain(model)
}

// FindByID retrieves a single todo by its primary key.
// GORM returns gorm.ErrRecordNotFound when no row matches; we translate that
// to domain.ErrNotFound to keep the caller decoupled from GORM internals.
func (r *TodoRepository) FindByID(ctx context.Context, id domain.ID) (domain.Todo, error) {
	var model TodoModel

	err := r.db.WithContext(ctx).First(&model, uint(id)).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return domain.Todo{}, domain.ErrNotFound
		}
		return domain.Todo{}, fmt.Errorf("gorm: find by id: %w", err)
	}

	return toDomain(model)
}

// FindAll retrieves every todo ordered by creation time.
func (r *TodoRepository) FindAll(ctx context.Context) ([]domain.Todo, error) {
	var models []TodoModel

	if err := r.db.WithContext(ctx).Order("created_at ASC").Find(&models).Error; err != nil {
		return nil, fmt.Errorf("gorm: find all: %w", err)
	}

	todos := make([]domain.Todo, 0, len(models))
	for _, m := range models {
		todo, err := toDomain(m)
		if err != nil {
			return nil, err
		}
		todos = append(todos, todo)
	}

	return todos, nil
}

// Update saves changes to an existing todo row using GORM's Save().
// Save() updates all fields of the struct, which is what we want here.
// For partial updates in a high-traffic system you would use Updates()
// with a map to only write changed columns.
func (r *TodoRepository) Update(ctx context.Context, todo domain.Todo) (domain.Todo, error) {
	model := TodoModel{
		ID:          uint(todo.ID()),
		Title:       todo.Title().String(),
		Description: todo.Description(),
		Completed:   todo.IsCompleted(),
		CreatedAt:   todo.CreatedAt(),
		UpdatedAt:   todo.UpdatedAt(),
	}

	if err := r.db.WithContext(ctx).Save(&model).Error; err != nil {
		return domain.Todo{}, fmt.Errorf("gorm: update todo: %w", err)
	}

	return toDomain(model)
}

// Delete removes a todo by its primary key.
func (r *TodoRepository) Delete(ctx context.Context, id domain.ID) error {
	result := r.db.WithContext(ctx).Delete(&TodoModel{}, uint(id))
	if result.Error != nil {
		return fmt.Errorf("gorm: delete todo: %w", result.Error)
	}
	return nil
}

// ─── Internal Mapper ──────────────────────────────────────────────────────────

// toDomain converts the GORM persistence model into a domain.Todo.
// This is the adapter's responsibility. The domain does not know that GORM exists.
func toDomain(m TodoModel) (domain.Todo, error) {
	title, err := domain.NewTitle(m.Title)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("gorm: reconstitute title: %w", err)
	}

	return domain.ReconstituteTodo(
		domain.ID(m.ID),
		title,
		m.Description,
		m.Completed,
		m.CreatedAt,
		m.UpdatedAt,
	), nil
}

Compare this file to the stdlib SQLite adapter. The GORM version is ~90 lines versus ~215 lines. The difference comes from three places: no raw SQL string literals, no manual column scanning, and no time parsing. GORM handles all three through reflection and the struct tags you defined in the model.

The cost: GORM’s Save() and Find() generate SQL at runtime via reflection. When something goes wrong, the error message is one level further from the raw SQL. Learning to read GORM’s debug output (.Debug() mode) is a skill you will need in a production system.


Step 7 — Write the HTTP Adapter with Gin

This is where Gin shows its biggest return. In the stdlib version the handler file handled method routing manually before Go 1.22, and even with 1.22’s new mux, the handler code itself was verbose: json.NewDecoder(r.Body).Decode(&req), r.PathValue("id"), manual Content-Type headers, manual w.WriteHeader.

Gin compresses all of that into a clean, consistent API. Pay attention to the validator tags on the DTOs — they eliminate the manual error checks for missing fields.

Create internal/adapters/http/dto.go

touch internal/adapters/http/dto.go
// Package ginhttp is the HTTP input adapter using the Gin framework.
package ginhttp

import "time"

// TodoResponse is the JSON shape returned by the API.
// The json tags define the wire format; they are independent of GORM
// or GORM model tags.
type TodoResponse struct {
	ID          uint      `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	Completed   bool      `json:"completed"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

// CreateRequest is the JSON body for creating a todo.
//
// The `binding` tags are read by Gin's ShouldBindJSON, which uses
// go-playground/validator/v10 internally. `binding:"required"` means
// "this field must be present and non-zero". If it is missing, Gin
// returns a descriptive validation error before the handler even runs.
//
// This replaces the manual `if req.Title == ""` check in the stdlib version.
type CreateRequest struct {
	Title       string `json:"title"       binding:"required,min=1,max=255"`
	Description string `json:"description"`
}

// UpdateRequest is the JSON body for updating an existing todo.
// Title is marked `omitempty` on the JSON side: if the client does not
// send it, the field is empty string and the application layer treats that
// as "no title change requested".
type UpdateRequest struct {
	Title       string `json:"title"       binding:"omitempty,min=1,max=255"`
	Description string `json:"description"`
	Completed   bool   `json:"completed"`
}

Create internal/adapters/http/middleware.go

touch internal/adapters/http/middleware.go
package ginhttp

import (
	"net/http"
	"time"

	"go.uber.org/zap"

	"github.com/gin-gonic/gin"
)

// RequestLogger returns a Gin middleware that logs each request using zap.
// In the stdlib version, logging required passing a logger into each handler
// or using a global. Gin middleware solves this cleanly: apply once, works everywhere.
//
// The middleware sits before any route handler. It calls c.Next() to run the handler,
// then logs after it returns — giving access to the response status code.
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()

		// c.Next() runs the matched route handler and any downstream middleware.
		c.Next()

		logger.Info("request",
			zap.String("method", c.Request.Method),
			zap.String("path", c.Request.URL.Path),
			zap.Int("status", c.Writer.Status()),
			zap.Duration("latency", time.Since(start)),
			zap.String("client_ip", c.ClientIP()),
		)
	}
}

// Recovery returns a Gin middleware that recovers from panics and returns 500.
// The stdlib version had no panic recovery — a nil pointer dereference in a handler
// would crash the entire server. This middleware catches it and keeps the server alive.
func Recovery(logger *zap.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		defer func() {
			if err := recover(); err != nil {
				logger.Error("panic recovered", zap.Any("error", err))
				c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
					"error": "internal server error",
				})
			}
		}()
		c.Next()
	}
}

Create internal/adapters/http/handler.go

touch internal/adapters/http/handler.go
package ginhttp

import (
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"github.com/sazardev/todo-api-packages/internal/domain"
	"github.com/sazardev/todo-api-packages/internal/ports/input"
)

// TodoHandler handles HTTP requests for the /todos resource using Gin.
// It depends on the input.TodoService port interface — not on any concrete type.
type TodoHandler struct {
	svc input.TodoService
}

// NewTodoHandler constructs the handler.
func NewTodoHandler(svc input.TodoService) *TodoHandler {
	return &TodoHandler{svc: svc}
}

// RegisterRoutes registers the five CRUD endpoints on the given Gin router.
//
// Comparison with the stdlib version:
//   stdlib: mux.HandleFunc("GET /todos", h.handleList)
//   Gin:    r.GET("/todos", h.handleList)
//
// Gin's API is slightly shorter. The real gain is the handler signature:
// `func(c *gin.Context)` gives you a rich context object with helpers for
// parsing, binding, and responding — eliminating most boilerplate in the handlers.
func (h *TodoHandler) RegisterRoutes(r gin.IRouter) {
	r.GET("/todos", h.handleList)
	r.POST("/todos", h.handleCreate)
	r.GET("/todos/:id", h.handleGetByID)
	r.PUT("/todos/:id", h.handleUpdate)
	r.DELETE("/todos/:id", h.handleDelete)
}

// handleList — GET /todos
func (h *TodoHandler) handleList(c *gin.Context) {
	todos, err := h.svc.GetAll(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list todos"})
		return
	}

	responses := make([]TodoResponse, len(todos))
	for i, t := range todos {
		responses[i] = toResponse(t)
	}

	c.JSON(http.StatusOK, responses)
}

// handleCreate — POST /todos
//
// ShouldBindJSON decodes the request body AND validates the struct tags in one call.
// If `title` is missing or too long, it returns a 422 before the application layer
// ever runs. This is the biggest ergonomic win Gin provides for API development.
func (h *TodoHandler) handleCreate(c *gin.Context) {
	var req CreateRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		// err here is a validator.ValidationErrors value. Its .Error() string
		// contains a readable description of which field failed and why.
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		return
	}

	todo, err := h.svc.Create(c.Request.Context(), input.CreateTodoRequest{
		Title:       req.Title,
		Description: req.Description,
	})
	if err != nil {
		if errors.Is(err, domain.ErrEmptyTitle) || errors.Is(err, domain.ErrTitleTooLong) {
			c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create todo"})
		return
	}

	c.JSON(http.StatusCreated, toResponse(todo))
}

// handleGetByID — GET /todos/:id
func (h *TodoHandler) handleGetByID(c *gin.Context) {
	id, ok := parseID(c)
	if !ok {
		return
	}

	todo, err := h.svc.GetByID(c.Request.Context(), domain.ID(id))
	if err != nil {
		if errors.Is(err, domain.ErrNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "todo not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get todo"})
		return
	}

	c.JSON(http.StatusOK, toResponse(todo))
}

// handleUpdate — PUT /todos/:id
func (h *TodoHandler) handleUpdate(c *gin.Context) {
	id, ok := parseID(c)
	if !ok {
		return
	}

	var req UpdateRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		return
	}

	todo, err := h.svc.Update(c.Request.Context(), input.UpdateTodoRequest{
		ID:          domain.ID(id),
		Title:       req.Title,
		Description: req.Description,
		Completed:   req.Completed,
	})
	if err != nil {
		if errors.Is(err, domain.ErrNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "todo not found"})
			return
		}
		if errors.Is(err, domain.ErrEmptyTitle) || errors.Is(err, domain.ErrTitleTooLong) {
			c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update todo"})
		return
	}

	c.JSON(http.StatusOK, toResponse(todo))
}

// handleDelete — DELETE /todos/:id
func (h *TodoHandler) handleDelete(c *gin.Context) {
	id, ok := parseID(c)
	if !ok {
		return
	}

	if err := h.svc.Delete(c.Request.Context(), domain.ID(id)); err != nil {
		if errors.Is(err, domain.ErrNotFound) {
			c.JSON(http.StatusNotFound, gin.H{"error": "todo not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete todo"})
		return
	}

	c.Status(http.StatusNoContent)
}

// ─── Internal Helpers ─────────────────────────────────────────────────────────

// toResponse converts a domain.Todo to the API DTO.
func toResponse(t domain.Todo) TodoResponse {
	return TodoResponse{
		ID:          uint(t.ID()),
		Title:       t.Title().String(),
		Description: t.Description(),
		Completed:   t.IsCompleted(),
		CreatedAt:   t.CreatedAt(),
		UpdatedAt:   t.UpdatedAt(),
	}
}

// parseID extracts and validates the :id path parameter.
//
// Comparison with stdlib:
//   stdlib: r.PathValue("id")     (Go 1.22+)
//   Gin:    c.Param("id")
//
// The behavior is identical. The naming convention differs.
func parseID(c *gin.Context) (int64, bool) {
	raw := c.Param("id")
	id, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || id <= 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a positive integer"})
		return 0, false
	}
	return id, true
}

Step 8 — Wire Everything with uber-go/fx

This is the most dramatic transformation. In the stdlib version, main.go was a sequence of explicit constructor calls — readable, but fully manual. Every new dependency required you to remember to add it to main.go and pass it to the right constructor.

uber-go/fx is a dependency injection framework. You describe what each constructor needs and what it provides. The framework builds the dependency graph and calls the constructors in the right order. If a dependency is missing, you get a compile-time-like error at startup rather than a nil pointer dereference at runtime.

Let us look at the before and after side by side to understand what changes.

Stdlib main.go (manual wiring):

db, _ := sqlite.Open("todos.db")
repo, _ := sqlite.New(db)
svc := application.NewTodoService(repo)
handler := httpadapter.NewTodoHandler(svc)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
http.ListenAndServe(":8080", mux)

Fx main.go (container wiring):

fx.New(
    fx.Provide(openDB),
    fx.Provide(gormsqlite.NewTodoRepository),
    fx.Provide(application.NewTodoService),
    fx.Provide(ginhttp.NewTodoHandler),
    fx.Provide(newGinEngine),
    fx.Invoke(startServer),
).Run()

fx.Provide registers a constructor. fx.Invoke calls a function and triggers the creation of the entire dependency graph. When you add a new component — a cache, a second repository — you add one fx.Provide line. You do not trace through the constructor call sequence to find where to insert it.

touch cmd/server/main.go
// Package main is the composition root. Using uber-go/fx, dependencies are
// declared rather than manually wired. fx builds the graph, calls constructors
// in dependency order, and manages lifecycle hooks (startup and shutdown).
package main

import (
	"context"
	"database/sql"
	"embed"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/pressly/goose/v3"
	"go.uber.org/fx"
	"go.uber.org/zap"
	"gorm.io/gorm"
	gormlogger "gorm.io/gorm/logger"

	gormsqlitedriver "github.com/glebarez/sqlite"
	gormiosqlite "github.com/glebarez/sqlite"

	gormsqlite "github.com/sazardev/todo-api-packages/internal/adapters/gorm"
	ginhttp "github.com/sazardev/todo-api-packages/internal/adapters/http"
	"github.com/sazardev/todo-api-packages/internal/application"
)

// migrationsFS embeds the db/migrations directory into the binary at compile time.
// go:embed is a stdlib directive (Go 1.16+). The migration SQL files are compiled
// into the binary, so the deployed server does not need a separate migrations folder.
//
//go:embed db/migrations/*.sql
var migrationsFS embed.FS

func main() {
	app := fx.New(
		// fx.Provide registers constructors. fx infers argument types from function
		// signatures and wires them automatically.
		fx.Provide(newLogger),
		fx.Provide(newSQLDB),
		fx.Provide(newGormDB),
		fx.Provide(gormsqlite.NewTodoRepository),

		// application.NewTodoService returns *application.TodoService, but the repository
		// expects output.TodoRepository (an interface). fx performs the assignment automatically
		// because *application.TodoService satisfies input.TodoService.
		// The interface conversion is declared via fx.As:
		fx.Provide(
			fx.Annotate(
				gormsqlite.NewTodoRepository,
				fx.As(new(interface{ /* output.TodoRepository */ })),
			),
		),

		fx.Provide(application.NewTodoService),
		fx.Provide(ginhttp.NewTodoHandler),
		fx.Provide(newGinEngine),

		// fx.Invoke calls startServer to register lifecycle hooks. Unlike Provide,
		// Invoke runs eagerly — it is the entry point into the dependency graph.
		fx.Invoke(startServer),
	)
	app.Run()
}

// newLogger constructs a production zap logger.
// zap.NewProduction() outputs structured JSON logs — ideal for log aggregation
// (Datadog, Grafana Loki, AWS CloudWatch). In development, replace with
// zap.NewDevelopment() for human-readable colored output.
func newLogger() (*zap.Logger, error) {
	env := os.Getenv("APP_ENV")
	if env == "production" {
		return zap.NewProduction()
	}
	return zap.NewDevelopment()
}

// newSQLDB opens a raw *sql.DB for the Goose migration runner, which requires
// the standard database/sql interface rather than GORM's abstraction.
func newSQLDB() (*sql.DB, error) {
	dsn := os.Getenv("DATABASE_DSN")
	if dsn == "" {
		dsn = "todos.db"
	}
	return gormsqlitedriver.Open(dsn)
}

// newGormDB constructs a GORM *gorm.DB using the already-opened *sql.DB.
// Reusing the same underlying connection means migrations and GORM queries
// share the same file handle — no "database is locked" conflicts.
func newGormDB(sqlDB *sql.DB, logger *zap.Logger) (*gorm.DB, error) {
	db, err := gorm.Open(gormiosqlite.New(gormiosqlite.Config{
		DriverName: "sqlite",
		DSN:        sqlDB,
	}), &gorm.Config{
		// Silence GORM's own logger in production; zap handles logging.
		Logger: gormlogger.Default.LogMode(gormlogger.Silent),
	})
	return db, err
}

// newGinEngine creates and configures the Gin router.
// gin.New() returns a bare engine with no default middleware.
// We add our own structured-logging and recovery middleware instead of
// Gin's built-in ones, which print to stdout without structure.
func newGinEngine(logger *zap.Logger) *gin.Engine {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(ginhttp.Recovery(logger))
	r.Use(ginhttp.RequestLogger(logger))
	return r
}

// startServer wires the HTTP handler into the Gin engine, runs Goose migrations,
// and registers fx lifecycle hooks for graceful startup and shutdown.
//
// fx.Lifecycle is the fx mechanism for registering startup/shutdown functions.
// OnStart runs when fx.App.Run() is called. OnStop runs when the process
// receives a termination signal (SIGINT, SIGTERM). This is how you get graceful
// shutdown without polling or manual signal handling.
func startServer(
	lc fx.Lifecycle,
	handler *ginhttp.TodoHandler,
	engine *gin.Engine,
	sqlDB *sql.DB,
	logger *zap.Logger,
) {
	// Register HTTP routes.
	v1 := engine.Group("/api/v1")
	handler.RegisterRoutes(v1)

	srv := &http.Server{
		Addr:    ":8080",
		Handler: engine,
	}

	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			// Run migrations before accepting traffic.
			goose.SetBaseFS(migrationsFS)
			if err := goose.SetDialect("sqlite3"); err != nil {
				return err
			}
			if err := goose.Up(sqlDB, "db/migrations"); err != nil {
				return err
			}
			logger.Info("migrations applied")

			// Start the HTTP server in a goroutine so OnStart returns immediately.
			// If we called srv.ListenAndServe() directly here, it would block and
			// prevent fx from completing the startup sequence.
			go func() {
				logger.Info("server starting", zap.String("addr", srv.Addr))
				if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
					logger.Fatal("server error", zap.Error(err))
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			// Graceful shutdown: drain inflight requests before exiting.
			// The context deadline is set by fx (default: 15 seconds).
			logger.Info("server stopping")
			return srv.Shutdown(ctx)
		},
	})
}

There is one part of the fx wiring above that requires a cleaner approach in a real project. The fx.As annotation for interface registration works differently depending on your Go and fx versions. The simpler and more idiomatic pattern is to wrap the constructor to return the interface:

// In a real project, provide the concrete type. fx resolves the interface
// injection through constructor signatures automatically when the concrete
// type satisfies the required interface in the dependent constructor's parameters.
//
// application.NewTodoService(repo output.TodoRepository) *TodoService
// gormsqlite.NewTodoRepository(db *gorm.DB) *TodoRepository
//
// fx sees that NewTodoService needs output.TodoRepository.
// It sees that *gormsqlite.TodoRepository satisfies output.TodoRepository.
// The wiring resolves automatically — no explicit fx.As needed.

This is one place where fx requires you to understand Go’s interface system precisely. When types satisfy interfaces implicitly (as they always do in Go), fx resolves the assignment. If you provide *gormsqlite.TodoRepository and some constructor requires output.TodoRepository, fx wires it correctly as long as the concrete type implements the interface.


Step 9 — Run the Application

go mod tidy
go run ./cmd/server

Expected output (zap development mode):

2026-04-02T10:00:00.000+0700    INFO    migrations applied
2026-04-02T10:00:00.001+0700    INFO    server starting {"addr": ":8080"}

The routes are now under /api/v1:

# Create
curl -s -X POST http://localhost:8080/api/v1/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Gin","description":"Build something real"}' | jq

# List
curl -s http://localhost:8080/api/v1/todos | jq

# Get one
curl -s http://localhost:8080/api/v1/todos/1 | jq

# Update
curl -s -X PUT http://localhost:8080/api/v1/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Gin","completed":true}' | jq

# Delete
curl -s -X DELETE http://localhost:8080/api/v1/todos/1 -o /dev/null -w "%{http_code}\n"

# Validation error (binding:"required" catches empty title)
curl -s -X POST http://localhost:8080/api/v1/todos \
  -H "Content-Type: application/json" \
  -d '{}' | jq

The last command should produce:

{
  "error": "Key: 'CreateRequest.Title' Error:Field validation for 'Title' failed on the 'required' tag"
}

That error message comes from go-playground/validator. It is more verbose than the domain’s "title cannot be empty", but it also activates before the application layer is ever called, which means invalid requests never reach business logic.


Step 10 — Test the GORM Adapter

The application tests from the previous post still run against the mock repository. The GORM adapter gets its own integration test using an in-memory SQLite database:

touch internal/adapters/gorm/repository_test.go
package gormsqlite_test

import (
	"context"
	"errors"
	"testing"

	"gorm.io/gorm"
	gormiosqlite "github.com/glebarez/sqlite"

	gormsqlite "github.com/sazardev/todo-api-packages/internal/adapters/gorm"
	"github.com/sazardev/todo-api-packages/internal/domain"
)

// newTestDB creates an in-memory GORM database and runs AutoMigrate for tests.
// AutoMigrate is appropriate here because we own both the model and the test schema.
// In production, real migrations (Goose) handle this.
func newTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	db, err := gorm.Open(gormiosqlite.Open(":memory:"), &gorm.Config{})
	if err != nil {
		t.Fatalf("open in-memory db: %v", err)
	}
	if err := db.AutoMigrate(&gormsqlite.TodoModel{}); err != nil {
		t.Fatalf("auto migrate: %v", err)
	}
	return db
}

func newTestRepo(t *testing.T) *gormsqlite.TodoRepository {
	t.Helper()
	return gormsqlite.NewTodoRepository(newTestDB(t))
}

func TestTodoRepository_CRUD(t *testing.T) {
	t.Run("given a new todo, Save returns it with an assigned ID", func(t *testing.T) {
		repo := newTestRepo(t)
		title, _ := domain.NewTitle("GORM save test")
		todo := domain.NewTodo(title, "testing GORM")

		saved, err := repo.Save(context.Background(), todo)

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if saved.ID() == 0 {
			t.Error("expected non-zero ID after save")
		}
	})

	t.Run("given a missing ID, FindByID returns ErrNotFound", func(t *testing.T) {
		repo := newTestRepo(t)

		_, err := repo.FindByID(context.Background(), domain.ID(9999))

		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound, got %v", err)
		}
	})

	t.Run("given two saved todos, FindAll returns both", func(t *testing.T) {
		repo := newTestRepo(t)
		t1, _ := domain.NewTitle("First")
		t2, _ := domain.NewTitle("Second")
		repo.Save(context.Background(), domain.NewTodo(t1, ""))
		repo.Save(context.Background(), domain.NewTodo(t2, ""))

		todos, err := repo.FindAll(context.Background())

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if len(todos) != 2 {
			t.Errorf("expected 2 todos, got %d", len(todos))
		}
	})

	t.Run("given a saved todo, Delete removes it", func(t *testing.T) {
		repo := newTestRepo(t)
		title, _ := domain.NewTitle("Delete me")
		saved, _ := repo.Save(context.Background(), domain.NewTodo(title, ""))

		if err := repo.Delete(context.Background(), saved.ID()); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		_, err := repo.FindByID(context.Background(), saved.ID())
		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound after delete, got %v", err)
		}
	})
}

Run the full suite:

go test ./...

The Honest Comparison

Now that both versions exist, here is an objective breakdown of what the packages changed.

Lines of Code

ComponentStdlibPackagesChange
Domain~120~1200%
Ports~50~500%
Application~90~900%
SQLite adapter~215~95-55%
HTTP adapter~170~130-24%
main.go~50~110+120%
Migrationsinline~15new file

The adapters shrank. The main.go grew — fx trades wiring simplicity for configuration verbosity. In a two-service application, fx is overhead. In a twenty-service application with shared dependencies (logger, config, metrics), fx becomes a significant advantage.

Where Each Package Earns Its Weight

Gin earns its weight in handler ergonomics. c.ShouldBindJSON + validator tags replace json.NewDecoder + manual field checks + manual error responses. The middleware system (logging, recovery, auth, rate-limiting) applies cleanly to route groups. The router’s performance advantage over http.ServeMux is real but irrelevant below tens of thousands of requests per second.

GORM earns its weight when data models are complex — associations, preloads, hooks, transactions across multiple tables. For a single-table TODO API, GORM is slight overkill. But the moment you add users, tags, and attachments with relationships, the ORM pays for itself in every query you do not write by hand.

Goose earns its weight immediately. SQL migrations with rollback capability are not optional in a production application. The stdlib CREATE TABLE IF NOT EXISTS approach has no rollback path.

Zap earns its weight in production observability. Structured JSON logs that include method, path, status, latency, and request_id can be queried in a log aggregator in ways that plain text cannot. log/slog (stdlib, Go 1.21) closes this gap significantly — if you are on 1.21+, the stdlib logger is a serious contender.

go-playground/validator earns its weight in API surface area. Validating twenty fields across a complex request body with tags is more maintainable than twenty if-statements. For a three-field TODO it is borderline. Its real strength shows in forms with conditional rules, cross-field validation, and custom validators.

uber-go/fx earns its weight in large teams where manual main.go wiring becomes a source of merge conflicts and initialization order bugs. For solo or small-team projects, explicit manual wiring (main.go from the stdlib post) is more readable and debuggable.

Where Packages Add Complexity

GORM adds a reflection layer between your code and SQL. When a query performs unexpectedly, you need to call .Debug() to see the generated SQL, then map that back to the GORM call that produced it. In databases with complex query plans, this indirection costs time.

Fx adds a framework-specific mental model. A new team member who understands Go but not fx will find main.go confusing before they find it elegant. The error messages when a dependency is missing are good but not perfect.

Gin’s release mode disables certain debug features, but in development mode it generates output that can pollute test output if you do not configure the logger properly. This is a small friction point but a real one.


When to Choose Which Approach

The stdlib approach is the right default when:

  • The team is learning Go. Native patterns teach the language.
  • The service is small and lifecycle is short.
  • You must minimize binary size and startup time (Lambda, edge functions).
  • You need precise control over every HTTP behavior.

The packages approach is the right choice when:

  • The API has dozens of endpoints with complex validation.
  • You are joining an existing team where Gin and GORM are already present.
  • The data model has relationships and you want an ORM’s association handling.
  • You need rollback-capable migrations from day one.
  • The team will grow and shared application setup creates coordination overhead.

Neither is universally correct. The architecture — hexagonal, DDD, ports and adapters — remains correct in both cases. That is the point. Good architecture lets you make the packages decision as a deployment detail, not as a design foundation.


Closing

Two posts, one set of domain rules, two completely different adapter implementations. The domain files did not change. The application files did not change. The tests of both still pass.

That is the proof of concept, not a theoretical argument. When people say “use clean architecture so you can swap implementations,” they mean exactly this: write domain.ErrNotFound once, and let Gin, net/http, GORM, database/sql, or whatever replaces them next year translate it as needed.

The packages did not make the code better. The architecture did. The packages made some parts of the code shorter.

A framework is a tool. The architecture is the decision. Knowing the difference determines which one you own and which one owns you.