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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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:


    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







    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"`
    }






    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
    }






    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)
    }






    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
    }






    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
    }










    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)
    }







    /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)
    }
    }







    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)
    }
    }










    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)
    }
    }







    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










    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.




    More...
Working...