Building a REST API with Go from scratch: clean structure and maintainable code

Tutorial to build a REST API in Go with a solid structure: handlers, services, repositories, errors, and configuration.

Cover for Building a REST API with Go from scratch: clean structure and maintainable code

Every Go tutorial builds a task API. This one does too. But the goal here is not just to get the CRUD working: it’s to make the project structure make sense when you come back to it a week later and need to add an endpoint without breaking three files.

I’ve seen too many Go projects where everything lives in main.go, handlers access the database directly, and error handling is a fmt.Println followed by an http.Error with a generic message. It works, sure. Until the project grows and every change becomes an archaeology session.

What we’re going to build is a task (TODO) API with real separation between layers: handlers for HTTP, services for business logic, repositories for data access, models for the domain, and an error system that doesn’t make you guess what went wrong. This isn’t enterprise architecture — it’s the minimum you need to keep the code maintainable.


Project structure

Before writing a single line of code, the most important decision is how you organize your files. Go doesn’t force any particular structure on you, which is liberating and dangerous in equal measure. If you want to dive deeper into this, I have a dedicated article on Go project structure, but for this API we’ll go with something straightforward.

todo-api/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── handler/
│   │   └── task.go
│   ├── model/
│   │   └── task.go
│   ├── repository/
│   │   └── task.go
│   ├── service/
│   │   └── task.go
│   └── apperror/
│       └── errors.go
├── go.mod
└── go.sum

There are deliberate decisions here:

  • cmd/server/: The entry point. If tomorrow you need a CLI or a worker, you add cmd/cli/ and cmd/worker/ without touching anything else.
  • internal/: Everything that shouldn’t be imported from outside the module. Go enforces this at the compiler level — it’s not an optional convention.
  • Packages by responsibility, not by feature: handler, service, repository. Not task/handler.go, task/service.go. In a small project it’s clearer, and when it grows, refactoring to feature-based is mechanical.

Initialize the module:

go mod init github.com/your-username/todo-api

Models: define the domain first

Before thinking about HTTP or databases, you need to know what a task is in your domain. It’s not a JSON, it’s not a database row. It’s a Go type with clear fields.

// internal/model/task.go
package model

import "time"

type Task struct {
	ID          int       `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	Done        bool      `json:"done"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

type CreateTaskRequest struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

type UpdateTaskRequest struct {
	Title       *string `json:"title"`
	Description *string `json:"description"`
	Done        *bool   `json:"done"`
}

There are important decisions here:

  • Task is the domain model. It has all fields, including the ones generated by the system (ID, CreatedAt, UpdatedAt).
  • CreateTaskRequest has no ID or timestamps. The user doesn’t set those fields. Separating the creation model from the domain model prevents bugs where someone sends an id in the body and expects it to be respected.
  • UpdateTaskRequest uses pointers. A *string can be nil, which lets you distinguish between “the user didn’t send this field” and “the user sent an empty string”. Without pointers, you can’t do partial updates correctly.

That pointer detail in the update is something many tutorials ignore, and it’s one of the first things that bites you in production.


The repository: data access as abstraction

The repository is the layer that knows how to store and retrieve tasks. For now we’ll use in-memory storage. Not because it’s useful in production, but because decoupling this layer from the start means that switching to PostgreSQL later is changing one implementation, not rewriting half the project.

// internal/repository/task.go
package repository

import (
	"fmt"
	"sync"
	"time"

	"github.com/your-username/todo-api/internal/model"
)

type TaskRepository interface {
	GetAll() ([]model.Task, error)
	GetByID(id int) (*model.Task, error)
	Create(task *model.Task) error
	Update(task *model.Task) error
	Delete(id int) error
}

type InMemoryTaskRepository struct {
	mu     sync.RWMutex
	tasks  map[int]model.Task
	nextID int
}

func NewInMemoryTaskRepository() *InMemoryTaskRepository {
	return &InMemoryTaskRepository{
		tasks:  make(map[int]model.Task),
		nextID: 1,
	}
}

The TaskRepository interface is the contract. Any implementation that fulfills those five methods works. This isn’t over-engineering: it’s what lets you write tests without a database and change storage without touching business logic.

Now the concrete implementations:

func (r *InMemoryTaskRepository) GetAll() ([]model.Task, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	tasks := make([]model.Task, 0, len(r.tasks))
	for _, t := range r.tasks {
		tasks = append(tasks, t)
	}
	return tasks, nil
}

func (r *InMemoryTaskRepository) GetByID(id int) (*model.Task, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	task, exists := r.tasks[id]
	if !exists {
		return nil, fmt.Errorf("task with id %d not found", id)
	}
	return &task, nil
}

func (r *InMemoryTaskRepository) Create(task *model.Task) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	task.ID = r.nextID
	task.CreatedAt = time.Now()
	task.UpdatedAt = time.Now()
	r.tasks[task.ID] = *task
	r.nextID++
	return nil
}

