Build a Production-Grade API in Go
Build a REST API with JWT auth, RBAC, SQLite, and in-memory caching in Go.
Introduction
30 Second Summary
Every time you log into an app, a backend API checks who you are, decides what you can access, and serves data fast enough that you never notice the work happening. That invisible layer of authentication, authorization, and caching is what separates a toy project from a production service.
In this project, you will build an ML Model Registry API in Go from scratch. It stores machine learning models in a database, authenticates users with JWT tokens, enforces role-based permissions, and caches reads with automatic invalidation on writes.
What You'll Build
You'll run curl commands against your local API, watch admin users create and delete models while viewers get blocked, and see cache hits and invalidations logged in real time as each request flows through your hand-built middleware chain.
By the end of this project, you'll have:
- A REST API backed by SQLite where you can register, query, and delete ML models and see your data persist across server restarts.
- JWT authentication and role-based access control (RBAC) enforced by hand-built middleware, where missing tokens return 401 and wrong roles return 403.
- An in-memory cache with automatic invalidation, where you can observe cache hits, misses, and invalidations logged after each request.
- Secret Mission: Add per-user rate limiting middleware that returns 429 Too Many Requests when a user exceeds 10 requests per minute.
Are there any prerequisites?
You need familiarity with programming in any language (Python or similar). No prior Go or web development experience is required.
Before We Start
You're about to build a production-grade ML Model Registry API in Go. It handles authentication, role-based permissions, database persistence, and in-memory caching.
Before writing any code, you need Go installed in your WSL environment and a project initialized. This step gets your tooling ready so you can focus on building.
In this step, get ready to:
- Install Go 1.26.5 in WSL.
- Create the project directory and initialize a Go module.
- Install the VS Code Go extension for editor support.
Install Go 1.26.5 in WSL
Go is the language you'll use to build the entire API. You need version 1.26.5 or higher because it includes the enhanced ServeMux routing patterns introduced in Go 1.22.
- Check if Go is already installed by running this command in your WSL terminal:
go version
✔️ I see version 1.26 or higher
You're all set. Skip ahead to the next substep.
ⓧ I see an older version
Your existing Go installation will be replaced with 1.26.5. The installer removes the old version first.
- Download the Go 1.26.5 tarball by running:
wget https://go.dev/dl/go1.26.5.linux-amd64.tar.gz
- Remove the old installation and extract the new one by running:
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz
- Add Go to your PATH permanently by running:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc && source ~/.bashrc
- Verify the installation by running:
go version
You should see go version go1.26.5 linux/amd64 (or similar).
ⓧ Command not found
Go isn't installed yet. Follow these steps to install it from the official release.
- Download the Go 1.26.5 tarball by running:
wget https://go.dev/dl/go1.26.5.linux-amd64.tar.gz
- Extract Go to /usr/local/go by running:
sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz
- Add Go to your PATH permanently by running:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc && source ~/.bashrc
- Verify the installation by running:
go version
You should see go version go1.26.5 linux/amd64 (or similar).
Still not seeing a version number?
Make sure you ran source ~/.bashrc after editing the file. Without it, your current terminal session does not pick up the new PATH.
If wget failed, check your internet connection and try the download again.
Help me debug my Go PATH
Create the project and write a minimal server
With Go installed, you can create a project folder and initialize a Go module. The module system manages your dependencies and package paths.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir model-registry
cd model-registry
- Initialize a Go module by running:
go mod init model-registry
You should see go: creating new go.mod: module model-registry printed in your terminal.
What is a Go module?
A Go module is the unit of dependency management in Go. The go.mod file it creates records your module name and Go version. Every package you import later gets tracked here automatically.
- Create a file called main.go in your model-registry folder with the following content:
package main
import (
"log"
"net/http"
)
func main() {
// Register a health check endpoint using Go 1.22+ method routing
http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
log.Println("server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("server failed: %v", err)
}
}
What does this code do?
This registers a single route: GET /health. The GET prefix is Go 1.22+ method-based routing. Only GET requests to /health reach this handler.
The handler responds with a JSON body confirming the server is alive. http.ListenAndServe starts the server on port 8080 and blocks until it encounters an error.
- Save main.go.
Before you run the server, what do you think will appear in your terminal when you start it?
- Start the server by running:
go run main.go
You should see server starting on :8080 printed. The terminal stays occupied because the server is listening.
- Open a second WSL terminal tab or window.
- Test the health endpoint by running:
curl http://localhost:8080/health
You should see {"status":"ok"} returned. Your API is alive.
- Return to your first terminal and press Ctrl+C to stop the server.
Seeing an error when running go run?
If you see syntax error, double-check that the backtick characters around {"status":"ok"} are backticks (`) not single quotes (').
If you see address already in use, another process is using port 8080. Run lsof -i :8080 to find it, then kill that process.
Help me debug my server error
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.go and confirmed the curl response.
ⓧ I'd like to double check the full code
Here is the complete main.go file:
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
log.Println("server starting on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("server failed: %v", err)
}
}
And here is go.mod (auto-generated by go mod init):
module model-registry
go 1.26
Install the VS Code Go extension
The Go extension for VS Code provides syntax highlighting, auto-completion, and error detection as you write Go code through the rest of this project.
- Install the Go extension by running this command in your WSL terminal:
code --install-extension golang.go
- Open your project folder in VS Code by running:
code .
VS Code opens with your model-registry folder in the Explorer sidebar. You should see main.go and go.mod listed.
VS Code not opening from WSL?
If code is not recognized, open VS Code manually, press Ctrl+Shift+P, type Shell Command: Install 'code' command in PATH, and run it. Then try code . again.
Help me connect VS Code to WSL
Your environment is ready. You have Go installed, a running server you've tested with curl, and VS Code open with editor support. Next up, you'll build the full API routes and handlers.
Build API Routes and Handlers
Your server starts and responds to a health check, but it does not do anything useful yet. Before adding persistence or authentication, you need a working API that you can hit with curl and see real JSON responses.
In this step, you will build the baby version of your model registry: four endpoints that store models in memory. This gives you a fast feedback loop to confirm your routing, JSON encoding, and response codes all work correctly.
In this step, get ready to:
- Define the Model struct and create in-memory CRUD handlers.
- Wire routes into a versioned sub-mux with a logging middleware.
- Test all four endpoints with curl and observe the in-memory limitation.
Refactor the server to use an explicit router with logging
Your current server uses the default global mux. An explicit ServeMux gives you a named router you can wrap with middleware and attach sub-routes to later.
- In VS Code, open main.go and replace the entire file contents with the following code:
package main
import (
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Wrap the mux with logging middleware
handler := loggingMiddleware(mux)
log.Println("server starting on :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatalf("server failed: %v", err)
}
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
What does this code do?
http.NewServeMux() creates a dedicated router instead of relying on the global default. This means you can pass the mux into middleware functions and compose behavior explicitly.
loggingMiddleware follows the standard Go middleware signature: func(http.Handler) http.Handler. It wraps any handler, records the start time, calls the inner handler, then logs the method, path, and duration.
- Save main.go.
- Stop any running server with Ctrl+C, then start it again by running:
go run main.go
- Open a second terminal and test the health endpoint by running:
curl http://localhost:8080/health
You should see {"status":"ok"} returned. In your first terminal (where the server is running), you should also see a log line like GET /health 42.125µs showing the method, path, and duration.
Not seeing the log line?
Make sure you are looking at the terminal where go run main.go is running, not the one you used for curl. The log output appears in the server's terminal.
If you see a compilation error, check that your import block includes "time" since loggingMiddleware uses time.Now() and time.Since().
help me debug my logging middleware
Define the Model struct and write the handlers
Every model in the registry has a name, version, framework, and accuracy score. You will store these in memory using a Go slice protected by a mutex for safe concurrent access.
- In main.go, replace everything above the func main() line (the package declaration and imports) with the following:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Model represents a registered ML model
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Framework string `json:"framework"`
Accuracy float64 `json:"accuracy"`
CreatedAt string `json:"created_at"`
}
// In-memory storage with mutex for concurrent access
var (
modelsMu sync.Mutex
models []Model
)
What does this code do?
The Model struct defines the shape of each ML model entry. The json:"..." struct tags tell Go's JSON encoder to use snake_case field names in the API response.
sync.Mutex protects the models slice from data races. If two requests arrive at the same time, the mutex ensures only one modifies the slice at once.
- Now add the list and create handler functions. Paste the following code directly below the var (...) block and above func main():
func listModelsHandler(w http.ResponseWriter, r *http.Request) {
modelsMu.Lock()
defer modelsMu.Unlock()
data, _ := json.Marshal(models)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func createModelHandler(w http.ResponseWriter, r *http.Request) {
var input Model
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
input.ID = fmt.Sprintf("%d", time.Now().UnixNano())
input.CreatedAt = time.Now().UTC().Format(time.RFC3339)
modelsMu.Lock()
models = append(models, input)
modelsMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(input)
}
What do these handlers do?
listModelsHandler locks the mutex, marshals the entire models slice to JSON, and writes it to the response.
createModelHandler decodes the request body into a Model, generates a unique ID from the current nanosecond timestamp, appends to the slice, and responds with 201 Created.
- Add the get and delete handler functions directly below createModelHandler:
func getModelHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
modelsMu.Lock()
defer modelsMu.Unlock()
for _, m := range models {
if m.ID == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(m)
return
}
}
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
}
func deleteModelHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
modelsMu.Lock()
defer modelsMu.Unlock()
for i, m := range models {
if m.ID == id {
models = append(models[:i], models[i+1:]...)
w.WriteHeader(http.StatusNoContent)
return
}
}
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
}
How does PathValue work?
Go 1.22+ introduced path wildcards in ServeMux patterns. When you register a route like "GET /models/{id}", the {id} segment is captured automatically.
Inside your handler, r.PathValue("id") returns whatever the client sent in that URL position. No external router library needed.
Wire the API routes and test the full flow
The handlers exist but are not connected to any URL path yet. You will register them on a separate sub-mux mounted at /api/v1/ so all your model endpoints share a versioned prefix.
- In main.go, find the func main() function. After the closing brace of the health handler registration (the line with })), add the following lines:
api := http.NewServeMux()
api.HandleFunc("GET /models", listModelsHandler)
api.HandleFunc("POST /models", createModelHandler)
api.HandleFunc("GET /models/{id}", getModelHandler)
api.HandleFunc("DELETE /models/{id}", deleteModelHandler)
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
What is StripPrefix doing?
http.StripPrefix("/api/v1", api) removes /api/v1 from the URL before passing the request to the api sub-mux. This means the sub-mux only needs to match /models and /models/{id}, not the full path.
The trailing slash on /api/v1/ in mux.Handle is required. It tells the router to match all paths that begin with this prefix.
- Save main.go.
- Stop the server with Ctrl+C and restart it by running:
go run main.go
You should see server starting on :8080 printed. If you see a compilation error, double check that every handler function is placed between the var (...) block and func main().
- In your second terminal, list models by running:
curl http://localhost:8080/api/v1/models
You should see null (the slice is empty and Go marshals a nil slice as null).
- Create a model by running:
curl -X POST http://localhost:8080/api/v1/models -d '{"name":"resnet50","version":"1.0","framework":"pytorch","accuracy":0.96}'
You should see a JSON response with the model you just created, including a generated id and created_at timestamp.
- List models again to confirm it was stored:
curl http://localhost:8080/api/v1/models
You should now see a JSON array containing the model you created.
What about GET by ID and DELETE?
Copy the id value from the create response. You can test get and delete by running (replace the ID with yours):
curl http://localhost:8080/api/v1/models/YOUR_MODEL_ID
To delete, use:
curl -X DELETE http://localhost:8080/api/v1/models/YOUR_MODEL_ID
A successful delete returns no body with a 204 No Content status.
Before moving on, think about what happens if you stop the server and start it again. Do you think the model you created will still be there?
- Stop the server with Ctrl+C, then restart it:
go run main.go
- List models again:
curl http://localhost:8080/api/v1/models
You get null again. The model you created is gone. This is expected. Your data lives in a Go slice in memory, so it disappears when the process stops. The next step fixes this with a database.
Getting a 404 on the models endpoint?
Check that mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api)) has a trailing slash on /api/v1/. Without the trailing slash, the mux will not match sub-paths.
Also confirm your curl URL is exactly http://localhost:8080/api/v1/models (no double slashes, no missing v1).
help me debug my API routes returning 404
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and the server restarts cleanly.
ⓧ I'd like to double check the full code
Your main.go should contain the following sections in this order. Scroll up and confirm each is present:
- Package declaration and imports (encoding/json, fmt, log, net/http, sync, time).
- Model struct with six JSON-tagged fields.
- modelsMu and models variable block.
- listModelsHandler function.
- createModelHandler function.
- getModelHandler function.
- deleteModelHandler function.
- func main() with health handler, api sub-mux, StripPrefix, and loggingMiddleware wrapping.
- loggingMiddleware function.
You have a complete in-memory REST API with four working endpoints and request logging. Next up, you will replace that disappearing in-memory slice with a real SQLite database so your models survive restarts.
Add SQLite Database Persistence
Your API works, but it has a critical limitation. Every time you stop the server, all your model data vanishes because it lives in a Go slice in memory.
In this step, you will add a SQLite database so your models survive server restarts. You will use modernc.org/sqlite, a pure-Go SQLite driver that requires zero external system dependencies.
In this step, get ready to:
- Add the SQLite dependency and create a database store package.
- Replace in-memory handlers with database-backed implementations.
- Verify that data persists across server restarts.
Install the SQLite dependency and create the store package
Go manages dependencies through modules. You need the pure-Go SQLite driver so your project can read and write a database file without requiring any C compiler or system libraries.
- In your WSL terminal, make sure you are inside the model-registry folder, then install the SQLite driver by running:
go get modernc.org/sqlite
What does this command do?
The go get command downloads the modernc.org/sqlite package and adds it to your go.mod file as a dependency. It also generates a go.sum file that records the exact checksums of every downloaded module.
- Confirm the dependency was added by checking your go.mod file:
cat go.mod
You should see require modernc.org/sqlite v1.54.0 listed in the file (possibly with indirect dependencies below it).
Don't see the require line?
Make sure you ran go get from inside the model-registry folder where your go.mod lives. Run pwd to confirm your current directory.
help me fix my go get command
- Create the internal/store directory by running:
mkdir -p internal/store
Go uses the internal/ convention to keep packages private to your module. Code inside internal/ cannot be imported by other projects.
- Create the file internal/store/store.go in VS Code. Start with the package declaration, imports, and type definitions by pasting this code:
package store
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
type Model struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Framework string `json:"framework"`
Accuracy float64 `json:"accuracy"`
CreatedAt string `json:"created_at"`
}
type ModelStore struct {
db *sql.DB
}
What does this code do?
The blank import _ "modernc.org/sqlite" registers the SQLite driver with Go's database/sql package. The underscore means you are importing it only for its side effect (driver registration), not calling any of its functions directly.
The Model struct is the same shape as before, and ModelStore wraps a database connection so your handlers can query it.
- Below the ModelStore struct, add the InitDB function and the NewModelStore constructor:
func InitDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
framework TEXT NOT NULL,
accuracy REAL NOT NULL,
created_at TEXT NOT NULL
)`)
if err != nil {
return nil, fmt.Errorf("create table: %w", err)
}
return db, nil
}
func NewModelStore(db *sql.DB) *ModelStore {
return &ModelStore{db: db}
}
What does InitDB do?
The sql.Open("sqlite", path) call opens (or creates) a SQLite database file at the given path. The CREATE TABLE IF NOT EXISTS statement ensures the models table exists on first run without failing on subsequent runs.
The NewModelStore constructor wraps the database connection so your handlers call methods on the store instead of raw SQL.
- Save internal/store/store.go and verify the package compiles by running:
go build ./internal/store
If the build succeeds, you see no output. That silence means your package compiled without errors.
Seeing a compile error?
Check that the import block includes "time" even though you have not used it yet. You will use it in the next chunk. If Go complains about an unused import, add a comment // used below temporarily, or continue to the next chunk where time is used.
help me fix my store.go compile error
Add the database query methods
Your store needs four methods matching your four API endpoints: list all models, create one, get one by ID, and delete one. Each method uses parameterized queries (? placeholders) to prevent SQL injection.
- Below NewModelStore in internal/store/store.go, add the List method:
func (s *ModelStore) List() ([]Model, error) {
rows, err := s.db.Query("SELECT id, name, version, framework, accuracy, created_at FROM models ORDER BY created_at DESC")
if err != nil {
return nil, err
}
defer rows.Close()
var models []Model
for rows.Next() {
var m Model
if err := rows.Scan(&m.ID, &m.Name, &m.Version, &m.Framework, &m.Accuracy, &m.CreatedAt); err != nil {
return nil, err
}
models = append(models, m)
}
if models == nil {
models = []Model{}
}
return models, nil
}
What does this code do?
The db.Query call returns multiple rows. You iterate with rows.Next() and scan each row into a Model struct. The defer rows.Close() ensures the database cursor is released even if an error occurs mid-iteration.
The if models == nil check ensures the API returns an empty JSON array [] instead of null when no models exist.
- Add the Create method below List:
func (s *ModelStore) Create(m Model) (Model, error) {
m.ID = fmt.Sprintf("%d", time.Now().UnixNano())
m.CreatedAt = time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
"INSERT INTO models (id, name, version, framework, accuracy, created_at) VALUES (?, ?, ?, ?, ?, ?)",
m.ID, m.Name, m.Version, m.Framework, m.Accuracy, m.CreatedAt,
)
if err != nil {
return Model{}, err
}
return m, nil
}
Why parameterized queries?
The ? placeholders tell the database driver to treat the values as data, not as SQL code. This prevents SQL injection attacks where a malicious input could alter your query logic.
- Add the GetByID method:
func (s *ModelStore) GetByID(id string) (Model, error) {
var m Model
err := s.db.QueryRow(
"SELECT id, name, version, framework, accuracy, created_at FROM models WHERE id = ?", id,
).Scan(&m.ID, &m.Name, &m.Version, &m.Framework, &m.Accuracy, &m.CreatedAt)
if err != nil {
return Model{}, err
}
return m, nil
}
- Add the Delete method:
func (s *ModelStore) Delete(id string) error {
result, err := s.db.Exec("DELETE FROM models WHERE id = ?", id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("model not found")
}
return nil
}
What do GetByID and Delete do?
The db.QueryRow call fetches a single row and scans it directly into a struct. If the ID does not exist, it returns an error that your handler converts into a 404 response.
The Delete method checks RowsAffected() to distinguish between a successful delete and a no-op (the ID did not exist). This lets the handler return 404 when deleting a nonexistent model.
- Save internal/store/store.go and verify the complete package compiles:
go build ./internal/store
No output means success. Your store package is ready to be used.
Seeing an unused import error?
If Go says "time" imported and not used, make sure you have the Create method that calls time.Now(). Go enforces that every import is used. Double-check that all four methods are present.
help me fix my store.go unused import error
✔️ Awesome, I've got everything!
Great. Make sure internal/store/store.go is saved.
ⓧ I'd like to double check the full code
Scroll up and confirm your file has these sections in order:
- Package declaration, imports, Model struct, and ModelStore struct.
- The InitDB function and NewModelStore constructor.
- The List method.
- The Create method.
- The GetByID method.
- The Delete method.
Update main.go to use the database store
Your store package is compiled, but main.go still uses the old in-memory slice. You need to replace the entire file so the handlers call your new ModelStore methods instead.
- Open main.go in VS Code and replace its entire contents with this new version. Start with the package declaration, imports, and main function:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"model-registry/internal/store"
)
func main() {
db, err := store.InitDB("models.db")
if err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
defer db.Close()
modelStore := store.NewModelStore(db)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
api := http.NewServeMux()
api.HandleFunc("GET /models", listModelsHandler(modelStore))
api.HandleFunc("POST /models", createModelHandler(modelStore))
api.HandleFunc("GET /models/{id}", getModelHandler(modelStore))
api.HandleFunc("DELETE /models/{id}", deleteModelHandler(modelStore))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
}
What changed in main?
Instead of a global models slice and a sync.Mutex, you now call store.InitDB to open the database and store.NewModelStore to create a store instance. Each handler function now takes the modelStore as a parameter and returns an http.HandlerFunc (a closure pattern).
- Below the main function (still in the same file), add the server startup and the first two handler functions:
func listModelsHandler(s *store.ModelStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
models, err := s.List()
if err != nil {
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
data, _ := json.Marshal(models)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
}
- Add the createModelHandler below:
func createModelHandler(s *store.ModelStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input store.Model
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
model, err := s.Create(input)
if err != nil {
http.Error(w, `{"error":"failed to create model"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(model)
}
}
- Add the getModelHandler and deleteModelHandler:
func getModelHandler(s *store.ModelStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
model, err := s.GetByID(id)
if err != nil {
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(model)
}
}
func deleteModelHandler(s *store.ModelStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Delete(id); err != nil {
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
- Finally, add the loggingMiddleware function and the server startup at the end. Complete the main function by adding these lines before its closing brace:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
Where does the server start?
The main function's first code block ends before the server startup. You need to add the startup lines inside the main function body, after the route declarations. Add these lines before the closing } of main:
- Inside the main function, after the line mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api)), add the server startup:
handler := loggingMiddleware(mux)
log.Println("server starting on :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatalf("server failed: %v", err)
}
- Save main.go. Tidy the module to sync all indirect dependencies by running:
go mod tidy
- Start the server by running:
go run .
You should see server starting on :8080 in the terminal. A new models.db file now exists in your project folder.
Seeing a compile error about missing closing brace?
Make sure the main function has a closing } after the http.ListenAndServe block. The server startup lines go inside main, not after it.
help me fix my main.go compile error
- Open a second WSL terminal and test creating a model:
curl -X POST http://localhost:8080/api/v1/models \
-H "Content-Type: application/json" \
-d '{"name":"resnet50","version":"1.0","framework":"pytorch","accuracy":0.96}'
You should see a JSON response with the model data, including a generated id and created_at timestamp.
✔️ Awesome, I've got everything!
Great. Make sure both internal/store/store.go and main.go are saved.
ⓧ I'd like to double check the full code
Your main.go should have these sections in order:
- Package declaration and imports (including "model-registry/internal/store").
- The main function: InitDB, NewModelStore, mux setup, api routes, StripPrefix, loggingMiddleware wrapping, and ListenAndServe.
- Four handler functions: listModelsHandler, createModelHandler, getModelHandler, deleteModelHandler.
- The loggingMiddleware function.
Test that data persists across restarts
The whole point of this step is proving that data survives a server restart. Before you run the test, think about what you expect to happen.
Before you run the next commands, predict: after you stop and restart the server, will the model you just created still be there?
- In the terminal running the server, press Ctrl+C to stop it.
- Restart the server by running:
go run .
- In your second terminal, query the models list:
curl http://localhost:8080/api/v1/models
You should see a JSON array containing the resnet50 model you created before the restart. Your data persisted in models.db.
Why does this work now?
Previously, your models lived in a Go slice that existed only in process memory. When the process stopped, the memory was freed and the data was gone. Now, every Create call writes a row to the models.db file on disk. When the server restarts, InitDB reopens the same file and all your data is still there.
Getting an empty array back?
Make sure you created a model with the curl -X POST command earlier in this step. If you restarted the server before creating any models, the database is empty. Run the POST command again, restart, and try the GET.
Also confirm a models.db file exists in your project folder by running ls models.db.
help me figure out why my data is not persisting
Your API now has a real database behind it. Models survive restarts, and parameterized queries protect against SQL injection. Next up, you will add authentication and role-based access control so only authorized users can create or delete models.
Add JWT Authentication and RBAC
Right now, anyone who can reach your API can create or delete models with zero identity checks. A single curl command is all it takes to wipe your registry clean.
You need two layers of protection: authentication (who are you?) and authorization (what can you do?). JWT tokens will carry the user's role in their claims, and middleware will enforce access rules before requests ever reach your handlers.
In this step, get ready to:
- Install the JWT library and build a token endpoint that issues signed tokens with role claims.
- Write authentication middleware that validates tokens and injects user identity into request context.
- Enforce role-based access control so only admins can create or delete models.
Install the JWT library and create the token endpoint
Your API needs a way to issue tokens that prove a user's identity and role. You will create an /auth/token endpoint that accepts a username and role, then returns a signed JWT.
- Install the JWT library by running this command in your project directory:
go get github.com/golang-jwt/jwt/v5
You should see Go download and add the dependency. Your go.mod now includes github.com/golang-jwt/jwt/v5.
Seeing a network error?
Make sure you are inside the model-registry directory where your go.mod lives. If you see a timeout, check your internet connection and try again.
Help me debug a go get failure.
- Create the auth package directory by running:
mkdir -p internal/auth
- Create a new file internal/auth/auth.go and add the package declaration, imports, and type definitions:
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims carries user identity inside the JWT
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Private context key type prevents collisions across packages
type contextKey string
const claimsKey contextKey = "claims"
// ClaimsFromContext extracts claims injected by auth middleware
func ClaimsFromContext(ctx context.Context) *Claims {
claims, _ := ctx.Value(claimsKey).(*Claims)
return claims
}
type tokenRequest struct {
Username string `json:"username"`
Role string `json:"role"
}
What does this code do?
- The Claims struct embeds jwt.RegisteredClaims (standard fields like expiry) and adds custom Username and Role fields.
- The contextKey type is private to this package. Using a plain string as a context key would risk collisions with other packages that also use context.
- The ClaimsFromContext helper lets any handler pull the authenticated user's identity from the request context.
- Add the TokenHandler function below the tokenRequest struct in internal/auth/auth.go:
func TokenHandler(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req tokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Username == "" || req.Role == "" {
http.Error(w, `{"error":"username and role are required"}`, http.StatusBadRequest)
return
}
if req.Role != "admin" && req.Role != "viewer" {
http.Error(w, `{"error":"role must be admin or viewer"}`, http.StatusBadRequest)
return
}
claims := &Claims{
Username: req.Username,
Role: req.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "model-registry",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(secret)
if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"token": signed})
}
}
What does TokenHandler do?
- It validates that the request includes a username and a role (only admin or viewer are accepted).
- It builds a JWT with the user's identity and a 15-minute expiry, then signs it with HS256 using the server's secret key.
- This simulates a login flow. In production, you would verify credentials against a user database before issuing the token.
- Save internal/auth/auth.go.
- To quickly test token generation, you need to temporarily wire this endpoint into main.go. In main.go, find the import block:
import (
"encoding/json"
"log"
"net/http"
"time"
"model-registry/internal/store"
)
- Replace it with this updated import block that adds os and the auth package:
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
"model-registry/internal/auth"
"model-registry/internal/store"
)
- In the main() function, find this line:
modelStore := store.NewModelStore(db)
- Add these lines directly below it to set up the JWT secret:
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
if len(jwtSecret) == 0 {
jwtSecret = []byte("dev-secret-change-in-production")
}
- Find the health endpoint registration (the closing }) of the health handler). Add this line directly below it:
mux.HandleFunc("POST /auth/token", auth.TokenHandler(jwtSecret))
- Save main.go and start the server by running:
go run .
- In a second terminal, request a token by running:
curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","role":"admin"}'
You should see a JSON response containing a token field with a long encoded string. That is your signed JWT.
Seeing a compile error?
If you see an error about unused imports, make sure you have removed any import that is no longer used after your edits. If you see an error about auth being undefined, confirm the import path matches exactly: model-registry/internal/auth.
Help me fix a compile error in my Go auth setup.
- Stop the server with Ctrl+C.
Write the authentication middleware and wire it in
The token endpoint issues JWTs, but nothing validates them yet. You need middleware that intercepts every API request, checks for a valid token, and injects the user's claims into the request context for downstream handlers.
- In internal/auth/auth.go, add the RequireAuth function below TokenHandler:
func RequireAuth(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"authorization header required"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
http.Error(w, `{"error":"authorization header format must be: Bearer {token}"}`, http.StatusUnauthorized)
return
}
token, err := jwt.ParseWithClaims(parts[1], &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(*Claims)
if !ok {
http.Error(w, `{"error":"invalid token claims"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
What does RequireAuth do?
- It returns a function that wraps any http.Handler. This is the standard Go middleware signature: func(http.Handler) http.Handler.
- The signing-method check inside the key function prevents algorithm confusion attacks. Without it, an attacker could switch the algorithm to none and bypass signature validation.
- After validation, the authenticated user's claims are injected into the request context so handlers can read them.
- Save internal/auth/auth.go.
- Now wire the middleware into your server. In main.go, find this line:
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
- Replace it with these two lines that wrap the API mux with the auth middleware:
protectedAPI := auth.RequireAuth(jwtSecret)(api)
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", protectedAPI))
- Save main.go and start the server:
go run .
Before you run the next command, predict: what will happen if you try to list models without providing a token?
- In your second terminal, try hitting the API without a token:
curl -s http://localhost:8080/api/v1/models
You get back {"error":"authorization header required"} with a 401 status. The middleware is blocking unauthenticated requests.
- Now get a token and use it:
TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","role":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s http://localhost:8080/api/v1/models \
-H "Authorization: Bearer $TOKEN"
You should see a JSON array of models (or [] if your database is empty). Authentication is working.
Still getting 401 with the token?
Check that the TOKEN variable is not empty by running echo $TOKEN. If it is empty, the token request failed. Make sure you have the /auth/token route registered in main.go and the server is running.
Help me debug why my JWT token is being rejected.
- Stop the server with Ctrl+C.
Enforce role-based access and add CORS
Authentication confirms identity, but you also need authorization. An admin should be able to create and delete models. A viewer should only be able to read. You will add role checks directly in the write handlers and a CORS middleware so browser-based clients can reach your API.
- In main.go, find the createModelHandler function. Locate this line at the start of the inner function:
var input store.Model
- Add this RBAC check directly above it, so it runs before decoding the request body:
// RBAC: only admins can create models
claims := auth.ClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
http.Error(w, `{"error":"forbidden: admin role required"}`, http.StatusForbidden)
return
}
- Now find the deleteModelHandler function. Locate this line:
id := r.PathValue("id")
- Add the same RBAC check directly above it:
// RBAC: only admins can delete models
claims := auth.ClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
http.Error(w, `{"error":"forbidden: admin role required"}`, http.StatusForbidden)
return
}
Why check roles in the handler, not a separate middleware?
Different routes need different role requirements. GET routes allow any authenticated user, while POST and DELETE require admin. Checking the role inside each handler gives you per-route granularity without needing to split your mux into multiple groups.
- Finally, add the CORS middleware. At the bottom of main.go, below the loggingMiddleware function, add:
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
What does corsMiddleware do?
Browsers block cross-origin requests by default. This middleware adds headers that tell the browser which methods and headers are allowed. The OPTIONS check handles preflight requests that browsers send before the actual request.
- In the main() function, find this line:
handler := loggingMiddleware(mux)
- Replace it with this line that wraps CORS inside logging:
handler := loggingMiddleware(corsMiddleware(mux))
- Save main.go and start the server:
go run .
Before testing, predict: if you use a viewer token to POST a new model, will it succeed or fail? What status code do you expect?
- First, confirm that a request without any token is rejected:
curl -s -X POST http://localhost:8080/api/v1/models \
-H "Content-Type: application/json" \
-d '{"name":"resnet50","version":"1.0","framework":"pytorch","accuracy":0.96}'
You see {"error":"authorization header required"} and a 401 status. No token means no access.
- Generate an admin token and create a model:
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","role":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s -X POST http://localhost:8080/api/v1/models \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"name":"resnet50","version":"1.0","framework":"pytorch","accuracy":0.96}'
The POST succeeds and returns the created model with an ID. Admins can write.
- Now generate a viewer token and try the same POST:
VIEWER_TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"bob","role":"viewer"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s -X POST http://localhost:8080/api/v1/models \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $VIEWER_TOKEN" \
-d '{"name":"bert","version":"2.0","framework":"tensorflow","accuracy":0.92}'
You get {"error":"forbidden: admin role required"} with a 403 status. The RBAC check blocks viewers from creating models.
- Confirm the viewer can still read by running:
curl -s http://localhost:8080/api/v1/models \
-H "Authorization: Bearer $VIEWER_TOKEN"
You see the list of models. Viewers can read but not write. Your three-layer security is working: no token gets 401, wrong role gets 403, correct role succeeds.
Getting a 401 instead of 403 for the viewer?
A 401 means the token itself is not being validated. Check that VIEWER_TOKEN is not empty (run echo $VIEWER_TOKEN). Also confirm the RequireAuth middleware and your handlers both use the same jwtSecret value.
Help me debug why my viewer token returns 401 instead of 403.
✔️ Awesome, I've got everything!
Great. Double check you have saved both internal/auth/auth.go and main.go.
ⓧ I'd like to double check the full code
Both files are longer than 30 lines, so scroll up and confirm each chunk is present in order.
For internal/auth/auth.go, confirm these sections appear in order:
- Package declaration and imports (including github.com/golang-jwt/jwt/v5).
- The Claims struct, contextKey type, claimsKey constant, and ClaimsFromContext function.
- The tokenRequest struct.
- The TokenHandler function that validates the request, builds claims, and signs the JWT.
- The RequireAuth middleware that extracts the Bearer token, validates it, checks the signing method, and injects claims into context.
For main.go, confirm these changes:
- Imports include "os" and "model-registry/internal/auth".
- The jwtSecret setup reads from the JWT_SECRET env var with a dev fallback.
- The POST /auth/token route is registered on the main mux.
- The api mux is wrapped with auth.RequireAuth(jwtSecret) as protectedAPI.
- The handler chain is loggingMiddleware(corsMiddleware(mux)).
- Both createModelHandler and deleteModelHandler have the RBAC check at the top of their inner functions.
- The corsMiddleware function exists at the bottom of the file.
Your API is now locked down. Authentication proves who the caller is, and RBAC controls what they can do. Next up, you will add an in-memory cache so repeated reads skip the database entirely.
Add In-Memory Caching with Invalidation
Your API now authenticates users and enforces role-based permissions. Every model lookup, though, hits the database even when the data has not changed since the last request.
An in-memory cache solves this by serving repeated reads from memory. The catch is the stale-read problem: after a write, cached data is outdated. In this step, you will build a cache that invalidates correctly on every write so readers always get fresh data.
In this step, get ready to:
- Create a thread-safe cache package with TTL-based expiry.
- Wire the cache into read and write handlers with hit/miss logging.
- Verify cache hits, misses, and invalidation via server logs.
Create the cache package
The cache will live in its own internal package, following the same layout as internal/auth and internal/store.
- Create the cache directory by running this command:
mkdir -p internal/cache
- Confirm the directory exists by running:
ls internal/
You should see auth, cache, and store listed.
- Create internal/cache/cache.go in VS Code and add the package declaration, types, and constructor:
package cache
import (
"sync"
"time"
)
// entry holds cached data and its expiration timestamp
type entry struct {
data []byte
expiresAt time.Time
}
// Cache is a thread-safe in-memory store with TTL
type Cache struct {
mu sync.RWMutex
items map[string]entry
ttl time.Duration
}
// New creates a cache with the given time-to-live per entry
func New(ttl time.Duration) *Cache {
return &Cache{
items: make(map[string]entry),
ttl: ttl,
}
}
What does this code do?
The entry struct pairs raw bytes with an expiration time. This means each cached item automatically becomes stale after the TTL elapses.
The sync.RWMutex allows multiple goroutines to read concurrently while blocking writes. This prevents race conditions when the server handles simultaneous requests.
- Add the three cache methods below the New function in the same file:
// Get retrieves cached data if it exists and hasn't expired
func (c *Cache) Get(key string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, exists := c.items[key]
if !exists || time.Now().After(e.expiresAt) {
return nil, false
}
return e.data, true
}
// Set stores data in the cache with the configured TTL
func (c *Cache) Set(key string, data []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry{
data: data,
expiresAt: time.Now().Add(c.ttl),
}
}
// Invalidate clears all cached entries
func (c *Cache) Invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = make(map[string]entry)
}
What does this code do?
- Get uses a read lock (RLock) so multiple readers don't block each other. It returns false if the key is missing OR expired.
- Set uses an exclusive write lock and stamps each entry with a future expiration time.
- Invalidate replaces the entire map with a fresh empty one. This is the simplest correct strategy: any write clears everything, guaranteeing no stale reads.
- Save internal/cache/cache.go.
Seeing a red underline in VS Code?
The Go extension may show errors because nothing imports this package yet. That is expected and resolves in the next substep when you wire the cache into main.go.
help me debug an error in my cache.go file
✔️ Awesome, I've got everything!
Great. Make sure you have saved internal/cache/cache.go with both chunks (types + constructor, then the three methods).
ⓧ I'd like to double check the full code
Your internal/cache/cache.go should contain, in order:
- The package cache declaration with imports for sync and time.
- The entry struct with data []byte and expiresAt time.Time.
- The Cache struct with sync.RWMutex, map[string]entry, and time.Duration.
- The New constructor, then Get, Set, and Invalidate methods.
Wire the cache into your handlers
Go is statically typed, so all four handler signatures must be updated before the code compiles again. You will make several edits, then verify at the end.
- In main.go, find the import block at the top of the file. Add "model-registry/internal/cache" to the third-party imports group (below "model-registry/internal/auth").
- Find this line inside the main() function:
modelStore := store.NewModelStore(db)
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
- Insert a new line between them to create the cache instance, so the block now looks like this:
modelStore := store.NewModelStore(db)
modelCache := cache.New(30 * time.Second)
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
- Find the four handler registrations and update each to pass modelCache as the second argument:
api.HandleFunc("GET /models", listModelsHandler(modelStore, modelCache))
api.HandleFunc("POST /models", createModelHandler(modelStore, modelCache))
api.HandleFunc("GET /models/{id}", getModelHandler(modelStore, modelCache))
api.HandleFunc("DELETE /models/{id}", deleteModelHandler(modelStore, modelCache))
What does this change?
Each handler now receives a *cache.Cache alongside the *store.ModelStore. The 30-second TTL means cached entries expire automatically even without an explicit invalidation.
Now replace the handler function bodies. Start with the read handlers that use the cache-first pattern.
- Replace the existing listModelsHandler function with this cache-aware version:
func listModelsHandler(s *store.ModelStore, c *cache.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cacheKey := "models:list"
if data, hit := c.Get(cacheKey); hit {
log.Println("cache hit:", cacheKey)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
return
}
log.Println("cache miss:", cacheKey)
models, err := s.List()
if err != nil {
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
data, _ := json.Marshal(models)
c.Set(cacheKey, data)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
}
What does this code do?
The handler checks the cache first using the key models:list. On a hit, it returns immediately without touching the database. On a miss, it queries the database and stores the result in the cache for next time.
- Replace the existing getModelHandler function with this version:
func getModelHandler(s *store.ModelStore, c *cache.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
cacheKey := "models:" + id
if data, hit := c.Get(cacheKey); hit {
log.Println("cache hit:", cacheKey)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
return
}
log.Println("cache miss:", cacheKey)
model, err := s.GetByID(id)
if err != nil {
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
return
}
data, _ := json.Marshal(model)
c.Set(cacheKey, data)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
}
What does this code do?
Same cache-first pattern as listModelsHandler, but the cache key includes the model ID (models:<id>). Each model gets its own cache entry.
Next, the write handlers. These must invalidate the cache after a successful database write so that the next read fetches fresh data.
- Replace the existing createModelHandler function with this version:
func createModelHandler(s *store.ModelStore, c *cache.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
http.Error(w, `{"error":"forbidden: admin role required"}`, http.StatusForbidden)
return
}
var input store.Model
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
model, err := s.Create(input)
if err != nil {
http.Error(w, `{"error":"failed to create model"}`, http.StatusInternalServerError)
return
}
c.Invalidate()
log.Println("cache invalidated: model created")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(model)
}
}
What does this code do?
After the database write succeeds, c.Invalidate() clears every cached entry. The next GET will see a cache miss and fetch fresh data from the database. This is the write-through invalidation pattern.
- Replace the existing deleteModelHandler function with this version:
func deleteModelHandler(s *store.ModelStore, c *cache.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
http.Error(w, `{"error":"forbidden: admin role required"}`, http.StatusForbidden)
return
}
id := r.PathValue("id")
if err := s.Delete(id); err != nil {
http.Error(w, `{"error":"model not found"}`, http.StatusNotFound)
return
}
c.Invalidate()
log.Println("cache invalidated: model deleted")
w.WriteHeader(http.StatusNoContent)
}
}
What does this code do?
Same invalidation pattern as create. After a successful delete, all cached entries are cleared so subsequent reads reflect the deletion.
- Save main.go.
- Verify the project compiles by running:
go build ./...
If the command produces no output, compilation succeeded. If you see errors, check that all four handler functions accept *cache.Cache as their second parameter and that the import for "model-registry/internal/cache" is present.
Seeing a compilation error?
- If you see "too many arguments" or "not enough arguments", one of your handler registrations still passes only modelStore without modelCache. All four must be updated.
- If you see "imported and not used", make sure modelCache is declared in main() and passed to the handlers.
help me fix my Go compilation error after adding the cache
✔️ Awesome, I've got everything!
Great. Double check you have saved both internal/cache/cache.go and main.go.
ⓧ I'd like to double check the full code
Your main.go should contain, in order:
- Imports including "model-registry/internal/cache".
- main() with modelCache := cache.New(30 * time.Second) after modelStore and all four registrations passing modelCache.
- listModelsHandler with cache-first read pattern (check cache, log hit/miss, populate on miss).
- createModelHandler with RBAC check, DB write, then c.Invalidate().
- getModelHandler with cache-first read pattern using "models:" + id as the key.
- deleteModelHandler with RBAC check, DB delete, then c.Invalidate().
- loggingMiddleware and corsMiddleware (unchanged from the previous step).
Test the full cache flow
Time to prove the cache works end to end. You will observe cache misses, cache hits, and invalidation all reflected in the server logs.
- Start the server by running:
go run .
You should see server starting on :8080 in your terminal.
- Open a second terminal. Get an admin token by running:
TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"alice","role":"admin"}' | grep -o '"token":"[^"]*' | cut -d'"' -f4)
echo $TOKEN
- Query the models list to trigger a cache miss by running:
curl -s http://localhost:8080/api/v1/models \
-H "Authorization: Bearer $TOKEN"
- Check the server terminal. You should see a log line containing cache miss: models:list.
- Run the exact same curl command again.
Before you check the logs, what do you expect to see this time? A miss or a hit?
The server logs now show cache hit: models:list. The second read was served entirely from memory without touching SQLite.
- Now create a model to trigger cache invalidation by running:
curl -s -X POST http://localhost:8080/api/v1/models \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"resnet50","version":"1.0","framework":"pytorch","accuracy":0.96}'
- Check the server logs. You should see cache invalidated: model created.
- Query the models list one more time by running:
curl -s http://localhost:8080/api/v1/models \
-H "Authorization: Bearer $TOKEN"
What do you expect to see in the logs? The cache was invalidated, so this should be a fresh database query.
The logs show cache miss: models:list again, proving the cache was cleared and the handler fetched fresh data from the database. The response includes the resnet50 model you just created.
Why full invalidation instead of key-specific?
When you create a model, the list cache (models:list) is now stale because it's missing the new entry. You could invalidate just that one key, but what about paginated list caches, filtered queries, or related data? Clearing everything is the simplest correct strategy.
Production systems often use targeted invalidation for performance, but full invalidation guarantees correctness with zero bugs from missed keys.
Not seeing cache hit/miss logs?
- Make sure you are reading the terminal where the server is running, not the terminal where you ran curl.
- If you see only the HTTP method/path log lines but no cache lines, double check that your listModelsHandler includes log.Println("cache hit:", cacheKey) and log.Println("cache miss:", cacheKey).
- If you restarted the server between tests, the cache was cleared (it lives in memory). Start a fresh sequence: GET (miss), GET (hit), POST (invalidate), GET (miss).
help me debug why my cache logs aren't showing
Your API now serves repeated reads from memory and guarantees fresh data after every write. You have a production-patterned Go service with authentication, authorization, persistence, and caching working together through composable middleware.
Secret mission
Add Rate Limiting Middleware
Your API currently allows unlimited requests from any authenticated user. A single user could flood your server with thousands of requests per second. In this secret mission, you'll implement a per-user token bucket rate limiter that restricts each user to 10 requests per minute.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The model-registry project folder on your Desktop (Go source files, SQLite database, Go module files).
- Go 1.26.5 installed at /usr/local/go.
- Go module cache (downloaded dependencies stored in ~/go/pkg/mod).
- VS Code Go extension (golang.go).
Keep everything running
No action needed. Choose this if you plan to keep building on the API or want to revisit it soon.
- Your server is not running unless you explicitly start it with go run . so it consumes no resources when idle.
- Go and the VS Code extension will remain installed on your system.
- The models.db SQLite file stays inside your project folder with any models you created.
Pause - I'll come back to this later
Stop any running server process to free up the port. Your files and installation stay intact.
- If the server is still running in your terminal, press Ctrl+C to stop it.
- Your project folder, Go installation, and VS Code extension all remain ready for when you return.
Delete - I don't want to use this again
Remove all project resources and the Go installation from your system.
- If the server is still running, press Ctrl+C to stop it.
- Delete the project folder by running:
rm -rf ~/Desktop/model-registry
- Clear the Go module cache by running:
go clean -modcache
- Remove the Go installation by running:
sudo rm -rf /usr/local/go
- Remove the Go PATH entry. Open your shell config file (~/.bashrc or ~/.profile) and delete the line that contains export PATH=$PATH:/usr/local/go/bin.
- Uninstall the VS Code Go extension. Open VS Code, click the Extensions icon in the sidebar, find Go by Google, and click Uninstall.
- Delete the downloaded Go tarball if it is still in your Downloads folder:
rm -f ~/Downloads/go1.26.5.linux-amd64.tar.gz
Nice Work!
Nice Work!
You just built a production-grade ML Model Registry API in Go from scratch. It authenticates users, enforces role-based permissions, caches reads, invalidates on writes, and handles cross-origin requests.
You've learned how to:
- Build a complete REST API in Go using only the standard library for routing and hand-built middleware composition (func(http.Handler) http.Handler).
- Implement JWT authentication with signing-method validation and role-based access control (RBAC) that returns 401 for missing tokens and 403 for insufficient permissions.
- Build an in-memory cache with TTL and write-through invalidation that solves the stale-read problem by clearing cached data on every create or delete.
- Secret Mission: Implemented per-user rate limiting with a token bucket algorithm that restricts each user to 10 requests per minute.
Ready to quiz yourself?