Store and retrieve notes
You already have an endpoint that validates JSON. This next example adds storage and separates the handler from application logic.
A larger example, not another prerequisite
This example introduces services and repository traits. They help separate responsibilities as an application grows; the framework does not require them for every endpoint. Storage is still process-local and public, with no user accounts.
This page is a guided tour of a working notes API. You run it first, see real requests and responses, and then read the few functions that produce them.
Prefer to build it yourself? Build it yourself is the full recipe: the manifest, every file, compile checkpoints, and the test stage. This page is the shorter tour for readers who want to see the finished behavior first.
The application is deliberately small: create a note, read a note, validate the input, and return predictable errors. Storage is in memory, so nothing needs a database. It uses the framework's contracts and HTTP adapter, not Axum alone.
Run every command from the repository root — the directory containing
Cargo.toml, crates/, and examples/. If you have not set up yet, do the
Quickstart first.
Run the finished example
The reference application is already in the checkout at
examples/handbook-notes/. Start it:
The first build compiles the framework crates and takes a while. When it finishes you should see:
notes API listening on http://127.0.0.1:3012
press Ctrl+C to stop
Port 3012 keeps this lesson from colliding with other examples and the docs
site; the loopback address keeps it off your network. Leave that terminal running
and open a second one.
Create a note:
HTTP/1.1 201 Created
content-type: application/json
{"id":1,"text":"buy milk"}
Read it back by id:
HTTP/1.1 200 OK
content-type: application/json
{"id":1,"text":"buy milk"}
Now the deliberate failures. Blank text is rejected by the contract rule:
HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json
{"type":"about:blank","title":"Validation failed","status":422,"code":"note.text_required","errors":[{"path":"text","code":"required"}]}
An unknown id is a missing-note error:
HTTP/1.1 404 Not Found
content-type: application/problem+json
{"type":"about:blank","title":"Not found","status":404,"code":"note.not_found","errors":[]}
Stop the server with Ctrl+C. Because the notes live in memory, they are gone when the process exits.
What just happened
Every request follows the same path. Nothing talks to the network except the host; the same functions can be called from a test or a worker:
- 01callerHTTP request
- 02adapterroutes.rs handlerParse and extract input
- 03applicationNotesServiceAccess check, validation, decisions
- 04persistenceNoteRepositoryStore and load
- 05callerHTTP responseSerialize or map the error
The code is split across three ideas, and the HTTP types are confined to
routes.rs:
The following snippets focus on those functions and omit imports and some surrounding definitions. Use Build it yourself for complete files.
| File | Responsibility |
|---|---|
src/note.rs | The data, the inputs and their rules, and the two boundary traits |
src/memory.rs | A volatile in-process repository |
src/service.rs | The application boundary over any repository |
src/routes.rs | The HTTP adapter (the only file that names Axum) |
src/config.rs | The settings loaded at startup |
src/main.rs | The composition root: choose the pieces, bind the host |
Read the next few functions in that order.
The rule: CreateNote::validate
Validate is a iet-core trait with one method. The rule lives on the input
type, not in an Axum extractor, so a test or a different adapter can use it.
AppError carries an ErrorKind such as Validation, NotFound, or
Unavailable, plus a machine-readable code, a human message, and optional
fields. Here the kind is Validation; the code note.text_required and the
field text are exactly what appeared in the 422 response. Keep submitted
values out of FieldError: its fields are names and codes, not private input.
The service: NotesService::create and get
NotesService is generic over R: NoteRepository, so it never names a concrete
store; main.rs names NotesService<MemoryNotes> exactly once.
CallContext is trusted invocation metadata: a request id, an optional
principal, and an optional tenant. This lesson has no authentication, so callers
pass an empty one. Building a CallContext proves nothing; a trusted adapter
fills in the principal after authenticating.
iet_core::operation::check reads the Operation implementation on the input
and enforces access before it calls validate(). Both notes operations are
Access::Public, which skips authorization but still validates. Passing
DenyPermissions is deliberate: it is the framework's deny-by-default policy, so
an operation later changed to Access::Permission(...) fails closed instead of
silently succeeding.
NotFound is an application classification. The service does not know that it
becomes HTTP 404; the adapter decides that.
The adapter: the handlers
async
async
ValidatedJson<CreateNote>parses the body, rejects a wrong content type with415 http.content_type, enforces the body limit, and callsvalidate(). That is why blank text produced a422before this function ran.ApiPath<u64>parses the path parameter without echoing a bad value back.Createdwrites201;Json<Note>writes the200body.Result<_, ApiError>lets?convert anAppErrorfrom the service into anApiError, which rendersapplication/problem+jsonwithtype,title,status,code, anderrors.
The store: MemoryNotes::insert and find
async
async
MemoryNotes is one implementation of NoteRepository: a BTreeMap behind an
RwLock, with an AtomicU64 handing out increasing ids that start at 1 (so 0
can stay an invalid path value). Cloning it clones an Arc, not the notes. When
the store is full, insert returns Unavailable, which the adapter turns into
503. main.rs passes the capacity in from configuration.
The composition root
src/main.rs is the only file that names every concrete type. Its essential
lines:
let source = snapshot?;
let config: NotesConfig = new.load?;
let notes = new;
let limiter = new;
let router = build_router?;
let host = bind
.await?;
EnvSource::snapshot("NOTES")takes one immutable copy of the environment, exposingNOTES_HTTP_ADDRESSandNOTES_CAPACITY.ConfigLoaderruns the derived load and validation, so a malformed value fails at startup. The address defaults to127.0.0.1:3012.config.capacitybounds stored notes and is unrelated to the limiter.MemoryCache::<u64>::new(4096)is the rate-limit counter cache: it holds up to 4096 distinct caller counters. That number is independent of how many notes you can store.RateLimitConfig::fixed(120, 60s)is the policy the namednotesmodule actually applies.build_routercallsRestApi::module_with_policy, which overrides the limiter's own default policy for that module, so the effective limit is 120 requests per minute per client. The two120s are the limiter fallback and the module policy; only the module policy governs these routes.HttpHost::bindperforms an explicit bind,host.address()reports the bound address, andhost.run(shutdown)serves until the shutdown future completes. The framework does not install a signal handler, which is whymainawaitstokio::signal::ctrl_c().
The rate limiter identifies callers by peer IP (by_ip), taken from connection
info the host installs. That is why in-process tests must supply it themselves.
Where the full files live
This page showed the functions that answer a request. To create every file with
its exact contents, including the manifest and settings, continue to
Build it yourself. To see the tests that lock this behavior
down, including the rate-limit and ConnectInfo details, go to
Test the notes API.
Two boundaries are deliberately absent: there is no authentication (every
operation is public and the context is empty), and there is no CallScope or
transaction (an in-memory map has no exclusive session to thread).
Calls and transactions introduces both.
Honest limits of this application: it is loopback-only and unauthenticated, so
do not expose it; notes vanish on restart; rate-limit counters are per process;
and a full store returns 503 rather than queuing.