func (r *InMemoryTaskRepository) Update(task *model.Task) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, exists := r.tasks[task.ID]; !exists {
		return fmt.Errorf("task with id %d not found", task.ID)
	}
	task.UpdatedAt = time.Now()
	r.tasks[task.ID] = *task
	return nil
}

func (r *InMemoryTaskRepository) Delete(id int) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, exists := r.tasks[id]; !exists {
		return fmt.Errorf("task with id %d not found", id)
	}
	delete(r.tasks, id)
	return nil
}

The sync.RWMutex is necessary because an HTTP server handles concurrent requests. Without it, two simultaneous writes to the map cause a data race that Go detects at runtime with a panic. This is something you won’t see in development with a single client, but that blows up in production.

RLock for reads (allows multiple concurrent readers) and Lock for writes (exclusive access). That’s the difference between a server that scales and one that serializes everything.


Error handling: don’t let HTTP dictate your logic

This is the point where most tutorials fail. They return http.StatusInternalServerError for everything, or worse, let the handler decide which HTTP code corresponds to each repository error. That couples the layers and forces you to import net/http in places it shouldn’t be.

The solution is to define your own error types in the domain:

// internal/apperror/errors.go
package apperror

import "fmt"

type ErrorType int

const (
	NotFound ErrorType = iota
	Validation
	Conflict
	Internal
)

type AppError struct {
	Type    ErrorType
	Message string
	Err     error
}

func (e *AppError) Error() string {
	if e.Err != nil {
		return fmt.Sprintf("%s: %v", e.Message, e.Err)
	}
	return e.Message
}

func (e *AppError) Unwrap() error {
	return e.Err
}

func NewNotFound(msg string) *AppError {
	return &AppError{Type: NotFound, Message: msg}
}

func NewValidation(msg string) *AppError {
	return &AppError{Type: Validation, Message: msg}
}

func NewInternal(msg string, err error) *AppError {
	return &AppError{Type: Internal, Message: msg, Err: err}
}

Now the service can return apperror.NewNotFound("task not found") without knowing anything about HTTP, and the handler can map NotFound to 404, Validation to 400, and so on. Each layer speaks its own language.

If you want to go deeper into Go error handling patterns, I recommend spending time on it because it’s one of the parts of the language that most affects code quality in the long run.


The service: business logic separate from HTTP

The service is where logic lives that is neither HTTP nor data access. Business validations, transformations, rules that apply regardless of whether the request comes from a REST API, a CLI, or a test.

// internal/service/task.go
package service

import (
	"errors"
	"strings"

	"github.com/your-username/todo-api/internal/apperror"
	"github.com/your-username/todo-api/internal/model"
	"github.com/your-username/todo-api/internal/repository"
)

type TaskService struct {
	repo repository.TaskRepository
}

