# Routes and handlers

> Give the blog App a store of posts, and add GET /posts and GET /posts/{id} with handlers that return values and a 404 for a missing post.

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:

- Keep the posts in a store type of their own, with no HTTP in it
- Write handlers that return values instead of writing responses
- Register routes, and read a path parameter
- Answer `404 Not Found` for a post that doesn't exist

## 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:

```go title="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.

```go title="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:

```go title="start/routes.go" ins={8,17-19}
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:

```sh
curl localhost:3000/posts
```

```text title="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:

```sh
curl localhost:3000/posts/1
```

```text title="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:

```sh
curl -i localhost:3000/posts/7
```

```text title="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`:

```text title="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:

```sh
curl localhost:3000/posts/latest
```

```text title="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](request-bodies.md) lets clients create posts.

See also: [Routing](../guides/basics/routing.md), [HTTP context](../guides/basics/http-context.md), [Response](../guides/basics/response.md), [Exception handling](../guides/basics/exception-handling.md)
