Serving Commands and Queries over HTTP
In this step, you turn the library into an HTTP API that accepts commands and answers queries. The API keeps its view up to date while it runs, and lets a caller read its own writes.
Mapping Requests to Commands#
The commands from Making Decisions know nothing about HTTP. To accept them over HTTP, define a request type for each command, with JSON annotations for the body, and a function that turns a request into a command.
Create a file requests.go:
package main
import (
"errors"
"net/http"
"time"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit/httpapi"
)
type acquireBookRequest struct {
BookID string `json:"bookId"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
}
func toAcquireBook(r *http.Request, request acquireBookRequest, user httpapi.NoUser) (AcquireBook, error) {
if err := bookSubject.Check(request.BookID); err != nil {
return AcquireBook{}, err
}
return AcquireBook{
BookID: request.BookID,
Title: request.Title,
Author: request.Author,
ISBN: request.ISBN,
}, nil
}
type borrowBookRequest struct {
BookID string `json:"bookId"`
ReaderID string `json:"readerId"`
BorrowedUntil string `json:"borrowedUntil"`
}
func toBorrowBook(r *http.Request, request borrowBookRequest, user httpapi.NoUser) (BorrowBook, error) {
if err := bookSubject.Check(request.BookID); err != nil {
return BorrowBook{}, err
}
if _, err := time.Parse(time.DateOnly, request.BorrowedUntil); err != nil {
return BorrowBook{}, errors.New("borrowedUntil must be a date such as 2026-10-24")
}
return BorrowBook{
BookID: request.BookID,
ReaderID: request.ReaderID,
BorrowedUntil: request.BorrowedUntil,
}, nil
}
type returnBookRequest struct {
BookID string `json:"bookId"`
}
func toReturnBook(r *http.Request, request returnBookRequest, user httpapi.NoUser) (ReturnBook, error) {
if err := bookSubject.Check(request.BookID); err != nil {
return ReturnBook{}, err
}
return ReturnBook{
BookID: request.BookID,
}, nil
}
type listBooksRequest struct {
OnlyAvailable bool `json:"onlyAvailable"`
}
func toListBooks(r *http.Request, request listBooksRequest, user httpapi.NoUser) (ListBooks, error) {
return ListBooks{
OnlyAvailable: request.OnlyAvailable,
}, nil
}
func toGetBook(r *http.Request, _ httpapi.NoBody, user httpapi.NoUser) (GetBook, error) {
return GetBook{
BookID: r.PathValue("id"),
}, nil
}
Each function receives the HTTP request, the body, already decoded into the request type, and the user who makes the request. The request holds what the body does not, such as the headers, or the values of the path that toGetBook reads below. Nor does the library authenticate its users, so the user is always httpapi.NoUser, and the reader who borrows a book is named in the body instead. For an API with authentication, see Setting Up an HTTP API.
The functions are also the place to validate a request, since a plain error they return is answered with 400 Bad Request (for errors that keep a status code of their own, see Authorizing Commands). They check two things:
- The ID of a book becomes part of a subject, and composing a subject from an empty ID, or from one with a character that a segment of a subject may not contain, such as a slash, a dot, or a space, is a programming error that makes
Buildpanic. So the functions first callCheckon the scheme of the subject, which returns an error for such an ID instead (see Composing Subjects). - The schema of
BookBorrowed, which ArchitectureKit derives from the struct, only requires the return date to be a string, so EventSourcingDB would store any text as a return date. Checking the date turns a mistake of the caller into a400 Bad Request, before anything is stored.
toListBooks and toGetBook do the same for the queries. A query is asked with the HTTP method QUERY, which changes nothing, like GET, but carries a body, like POST, so a query takes its input from the body, as a command does. toListBooks takes from its body whether to list only the available books. toGetBook needs no body, so its request type is httpapi.NoBody, and it reads the ID of the book from the path, where the route below names it {id}. That ID only looks up an item and never becomes part of a subject, so toGetBook does not need to call Check.
Mapping Items to Responses#
The items of the catalog carry no JSON annotations, since what a caller sees is decided by a query and its answer, not by the view. So the answer over HTTP maps the items to a response type with JSON annotations, the counterpart of the request types above.
Create a file responses.go:
package main
import (
"context"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit/httpapi"
)
type bookBody struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
IsBorrowed bool `json:"isBorrowed"`
BorrowedUntil string `json:"borrowedUntil"`
}
func bookBodyOf(book BookItem) bookBody {
return bookBody{
ID: book.ID,
Title: book.Title,
Author: book.Author,
IsBorrowed: book.IsBorrowed,
BorrowedUntil: book.BorrowedUntil,
}
}
func answerListBooks(catalog architecturekit.View[BookItem]) httpapi.Answer[ListBooks, []bookBody] {
ask := listBooks(catalog)
return func(ctx context.Context, q ListBooks) ([]bookBody, error) {
books, err := ask(ctx, q)
if err != nil {
return nil, err
}
bodies := make([]bookBody, 0, len(books))
for _, book := range books {
bodies = append(bodies, bookBodyOf(book))
}
return bodies, nil
}
}
func answerGetBook(catalog architecturekit.KeyedView[string, BookItem]) httpapi.Answer[GetBook, bookBody] {
ask := getBook(catalog)
return func(ctx context.Context, q GetBook) (bookBody, error) {
book, err := ask(ctx, q)
if err != nil {
return bookBody{}, err
}
return bookBodyOf(book), nil
}
}
answerListBooks answers the ListBooks query with listBooks from Defining a View and a Query, and turns every item into a bookBody with bookBodyOf. answerGetBook does the same for a single book with getBook, and hands on an error as it is, so that query.ErrNoItems still says that the book does not exist. Their type, httpapi.Answer, is the type of a function that answers a query over HTTP: it receives the query, but neither the request nor the user. Since the answer decides what a caller sees, the catalog can change its items without changing the API. For details, see Handling Queries over HTTP.
Wiring the API#
Replace the content of main.go with the following program, which serves the three commands and the two queries on port 8080:
package main
import (
"context"
"log"
"net/http"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit"
"github.com/thenativeweb/architecturekit-golang/v2/architecturekit/httpapi"
)
func main() {
store, err := newStore()
if err != nil {
log.Fatal(err)
}
catalog := newCatalog()
catalogProjection := architecturekit.Tracking(newCatalogProjection(catalog), catalog)
run := architecturekit.StartProjection(context.TODO(), store, architecturekit.SubjectTree("/books"), catalogProjection)
err = architecturekit.WaitCaughtUp(context.TODO(), run)
if err != nil {
log.Fatal(err)
}
api := httpapi.NewPublicAPI(store)
mux := http.NewServeMux()
httpapi.Route(api, mux, "POST /api/acquire-book", toAcquireBook, acquireBook)
httpapi.Route(api, mux, "POST /api/borrow-book", toBorrowBook, borrowBook)
httpapi.Route(api, mux, "POST /api/return-book", toReturnBook, returnBook)
httpapi.Query(api, mux, "QUERY /api/books", toListBooks, answerListBooks(catalog),
httpapi.Revisioned(catalog, httpapi.DefaultWait),
)
httpapi.Query(api, mux, "QUERY /api/books/{id}", toGetBook, answerGetBook(catalog),
httpapi.Revisioned(catalog, httpapi.DefaultWait),
)
log.Println("Listening on http://localhost:8080.")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Unlike CatchUpProjection, StartProjection does not stop once it has applied the stored events. It runs the projection in the background and keeps observing new events, so the catalog follows every command the API executes. Since a half-built view would answer wrongly, the program calls WaitCaughtUp with the run, which returns once the projection has applied all events that were stored when it started, and only then serves requests. If the projection ends before, WaitCaughtUp returns why, and the program stops. An application with several projections hands over all of their runs at once.
Tracking wraps the projection, so that the catalog records the ID of the last event it has seen. That ID is the catalog's revision, and the queries need it to let callers read their own writes, as described below.
NewPublicAPI creates an API without authentication. Route accepts a command: it decodes the body, turns the request into the command with the given function, and executes the command with its decider. Query answers a query with the given functions, QUERY /api/books with toListBooks and answerListBooks, and QUERY /api/books/{id} with toGetBook and answerGetBook. It accepts only the method QUERY, and decodes the body as Route does. Its option Revisioned lets callers read their own writes from the catalog, as described below.
Start the API:
go run .
It prints the following line, with the current date and time at the beginning:
2026/10/07 08:22:33 Listening on http://localhost:8080.
Open another terminal and ask for all books. A query is sent with the method QUERY and its input as JSON in the body, which for all books is an empty object. The catalog has been built from the events of the previous steps:
curl \
-X QUERY \
-H "content-type: application/json" \
-d '{}' \
http://localhost:8080/api/books
[{"id":"42","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isBorrowed":true,"borrowedUntil":"2026-10-24"}]
Sending Commands#
The library acquires a second copy of the book, with the ID 43:
curl \
-i \
-X POST \
-H "content-type: application/json" \
-d '{"bookId":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isbn":"978-0756906788"}' \
http://localhost:8080/api/acquire-book
The API answers with 200 OK and the revision it has written, which is the ID of the last written event. Apart from the Date header, which shows the current time in this and all following responses, it looks like this:
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 17
{"revision":"2"}
Now try to lend book 42 to a second reader:
curl \
-i \
-X POST \
-H "content-type: application/json" \
-d '{"bookId":"42","readerId":"17","borrowedUntil":"2026-10-31"}' \
http://localhost:8080/api/borrow-book
The decider rejects the command, just as in Making Decisions, and the API answers with 422 Unprocessable Entity and the message of the rejection:
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 42
{"message":"book 42 is already borrowed"}
A request with a return date that is not a date does not even reach the decider:
curl \
-i \
-X POST \
-H "content-type: application/json" \
-d '{"bookId":"43","readerId":"17","borrowedUntil":"tomorrow"}' \
http://localhost:8080/api/borrow-book
toBorrowBook refuses it, and the API answers with 400 Bad Request:
HTTP/1.1 400 Bad Request
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 81
{"message":"malformed request: borrowedUntil must be a date such as 2026-10-24"}
Every error maps to the status code that matches it. For the complete list, see Mapping Errors to Status Codes.
Reading Your Own Writes#
The projection applies new events in the background, so the catalog lags a little behind the events that have been written. A query sent right after a command may therefore not see the command's events yet – the view is eventually consistent.
To see its own write, a caller sends the revision from the answer in the Wait-For-Revision header. The query then waits until the catalog has seen that event, for at most five seconds. Ask for the available books after acquiring book 43, whose event has the ID 2:
curl \
-i \
-X QUERY \
-H "content-type: application/json" \
-H "Wait-For-Revision: 2" \
-d '{"onlyAvailable":true}' \
http://localhost:8080/api/books
The response contains book 43, which is available, but not book 42, which is borrowed:
HTTP/1.1 200 OK
Cache-Control: private, no-cache
Content-Type: application/json
Etag: "2-4142521d03aa8052"
Revision: 2
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 115
[{"id":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isBorrowed":false,"borrowedUntil":""}]
The Revision header tells which revision the answer shows. For how the ETag header saves a caller from downloading an unchanged answer again, see Reading Your Own Writes over HTTP.
Getting a Single Book#
To get a single book, put its ID into the path. The query has no other input, so the request has no body. Ask for book 43:
curl \
-i \
-X QUERY \
http://localhost:8080/api/books/43
The response contains the book as a single object, rather than as a list:
HTTP/1.1 200 OK
Cache-Control: private, no-cache
Content-Type: application/json
Etag: "2-fe4df14ebdd492c5"
Revision: 2
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 113
{"id":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isBorrowed":false,"borrowedUntil":""}
Now ask for a book that does not exist:
curl \
-i \
-X QUERY \
http://localhost:8080/api/books/99
getBook returns query.ErrNoItems, which the API answers with 404 Not Found and the message not found (see Mapping Errors to Status Codes):
HTTP/1.1 404 Not Found
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 07 Oct 2026 08:22:33 GMT
Content-Length: 24
{"message":"not found"}
Your Turn#
Try a few more requests, and use Wait-For-Revision to see their effect on the catalog:
- Return book
42, and then lend it to reader17. - Return book
43, although it is not borrowed. The API answers with200 OK, but the revision is empty, since there was nothing to do. - Send a request without the
content-typeheader of the examples, so thatcurl -dsends the body asapplication/x-www-form-urlencoded, or with a field the request type does not know, and look at the status codes.