func NewTaskService(repo repository.TaskRepository) *TaskService {
	return &TaskService{repo: repo}
}

func (s *TaskService) GetAll() ([]model.Task, error) {
	return s.repo.GetAll()
}

func (s *TaskService) GetByID(id int) (*model.Task, error) {
	task, err := s.repo.GetByID(id)
	if err != nil {
		return nil, apperror.NewNotFound("task not found")
	}
	return task, nil
}

func (s *TaskService) Create(req model.CreateTaskRequest) (*model.Task, error) {
	if strings.TrimSpace(req.Title) == "" {
		return nil, apperror.NewValidation("title is required")
	}

	task := &model.Task{
		Title:       strings.TrimSpace(req.Title),
		Description: strings.TrimSpace(req.Description),
		Done:        false,
	}

	if err := s.repo.Create(task); err != nil {
		return nil, apperror.NewInternal("failed to create task", err)
	}

	return task, nil
}

func (s *TaskService) Update(id int, req model.UpdateTaskRequest) (*model.Task, error) {
	task, err := s.repo.GetByID(id)
	if err != nil {
		return nil, apperror.NewNotFound("task not found")
	}

	if req.Title != nil {
		title := strings.TrimSpace(*req.Title)
		if title == "" {
			return nil, apperror.NewValidation("title cannot be empty")
		}
		task.Title = title
	}
	if req.Description != nil {
		task.Description = strings.TrimSpace(*req.Description)
	}
	if req.Done != nil {
		task.Done = *req.Done
	}

	if err := s.repo.Update(task); err != nil {
		return nil, apperror.NewInternal("failed to update task", err)
	}

	return task, nil
}

func (s *TaskService) Delete(id int) error {
	_, err := s.repo.GetByID(id)
	if err != nil {
		return apperror.NewNotFound("task not found")
	}

	if err := s.repo.Delete(id); err != nil {
		return apperror.NewInternal("failed to delete task", err)
	}

	return nil
}

Notice a few things:

  • The service receives the TaskRepository interface, not the concrete implementation. This means that in tests you can pass a mock without touching anything.
  • Validations are in the service, not in the handler. The handler only parses the JSON and calls the service. If tomorrow you add a CLI that creates tasks, validations still apply.
  • Update uses the pointers from UpdateTaskRequest. It only modifies the fields the user sent. A real PATCH, not a PUT in disguise.
  • Repository errors are transformed into AppError. The handler never sees a raw fmt.Errorf from the repo.

Also notice that GetAll doesn’t transform the error. If the repository fails, the error propagates as-is. You don’t always need to wrap every error: sometimes transparency is better than ceremony.


Handlers: the HTTP layer

The handler is the boundary between HTTP and your domain. Its job is simple: read the request, call the service, write the response. No business logic here.

// internal/handler/task.go
package handler

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

	"github.com/your-username/todo-api/internal/apperror"
	"github.com/your-username/todo-api/internal/model"
	"github.com/your-username/todo-api/internal/service"
)

type TaskHandler struct {
	service *service.TaskService
}

func NewTaskHandler(service *service.TaskService) *TaskHandler {
	return &TaskHandler{service: service}
}

type ErrorResponse struct {
	Error string `json:"error"`
}

func (h *TaskHandler) writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func (h *TaskHandler) writeError(w http.ResponseWriter, err error) {
	var appErr *apperror.AppError
	if errors.As(err, &appErr) {
		status := http.StatusInternalServerError
		switch appErr.Type {
		case apperror.NotFound:
			status = http.StatusNotFound
		case apperror.Validation:
			status = http.StatusBadRequest
		case apperror.Conflict:
			status = http.StatusConflict
		}
		h.writeJSON(w, status, ErrorResponse{Error: appErr.Message})
		return
	}
	h.writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
}

The writeError method is what does the translation between domain errors and HTTP status codes. A clean switch, no nested ifs, easy to extend. When you add a new error type (for example, Unauthorized), you just add a case here.

