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, and implement the ToCommand function, which turns a request into a command.
Create a file requests.go:
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/thenativeweb/architecturekit-golang/architecturekit/httpapi"
)
type acquireBookRequest struct {
BookID string `json:"bookId"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
}
func (r acquireBookRequest) ToCommand(user httpapi.NoUser) (AcquireBook, error) {
if err := checkBookID(r.BookID); err != nil {
return AcquireBook{}, err
}
return AcquireBook{
BookID: r.BookID,
Title: r.Title,
Author: r.Author,
ISBN: r.ISBN,
}, nil
}
type borrowBookRequest struct {
BookID string `json:"bookId"`
ReaderID string `json:"readerId"`
BorrowedUntil string `json:"borrowedUntil"`
}
func (r borrowBookRequest) ToCommand(user httpapi.NoUser) (BorrowBook, error) {
if err := checkBookID(r.BookID); err != nil {
return BorrowBook{}, err
}
if _, err := time.Parse(time.DateOnly, r.BorrowedUntil); err != nil {
return BorrowBook{}, errors.New("borrowedUntil must be a date such as 2026-10-24")
}
return BorrowBook{
BookID: r.BookID,
ReaderID: r.ReaderID,
BorrowedUntil: r.BorrowedUntil,
}, nil
}
type returnBookRequest struct {
BookID string `json:"bookId"`
}
func (r returnBookRequest) ToCommand(user httpapi.NoUser) (ReturnBook, error) {
if err := checkBookID(r.BookID); err != nil {
return ReturnBook{}, err
}
return ReturnBook{
BookID: r.BookID,
}, nil
}
func checkBookID(bookID string) error {
if bookID == "" || strings.Contains(bookID, "/") {
return errors.New("bookId must not be empty or contain a slash")
}
return nil
}
func toListBooks(r *http.Request, user httpapi.NoUser) (ListBooks, error) {
return ListBooks{
OnlyAvailable: r.URL.Query().Get("available") == "true",
}, nil
}
ToCommand receives the user who makes the request. The library does not authenticate its users, so the user is always httpapi.NoUser, and the reader who borrows a book is named in the request instead. For an API with authentication, see Setting Up an HTTP API.
ToCommand is also the place to validate a request, since a plain error it returns is answered with 400 Bad Request (for errors that keep a status code of their own, see Authorizing Commands). The functions check two things:
- The ID of a book becomes part of a subject, and composing a subject from an empty ID, or from one that contains a slash, is a programming error that makes
Buildpanic. SocheckBookIDrefuses such an ID before a command is built. - EventSourcingDB would reject a return date that is not a date, since the schema of
BookBorrowedrequires one. But ArchitectureKit treats an event that does not match its schema as a failure that trying again will not fix, which is answered with500 Internal Server Error. Checking the date first turns a mistake of the caller into a400 Bad Request.
toListBooks does for the query what ToCommand does for commands: it turns a request into a ListBooks query, and reads the query parameter available for that.
Wiring the API#
Replace the content of main.go with the following program, which serves the three commands and the query on port 8080:
package main
import (
"context"
"log"
"net/http"
"github.com/thenativeweb/architecturekit-golang/architecturekit"
"github.com/thenativeweb/architecturekit-golang/architecturekit/httpapi"
)
func main() {
store, err := newStore()
if err != nil {
log.Fatal(err)
}
catalog := newCatalog()
catalogProjection := architecturekit.Tracking(catalog, newCatalogProjection(catalog))
run := architecturekit.StartProjection(context.TODO(), store, "/books", true, catalogProjection)
select {
case <-run.CaughtUp():
case <-run.Done():
log.Fatal(run.Err())
}
api := httpapi.NewPublicAPI(store)
mux := http.NewServeMux()
httpapi.Route[acquireBookRequest](api, mux, "POST /api/acquire-book", acquireBook)
httpapi.Route[borrowBookRequest](api, mux, "POST /api/borrow-book", borrowBook)
httpapi.Route[returnBookRequest](api, mux, "POST /api/return-book", returnBook)
httpapi.QueryRevisioned(api, mux, "GET /api/books", catalog, toListBooks, listBooks(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 waits until CaughtUp reports that the projection has applied all events that were stored when it started, and only then serves requests. If the projection ends before, Done says so, and the program stops.
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 query needs it to let callers read their own writes, as described below.
NewPublicAPI creates an API without authentication. Route accepts a command: it decodes the request, calls ToCommand, and executes the command with its decider. QueryRevisioned answers the query with toListBooks and listBooks.
Start the API:
go run .
It prints the following line, with the current date and time at the beginning:
2026/09/29 21:58:04 Listening on http://localhost:8080.
Open another terminal and ask for all books. The catalog has been built from the events of the previous steps:
curl 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 IDs of the written events. 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
Content-Type: application/json
Date: Tue, 29 Sep 2026 21:58:18 GMT
Content-Length: 34
{"eventIds":["2"],"message":"ok"}
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
Content-Type: application/json
Date: Tue, 29 Sep 2026 21:58:19 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
ToCommand refuses it, and the API answers with 400 Bad Request:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Tue, 29 Sep 2026 21:58:19 GMT
Content-Length: 90
{"message":"httpapi: malformed request: borrowedUntil must be a date such as 2026-10-24"}
Every error maps to the status code that matches its category. 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 highest ID from eventIds 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 \
-H "Wait-For-Revision: 2" \
"http://localhost:8080/api/books?available=true"
The response contains book 43, which is available, but not book 42, which is borrowed:
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: application/json
Etag: "2-2b3dd5a380811e1a"
X-Revision: 2
Date: Tue, 29 Sep 2026 21:58:20 GMT
Content-Length: 115
[{"id":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isBorrowed":false,"borrowedUntil":""}]
The X-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.
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, buteventIdsis empty, since there was nothing to do. - Send a request without the
content-typeheader, or with a field the request type does not know, and look at the status codes.