Build a greeting endpoint, count greetings in memory, and reject an oversized name with an HTTP error. No database, broker, or credentials are needed.
Start with the hello service from Creating Projects,
including its dependency-resolution step. Run every command below from that
project’s root. Keep the generated bootstrap files: the edits extend them rather
than replacing their lifecycle, middleware, or probes.
First Request
Start the service on loopback:
HTTP_ADDR=127.0.0.1:8080 go run ./cmd/server
The shell examples use POSIX environment assignments. In PowerShell, set
$env:HTTP_ADDR = "127.0.0.1:8080" before running go run ./cmd/server.
If port 8080 is occupied, choose another port and use it in the requests below.
The development .env enables unauthenticated diagnostics on the same listener.
Keep the loopback address; do not expose this development process publicly.
Starter Defaults explains the settings.
In another terminal, request the welcome page and readiness probe:
curl -i http://127.0.0.1:8080/
curl -i http://127.0.0.1:8080/readyz
Both return 200 OK; / contains an HTML welcome page and /readyz contains
ok. The server’s startup log reports its bound address. If binding fails, the
process exits unsuccessfully rather than reporting a successful HTTP start.
Stop with Ctrl+C before editing. Repeat the start command after each edit below; the starter does not watch or reload source files.
Greeting Handler
An Arkoren HTTP handler takes the familiar response writer and request, then returns an error. The router renders returned errors centrally, so handlers can return failures without writing a second response.
Create internal/bootstrap/greeting.go. Start it with the package, imports,
and a typed response:
package bootstrap
import (
"net/http"
"arkoren.dev/core/http/response"
)
// Greeting is the JSON returned for an accepted greeting.
type Greeting struct {
Message string `json:"message"`
}
Append this handler to the same file. It reads a route wildcard named name and
returns JSON:
// greet responds to a greeting request using the route's name.
func greet(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
return response.JSON(w, http.StatusOK, Greeting{
Message: "Welcome, " + name + "!",
})
}
response.JSON sets the content type and encodes the response. Returning its error
keeps encoding or write failures visible to the router.
Route Registration
In internal/bootstrap/router.go, inside the callback returned by Router(),
add this line before return routes:
routes.Post("/greetings/{name}", greet)
Keep the existing middleware, welcome route, and probe routes. Registration maps the method and path to the handler; it does not open a listener. Use POST because the next step records a greeting and changes state.
Restart the service, then send a greeting:
curl -i -X POST http://127.0.0.1:8080/greetings/Mira
The response is 200 OK with this JSON content:
{
"message": "Welcome, Mira!"
}
The starter’s CSRF protection allows this command-line request without Origin or Sec-Fetch-Site headers. That is not authentication or permission to expose state-changing routes without an application security policy.
Counting Greetings
Keep counters behind a cache facade so the handler does not depend on how values
are stored. In internal/bootstrap/bootstrap.go, add these imports alongside
the existing application import:
"arkoren.dev/core/service/cache"
"arkoren.dev/core/service/cache/memorycache"
Inside Compose, replace only routes := compose.Use(Router()) with:
driver := memorycache.New()
compose.Cleanup(driver.Stop)
compose.Startup(driver.Start)
visits := cache.New(driver)
routes := compose.Use(Router(visits))
Keep the following compose.UseDebug(MCP(routes)) and
compose.Use(Server(routes)) calls. Cleanup is registered immediately at the
driver’s owner and runs in reverse order, so the server stops before the cache.
Composition finishes before startup starts the cache’s expiry janitor and binds
the HTTP listener. Failures during composition or startup also release acquired
resources.
In internal/bootstrap/router.go, import arkoren.dev/core/service/cache and
change the factory declaration to accept the facade:
func Router(visits *cache.Cache) application.Factory[*router.Router] {
Keep the existing factory body and replace the greeting registration with this closure, which passes the shared facade to the handler:
routes.Post("/greetings/{name}", func(w http.ResponseWriter, r *http.Request) error {
return greet(w, r, visits)
})
Add net/http to that file’s imports. In greeting.go, add the cache import
and a Count int64 field with the tag json:"count" to Greeting. Replace
the entire greet function with:
// greet counts an accepted greeting and returns its current process-local total.
func greet(w http.ResponseWriter, r *http.Request, visits *cache.Cache) error {
name := r.PathValue("name")
key := cache.NewKey[int64]("greetings." + name)
count, err := visits.Increment(r.Context(), key, 1)
if err != nil {
return err
}
return response.JSON(w, http.StatusOK, Greeting{
Message: "Welcome, " + name + "!",
Count: count,
})
}
The typed key names Mira’s counter exactly greetings.Mira. Increment creates
a missing counter and updates it atomically; it does not use the key’s write TTL.
These counters have no expiry, belong only to this process, and reset on restart.
They are demonstration state, not durable business records or an exactly-once
record of delivered responses. A write can succeed even if sending its response
later fails.
Restart, then issue the POST request twice. The second response contains:
{
"message": "Welcome, Mira!",
"count": 2
}
For shared counters across replicas, follow Cache Drivers for backend construction and ownership. The handler continues to use the facade. Use Key Definitions and Counters when you need reusable naming or bounded counter windows.
Expected Errors
Reject an oversized name before changing its counter. Add
arkoren.dev/foundation/problem to greeting.go’s imports. Immediately after
name := r.PathValue("name"), insert:
if len(name) > 32 {
return problem.UnprocessableContent().Derive(func(details *problem.Builder) {
details.Detail("The name must be at most 32 bytes.")
})
}
len counts bytes, not Unicode characters. Restart and send 33 ASCII characters:
curl -i -X POST http://127.0.0.1:8080/greetings/abcdefghijklmnopqrstuvwxyzabcdefg
The response has status 422 Unprocessable Content and a Problem Details body:
{
"type": "about:blank",
"title": "Unprocessable Content",
"status": 422,
"detail": "The name must be at most 32 bytes.",
"instance": "/greetings/abcdefghijklmnopqrstuvwxyzabcdefg"
}
No counter is incremented for this rejected request. The router can render an error only before the response is committed; it cannot replace headers or body bytes already sent. See Error Handling for private internal failures and custom problem responses.
Stopping Safely
Press Ctrl+C in the service terminal. The generated executable’s signal context
also handles SIGTERM. The application stops serving, runs registered cleanup,
and returns before the executable exits; clean shutdown exits with status 0,
while lifecycle failures exit with status 1.
The HTTP runner allows in-flight requests up to 15 seconds to drain by default.
After exit, another request fails to connect and the memory counters are gone.
This walkthrough creates no persistent backend or container to remove. Keep or
delete only the hello project you created; do not remove a shared parent directory.
You now have a working route, a shared dependency inside one process, and a checked error path. Continue with Configuration to translate application settings, Project Layout to separate features, or Deployment before running outside development.