Now the concrete handlers:

func (h *TaskHandler) GetAll(w http.ResponseWriter, r *http.Request) {
	tasks, err := h.service.GetAll()
	if err != nil {
		h.writeError(w, err)
		return
	}
	h.writeJSON(w, http.StatusOK, tasks)
}

func (h *TaskHandler) GetByID(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
		return
	}

	task, err := h.service.GetByID(id)
	if err != nil {
		h.writeError(w, err)
		return
	}
	h.writeJSON(w, http.StatusOK, task)
}

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req model.CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
		return
	}

	task, err := h.service.Create(req)
	if err != nil {
		h.writeError(w, err)
		return
	}
	h.writeJSON(w, http.StatusCreated, task)
}

func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
		return
	}

	var req model.UpdateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"})
		return
	}

	task, err := h.service.Update(id, req)
	if err != nil {
		h.writeError(w, err)
		return
	}
	h.writeJSON(w, http.StatusOK, task)
}

func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		h.writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid id"})
		return
	}

	if err := h.service.Delete(id); err != nil {
		h.writeError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

Each handler follows the same pattern: parse input, call the service, return result or error. No business logic. No data access. If you read a handler and need more than 10 seconds to understand what it does, something is wrong.

One important detail: r.PathValue("id") is Go 1.22+, which added native support for path parameters. Before that you needed an external router like Gorilla Mux or Chi for something this basic. If you’re on an older version, you’ll need a third-party router or parse the URL by hand.


Routing: connecting routes and handlers

With Go 1.22, the standard library net/http supports HTTP methods and path parameters. This means that for an API like ours, you don’t need Gin, Chi, or any external framework.

func setupRoutes(taskHandler *handler.TaskHandler) *http.ServeMux {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/tasks", taskHandler.GetAll)
	mux.HandleFunc("GET /api/tasks/{id}", taskHandler.GetByID)
	mux.HandleFunc("POST /api/tasks", taskHandler.Create)
	mux.HandleFunc("PUT /api/tasks/{id}", taskHandler.Update)
	mux.HandleFunc("DELETE /api/tasks/{id}", taskHandler.Delete)

	return mux
}

Clean, no magic, no dependencies. The pattern "GET /api/tasks/{id}" tells the mux to only match GET requests with that path, and to extract {id} as a parameter accessible via r.PathValue("id").

What if you prefer Gin?

If your API is going to grow and you need more sophisticated middleware, binding validation, or simply prefer Gin’s ergonomics, the adaptation is straightforward:

func setupGinRoutes(taskHandler *handler.TaskHandler) *gin.Engine {
	r := gin.Default()

	api := r.Group("/api")
	{
		api.GET("/tasks", wrapHandler(taskHandler.GetAll))
		api.GET("/tasks/:id", wrapHandler(taskHandler.GetByID))
		api.POST("/tasks", wrapHandler(taskHandler.Create))
		api.PUT("/tasks/:id", wrapHandler(taskHandler.Update))
		api.DELETE("/tasks/:id", wrapHandler(taskHandler.Delete))
	}

	return r
}

func wrapHandler(h http.HandlerFunc) gin.HandlerFunc {
	return func(c *gin.Context) {
		h(c.Writer, c.Request)
	}
}

But my recommendation for a new API on Go 1.22+: start with the standard library. Add dependencies when you need them, not before. Go has that virtue: the stdlib is enough for 80% of cases.


Configuration: environment variables

Hardcoded configuration is the source of half of all deployment bugs. Server port, database URLs, timeouts: all of that has to come from the environment.

// internal/config/config.go
package config

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Port            int
	ReadTimeout     time.Duration
	WriteTimeout    time.Duration
	ShutdownTimeout time.Duration
}

