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
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/httpis enough. -
Interface in the consumer package (
note.Storelives innote, notstorage) keeps dependencies pointing inward. - Typed sentinel errors decouple business logic from HTTP concerns.
-
sync.RWMutexwith 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)