7.4 The Proper Store
A tool built for work and not used for its purpose does not disappear - it simply waits. The store was designed with intent: accept a task, find one by id, keep the counter straight. The server received it firsthand - and still does everything itself: loop after loop, counter by hand. The store watches silently. Stores have no opinions. That is, perhaps, their chief shortcoming.
What You Need
Right now the handlers work with the vector directly: add calculates the id by hand,
done iterates the vector itself. TaskStore from tq_core knows how to do both - and
does it behind its own door: .add() maintains the counter, .get_mut() finds by id and
returns an error if nothing is found. How exactly it searches is the store’s business, not
the handler’s.
One step: replace Vec<Task> with TaskStore in SharedStore. The handlers gain ready-made
methods - the manual logic moves to where it belongs.
The Build
In crates/api/src/routes.rs the imports change. Store is the trait where .add(),
.get(), .get_mut(), .all() are declared. Without it the compiler cannot see the
methods on TaskStore:
use tq_core::store::{Store, TaskStore};
use std::sync::{Arc, Mutex}; // MutexGuard is no longer needed
SharedStore gets a new type:
pub type SharedStore = Arc<Mutex<TaskStore>>;
The full crates/api/src/routes.rs:
// crates/api/src/routes.rs - CHANGED
use axum::{
Json, Router,
extract::{Path, State},
routing::{get, patch, post},
};
use serde::Deserialize;
use std::sync::{Arc, Mutex};
use tq_core::store::{Store, TaskStore};
use tq_core::task::Task;
pub type SharedStore = Arc<Mutex<TaskStore>>;
#[derive(Deserialize)]
struct AddRequest {
title: String,
}
async fn add(State(store): State<SharedStore>, Json(req): Json<AddRequest>) -> Json<Task> {
let mut guard = store.lock().unwrap();
let id = guard.add(req.title.as_str()).unwrap(); // TaskStore assigns the id
Json(guard.get(id).unwrap().clone())
}
async fn list(State(store): State<SharedStore>) -> Json<Vec<Task>> {
let guard = store.lock().unwrap();
Json(guard.all().to_vec()) // Json needs owned Vec<Task>; to_vec() copies out of the guard
}
async fn get_task(State(store): State<SharedStore>, Path(id): Path<u64>) -> Json<Task> {
let guard = store.lock().unwrap();
Json(guard.get(id).unwrap().clone())
}
async fn done(State(store): State<SharedStore>, Path(id): Path<u64>) -> Json<Task> {
let mut guard = store.lock().unwrap();
let task = guard.get_mut(id).unwrap();
task.complete();
Json(task.clone())
}
pub fn router(store: SharedStore) -> Router {
Router::new()
.route("/tasks", post(add).get(list))
.route("/tasks/{id}", get(get_task))
.route("/tasks/{id}/done", patch(done))
.with_state(store)
}
In crates/api/src/main.rs - TaskStore in place of the empty vector:
// crates/api/src/main.rs - CHANGED
mod routes;
use axum::{Router, routing::get};
use routes::SharedStore;
use std::sync::{Arc, Mutex};
use tq_core::store::TaskStore;
async fn health() -> String {
"tq ok".to_string()
}
#[tokio::main]
async fn main() {
let store: SharedStore = Arc::new(Mutex::new(TaskStore::new(vec![]))); // CHANGED
let app = Router::new()
.route("/", get(health))
.merge(routes::router(store));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
The Result
Requests work as before:
$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"buy milk"}'
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}
$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}]
$ curl -s -X PATCH localhost:3000/tasks/1/done
{"id":1,"title":"buy milk","status":"Done","created_at":"2026-06-20T10:00:00Z"}
The handlers no longer concern themselves with the store’s internals - they ask, and the store answers. The tool is finally used for its purpose.
The complete
tqcode for this chapter is in7-many-hands/04-the-proper-store/.