func Load() (*Config, error) {
	port, err := getEnvInt("PORT", 8080)
	if err != nil {
		return nil, fmt.Errorf("invalid PORT: %w", err)
	}

	readTimeout, err := getEnvDuration("READ_TIMEOUT", 5*time.Second)
	if err != nil {
		return nil, fmt.Errorf("invalid READ_TIMEOUT: %w", err)
	}

	writeTimeout, err := getEnvDuration("WRITE_TIMEOUT", 10*time.Second)
	if err != nil {
		return nil, fmt.Errorf("invalid WRITE_TIMEOUT: %w", err)
	}

	shutdownTimeout, err := getEnvDuration("SHUTDOWN_TIMEOUT", 15*time.Second)
	if err != nil {
		return nil, fmt.Errorf("invalid SHUTDOWN_TIMEOUT: %w", err)
	}

	return &Config{
		Port:            port,
		ReadTimeout:     readTimeout,
		WriteTimeout:    writeTimeout,
		ShutdownTimeout: shutdownTimeout,
	}, nil
}

func getEnvInt(key string, defaultVal int) (int, error) {
	val, exists := os.LookupEnv(key)
	if !exists {
		return defaultVal, nil
	}
	return strconv.Atoi(val)
}

func getEnvDuration(key string, defaultVal time.Duration) (time.Duration, error) {
	val, exists := os.LookupEnv(key)
	if !exists {
		return defaultVal, nil
	}
	return time.ParseDuration(val)
}

Each value has a sensible default, and if someone passes an invalid value, the server fails at startup with a clear message instead of behaving unpredictably at runtime.

I don’t use libraries like Viper or godotenv here. For an API with four variables, os.LookupEnv and helper functions are more than enough. When you have 20 variables and need complex validation, then evaluate whether a library adds anything.


main.go: wiring everything together

main.go is the only place where all layers know about each other. This is where you do manual dependency wiring. There’s no automatic dependency injection like in Spring, and that’s an advantage: you can read main and know exactly how the server is assembled.

// cmd/server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"

	"github.com/your-username/todo-api/internal/config"
	"github.com/your-username/todo-api/internal/handler"
	"github.com/your-username/todo-api/internal/repository"
	"github.com/your-username/todo-api/internal/service"
)

func main() {
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("failed to load config: %v", err)
	}

	// Dependency wiring
	taskRepo := repository.NewInMemoryTaskRepository()
	taskService := service.NewTaskService(taskRepo)
	taskHandler := handler.NewTaskHandler(taskService)

	// Routes
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/tasks", taskHandler.GetAll)
	mux.HandleFunc("GET /api/tasks/{id}", taskHandler.GetByID)
	mux.HandleFunc("POST /api/tasks", taskHandler.Create)
	mux.HandleFunc("PUT /api/tasks/{id}", taskHandler.Update)
	mux.HandleFunc("DELETE /api/tasks/{id}", taskHandler.Delete)

	// Server with timeouts
	server := &http.Server{
		Addr:         fmt.Sprintf(":%d", cfg.Port),
		Handler:      mux,
		ReadTimeout:  cfg.ReadTimeout,
		WriteTimeout: cfg.WriteTimeout,
	}

	// Graceful shutdown
	go func() {
		sigChan := make(chan os.Signal, 1)
		signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
		<-sigChan

		log.Println("shutting down server...")
		ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
		defer cancel()

		if err := server.Shutdown(ctx); err != nil {
			log.Printf("server shutdown error: %v", err)
		}
	}()

	log.Printf("server starting on port %d", cfg.Port)
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatalf("server error: %v", err)
	}
	log.Println("server stopped")
}

Three things many tutorials omit that are here from the start:

Server timeouts. An http.Server without ReadTimeout or WriteTimeout accepts connections that can stay open indefinitely. In production, that’s a trivial attack vector (slowloris) and a resource leak.

