Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1. Design The Contract

Start with workflows, not with Axum routes or database tables. Write down what clients need to do and which operations need identity.

For the team workspace, the first pass looks like this:

WorkflowMacroReason
list projects, read tasks, create tasksrest_service!conventional JSON resources, OpenAPI, browser clients
upload and download task attachmentsfile_service!streaming, multipart validation, early auth checks
live task activityjsonrpc_bidirectional_service!typed WebSocket notifications
command-heavy workflowsjsonrpc_service!optional alternative for RPC-style APIs

Name Permissions Early

Permissions should be stable application concepts, not incidental handler details. Good permission names usually describe the capability:

project:read
project:write
task:write
attachment:read
attachment:write
admin

Each protected operation declares those requirements in the API definition:

GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse,
POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task,
DELETE WITH_PERMISSIONS(["admin"] | ["project:owner"]) projects/{project_id: String}() -> (),

WITH_PERMISSIONS(["a", "b"]) means the authenticated user needs both permissions. WITH_PERMISSIONS(["a"] | ["b", "c"]) means either the first group or the second group is enough. WITH_PERMISSIONS([]) means authenticated, with no extra permission requirement.

Keep DTOs Boring

DTOs should be explicit, serializable, and independent of storage models. Avoid exposing database-specific fields just because they exist.

#[cfg(feature = "server")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct Task {
    pub id: String,
    pub project_id: String,
    pub title: String,
    pub status: TaskStatus,
    pub assignee_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub enum TaskStatus {
    Open,
    InProgress,
    Done,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct CreateTaskRequest {
    pub title: String,
    pub assignee_id: Option<String>,
}

The JsonSchema derive is gated because only server/spec generation needs it. Shared serialization stays available with no transport feature enabled.

Sketch The Service

A REST task service definition can stay close to the client workflow:

rest_service!({
    service_name: TaskService,
    base_path: "/api/v1",
    openapi: true,
    serve_docs: true,
    docs_path: "/docs",
    endpoints: [
        GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse,
        GET WITH_PERMISSIONS(["project:read"]) projects/{project_id: String}/tasks() -> TasksResponse,
        POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task,
        PATCH WITH_PERMISSIONS(["task:write"]) tasks/{task_id: String}(UpdateTaskRequest) -> Task,
    ]
});

At this point you have made the most important design decisions: operation names, path parameters, request/response types, and auth requirements. The server implementation can change later without changing this contract.