DEV Community

A0mineTV
A0mineTV

Posted on

Building a Clean REST API in Go — No Frameworks, No Fuss

Go's standard library is powerful enough to build a production-ready REST API without reaching for a framework. In this post, we'll walk through a small but well-structured Notes API that covers all CRUD operations — and the architectural decisions behind it.

What we're building

A simple API to manage notes, with five routes:

Method Path Description
POST /notes Create a note
GET /notes List all notes
GET /notes/{id} Get a note by ID
PUT /notes/{id} Update a note
DELETE /notes/{id} Delete a note

Project structure

notes-api/
├── cmd/api/          # Entry point
├── internal/
│   ├── httpapi/      # HTTP handlers
│   ├── note/         # Model, service, storage interface
│   └── storage/      # In-memory implementation
Enter fullscreen mode Exit fullscreen mode

Three distinct layers: HTTP, business logic, and storage. Each one
knows nothing about the others except through interfaces.


The model

type Note struct {
    ID        int64     `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}
Enter fullscreen mode Exit fullscreen mode

Simple and flat. Timestamps are managed by the service, not by the caller.


The storage interface

type Store interface {
    Create(ctx context.Context, n Note) (Note, error)
    List(ctx context.Context) ([]Note, error)
    GetByID(ctx context.Context, id int64) (Note, error)
    Update(ctx context.Context, id int64, title, content string) (Note, error)
    Delete(ctx context.Context, id int64) error
}
Enter fullscreen mode Exit fullscreen mode

Defining a Store interface in the note package (not in storage) is a key decision: the business logic owns the contract, and the storage implementation satisfies it. This makes swapping backends (PostgreSQL, Redis, etc.) trivial later.


The service layer

var ErrInvalidTitle = errors.New("invalid title")
var ErrNotFound = errors.New("not found")

func (s *Service) Create(ctx context.Context, title, content string) (Note, error) {
    if title == "" {
        return Note{}, ErrInvalidTitle
    }
    now := time.Now()
    n := Note{
        Title:     title,
        Content:   content,
        CreatedAt: now,
        UpdatedAt: now,
    }
    return s.store.Create(ctx, n)
}
Enter fullscreen mode Exit fullscreen mode

The service is where business rules live. Here it validates that the title is non-empty and stamps the timestamps before delegating to the store. Typed sentinel errors (ErrInvalidTitle, ErrNotFound) let the HTTP layer map them to the right status codes without leaking implementation details.


The in-memory storage

type MemoryStorage struct {
    mu     sync.RWMutex
    notes  []note.Note
    nextID int64
}
Enter fullscreen mode Exit fullscreen mode

A slice protected by a sync.RWMutex. Read operations (List, GetByID) use RLock to allow concurrent reads; write operations use Lock for exclusive access. Note that the returned slice from List is a copy — so callers can't accidentally mutate the internal state:

func (s *MemoryStorage) List(ctx context.Context) ([]note.Note, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    out := make([]note.Note, len(s.notes))
    copy(out, s.notes)
    return out, nil
}
Enter fullscreen mode Exit fullscreen mode

The HTTP handler

Routing is done with the standard http.ServeMux. Two handler functions cover
all five routes:

func (h *Handler) Register(mux *http.ServeMux) {
    mux.HandleFunc("/notes", h.handleNotes)
    mux.HandleFunc("/notes/", h.handleNoteByID)
}
Enter fullscreen mode Exit fullscreen mode

/notes handles GET (list) and POST (create). /notes/ catches everything
with an ID suffix and dispatches on the method:

func (h *Handler) handleNoteByID(w http.ResponseWriter, r *http.Request) {
    id, err := parseIDFromPath(r.URL.Path)
    if err != nil {
        http.Error(w, "Invalid note ID", http.StatusBadRequest)
        return
    }
    switch r.Method {
    case http.MethodGet:
        h.getNote(w, r, id)
    case http.MethodPut:
        h.updateNote(w, r, id)
    case http.MethodDelete:
        h.deleteNote(w, r, id)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}
Enter fullscreen mode Exit fullscreen mode

Service errors are translated to HTTP status codes in one place:

func handleServiceError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, note.ErrInvalidTitle):
        http.Error(w, "Invalid title", http.StatusBadRequest)
    case errors.Is(err, note.ErrNotFound):
        http.Error(w, "Note not found", http.StatusNotFound)
    default:
        http.Error(w, "Internal server error", http.StatusInternalServerError)
    }
}
Enter fullscreen mode Exit fullscreen mode

Wiring it all together

func main() {
    store := storage.NewMemoryStore()
    service := note.NewService(store)
    handler := httpapi.NewHandler(service)

    mux := http.NewServeMux()
    handler.Register(mux)

    log.Println("Server is running on port 8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Three lines of wiring. Each dependency is injected explicitly — no globals, no service locators.


Try it out

go run ./cmd/api

# Create
curl -X POST http://localhost:8080/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "content": "World"}'

# List
curl http://localhost:8080/notes

# Get
curl http://localhost:8080/notes/1

# Update
curl -X PUT http://localhost:8080/notes/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated", "content": "New content"}'

# Delete
curl -X DELETE http://localhost:8080/notes/1
Enter fullscreen mode Exit fullscreen mode

Key takeaways

  • No framework needed for a simple CRUD API — net/http is enough.
  • Interface in the consumer package (note.Store lives in note, notstorage) keeps dependencies pointing inward.
  • Typed sentinel errors decouple business logic from HTTP concerns.
  • sync.RWMutex with defensive copies keeps the in-memory store safe under concurrency.
  • Dependency injection via constructors makes the whole thing trivially testable.

The next natural step would be swapping MemoryStorage for a real database (PostgreSQL with pgx, for example) — and because of the Store interface, main.go is the only file that changes.

Top comments (0)