tinka alpha
tinkaalpha

Routes and handlers

This chapter adds the App's first routes: one that lists the posts, and one that shows a post by its ID. You will learn how to:

Overview

A route connects a method and a path, such as GET /posts, to a handler. A Tinka handler is a function that receives the request's *tinka.Ctx and returns two things: a value, which the App sends as the response, and an error, which the App turns into an error response. A handler never writes the response itself, so it reads top to bottom and can't answer twice.

The posts themselves don't belong in the handlers. They go in a package of their own, app/posts, which knows nothing about HTTP: a Capability, in Tinka's terms. The handlers adapt requests to it, and later chapters reuse it from a Command and from tests without going through HTTP.

Store the posts

Store keeps the posts in a slice, in memory. Its methods take a context.Context and return an error even though memory never fails, because a store backed by a database needs both: when one replaces this store, its callers don't change. Find returns ErrNotFound for an ID no post has, so callers can tell a missing post from a failure. NewStore starts the store with two posts, so the App has something to show.

Create the file:

app/posts/posts.go
// Package posts keeps the blog's posts.
package posts

import (
	"context"
	"errors"
	"slices"
)

// ErrNotFound means no post has the ID asked for.
var ErrNotFound = errors.New("post not found")

// Post is one blog post.
type Post struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Body  string `json:"body"`
}

// Store keeps the posts in memory, so they last until the App stops. Its
// methods take a context and return errors, as a store backed by a
// database does, so one can replace it without changing its callers.
type Store struct {
	posts []Post
}

// NewStore returns a Store holding two posts, so the App has something to
// show.
func NewStore() *Store {
	return &Store{posts: []Post{
		{ID: 1, Title: "Hello, Tinka", Body: "The first post on the blog."},
		{ID: 2, Title: "Routes and handlers", Body: "How a request finds its handler."},
	}}
}

// All returns every post, oldest first.
func (s *Store) All(ctx context.Context) ([]Post, error) {
	return slices.Clone(s.posts), nil
}

// Find returns the post with the ID id, or ErrNotFound.
func (s *Store) Find(ctx context.Context, id int64) (Post, error) {
	for _, p := range s.posts {
		if p.ID == id {
			return p, nil
		}
	}
	return Post{}, ErrNotFound
}

All returns a copy of the slice, so a caller that changes what it received can't change the store.

Write the handlers

The handlers need the store, so they are methods on a Posts struct that holds it. A method value, such as postHandlers.List, is a function of the right shape, so the router takes it as a handler.

List returns the store's posts as they are: a slice becomes a JSON array. Show reads the {id} path parameter with c.ParamInt, which parses it as a whole number and returns an error that answers 422 when it isn't one, so the handler returns that error unchanged. It then maps posts.ErrNotFound to tinka.NotFound, which answers 404 with the message you give it. Any other error passes through, and the App answers it with a generic 500 and logs it, so a store's internal error never reaches the client.

The *tinka.Ctx is also the request's context.Context, so the handlers pass c to the store: when the client disconnects, a database query made with it stops.

app/handlers/posts.go
package handlers

import (
	"errors"

	"github.com/mucolabs/tinka"

	"blog/app/posts"
)

// Posts answers the /posts routes from the store.
type Posts struct {
	Store *posts.Store
}

// List answers GET /posts with every post.
func (h Posts) List(c *tinka.Ctx) ([]posts.Post, error) {
	return h.Store.All(c)
}

// Show answers GET /posts/{id} with one post, or 404 Not Found.
func (h Posts) Show(c *tinka.Ctx) (posts.Post, error) {
	id, err := c.ParamInt("id")
	if err != nil {
		return posts.Post{}, err
	}
	post, err := h.Store.Find(c, id)
	if errors.Is(err, posts.ErrNotFound) {
		return posts.Post{}, tinka.NotFound("No post has the ID %d.", id)
	}
	return post, err
}

Register the routes

Routes runs once, when the App starts. It creates the store there and gives it to the handlers, so every request shares the same store. {id} in a pattern matches one path segment, which c.ParamInt("id") reads. Add the import and the routes:

start/routes.go
package start

import (
	"github.com/mucolabs/tinka"
	"github.com/mucolabs/tinka/health"

	"blog/app/handlers"
	"blog/app/posts"
)

// Routes registers the App's routes. It runs as the App starts, after the
// providers registered their services, so it can resolve them; an error
// stops the App before it serves.
func Routes(app *tinka.App) error {
	app.Get("/", handlers.Home)

	postHandlers := handlers.Posts{Store: posts.NewStore()}
	app.Get("/posts", postHandlers.List)
	app.Get("/posts/{id}", postHandlers.Show)

Try the routes

Save the files, and act serve restarts the App with the new routes. List the posts:

Terminal
curl localhost:3000/posts
Output
[{"id":1,"title":"Hello, Tinka","body":"The first post on the blog."},{"id":2,"title":"Routes and handlers","body":"How a request finds its handler."}]

Show the first one:

Terminal
curl localhost:3000/posts/1
Output
{"id":1,"title":"Hello, Tinka","body":"The first post on the blog."}

Ask for a post that doesn't exist, with -i to see the status and headers:

Terminal
curl -i localhost:3000/posts/7
Output
HTTP/1.1 404 Not Found
Cache-Control: no-store
Content-Length: 129
Content-Type: application/problem+json
Date: Tue, 20 Oct 2026 23:10:49 GMT
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Request-Id: VU2XFXBNWVRMBJK4VM6PJRTSCK

{"title":"Not Found","status":404,"detail":"No post has the ID 7.","code":"not_found","request_id":"VU2XFXBNWVRMBJK4VM6PJRTSCK"}

The error became problem details, the standard JSON shape for HTTP errors (RFC 9457). detail holds your message, and code is a stable identifier a client can match on. request_id matches the X-Request-Id header and the dev server's log, which records the rejected request at WARN:

Output
[01:10:49.604] WARN: request rejected method=GET path=/posts/7 status=404 code=not_found detail="No post has the ID 7." request_id=VU2XFXBNWVRMBJK4VM6PJRTSCK

An ID that isn't a number never reaches the store. c.ParamInt refuses it, and the response names the parameter:

Terminal
curl localhost:3000/posts/latest
Output
{"title":"Unprocessable Entity","status":422,"detail":"The path parameter id must be a whole number.","code":"invalid","fields":{"id":"must be a whole number"},"request_id":"FG4OZAWULJBCGQ2WVCVABVRISP"}

Recap

The posts live in a Store in app/posts, with no HTTP in it. The handlers in app/handlers/posts.go return values that become JSON, and errors that become problem details: tinka.NotFound for a missing post, and the 422 that c.ParamInt returns for an ID that isn't a number. Next, Request bodies lets clients create posts.

See also: Routing, HTTP context, Response, Exception handling