Graceful shutdown. When the process receives a SIGINT or SIGTERM (which is what Kubernetes, Docker, or a Ctrl+C sends), it doesn’t cut open connections abruptly. It gives the server time to finish in-flight requests. Without this, your users see 502 errors every time you deploy.

Explicit wiring. Repo -> Service -> Handler. You can read those three lines and understand the entire dependency chain. No IoC containers, no reflection, no autoconfiguration guessing what to inject. In Go, this is a feature.


Testing the API

Start the server:

go run ./cmd/server/

And test with curl:

# Create a task
curl -s -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Go", "description": "Build a REST API"}' | jq

# Response:
# {
#   "id": 1,
#   "title": "Learn Go",
#   "description": "Build a REST API",
#   "done": false,
#   "created_at": "2026-07-02T10:30:00Z",
#   "updated_at": "2026-07-02T10:30:00Z"
# }

# List all tasks
curl -s http://localhost:8080/api/tasks | jq

# Get a task by ID
curl -s http://localhost:8080/api/tasks/1 | jq

# Partial update (only mark as done)
curl -s -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"done": true}' | jq

# Delete a task
curl -s -X DELETE http://localhost:8080/api/tasks/1 -w "\n%{http_code}\n"
# 204

# Try to get a task that doesn't exist
curl -s http://localhost:8080/api/tasks/999 | jq
# {
#   "error": "task not found"
# }

The API returns consistent JSON both on success and on error. No default HTML error pages, no raw Go messages. Every response has a predictable format that a frontend or a client can parse without special cases.


What’s missing and why

This article covers the structure, not every production feature. There are things deliberately left out that you’ll need before deploying:

Logging middleware. Every request should log method, path, status code, and duration. With net/http you can write a middleware that wraps the handler in 15 lines.

Real database. The in-memory repository is to demonstrate the pattern. For production you need PostgreSQL or another database behind the same TaskRepository interface.

More robust validation. The service validates that the title isn’t empty, but in a real project you need length and format validation, and probably a library like go-playground/validator.

Tests. Thanks to the interfaces, testing each layer is straightforward: mock the repository to test the service, mock the service to test the handler. It deserves its own article on Go testing.

Docker. To deploy this you need a multi-stage Dockerfile that compiles the binary and runs it in a minimal image. I cover that in dockerizing a Go API.

Authentication and authorization. JWT middleware, API keys, or whatever your use case needs. It’s not here because it’s orthogonal to the structure, but it’s essential before exposing the API.


The big picture

Let’s zoom out. The full flow of an HTTP request through this architecture is:

HTTP Request


  Handler          → Parses request, calls service, writes response


  Service          → Validates, applies business logic, calls repository


  Repository       → Reads/writes data (memory, PostgreSQL, whatever)


  Model            → Domain types that flow through all layers


  AppError         → Typed errors that the handler translates to HTTP

Each layer has a single responsibility. Each layer communicates with the next through interfaces or shared types. No layer knows how the others work internally.

This is not hexagonal architecture or clean architecture with ports and adapters and 47 interfaces. It’s basic separation of concerns using the tools Go gives you. And for 90% of the APIs you’re going to build, it’s enough.

The key isn’t the structure itself — it’s the discipline to maintain it. When you’re in a hurry and want to put a SQL query inside a handler “just this once”, remember that exception becomes the norm within two sprints.


What’s next

From this foundation, the natural next steps are:

  1. Add real persistence with PostgreSQL: implement TaskRepository with database/sql or sqlx.
  2. Write tests for each layer taking advantage of the interfaces you already have. I cover this in detail in Go testing.
  3. Containerize the application with a multi-stage Dockerfile to get a 15MB binary in a scratch image. See dockerize Go API.
  4. Add middleware for logging, recovery, and CORS.
  5. Document the API with OpenAPI/Swagger if other teams are going to consume it.

The structure we’ve built supports all these changes without rewriting what already works. That was the goal from the start: not to build the prettiest TODO in the world, but one you can keep maintaining.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved