Impl-First Design
Server-less takes an impl-first approach: write your Rust methods, derive macros project them into protocols.
impl MyService {
/// Create a new user
fn create_user(&self, name: String, email: String) -> Result<User, UserError> { ... }
/// Get user by ID
fn get_user(&self, id: UserId) -> Option<User> { ... }
/// List all users
fn list_users(&self, limit: u32, offset: u32) -> Vec<User> { ... }
/// Watch for new users
fn watch_users(&self) -> impl Stream<Item=User> { ... }
}Each derive macro projects this into a protocol:
| Derive | Projection |
|---|---|
Http | REST endpoints |
Grpc | Protobuf service |
GraphQL | Schema + resolvers |
Cli | Command-line interface |
Mcp | MCP tools for LLMs |
Client (planned) | Type-safe client SDK |
OpenApi | Spec from types + docs |
Naming Conventions
HTTP Verb Inference
| Method prefix | HTTP verb | Path pattern |
|---|---|---|
create_*, add_* | POST | /resources |
get_*, fetch_* | GET | /resources/{id} |
list_*, find_*, search_* | GET | /resources |
update_*, set_* | PUT | /resources/{id} |
delete_*, remove_* | DELETE | /resources/{id} |
| anything else | POST | /rpc/{method_name} |
Parameter Conventions
fn get_user(id: UserId) -> User
// ^^^ path param: ends with "Id" or named "id"
fn list_users(limit: u32, offset: u32) -> Vec<User>
// ^^^ query params: GET + primitive types
fn create_user(name: String, email: String) -> User
// ^^^ body: POST/PUT + non-id paramsCLI Conventions
fn create_user(name: String, email: String) -> User
// → myapp create-user --name "..." --email "..."
fn get_user(id: UserId) -> Option<User>
// → myapp get-user <ID>
// ^^^ positional: single id-like argMCP Conventions
/// Search for users by name
fn search_users(&self, query: String, limit: Option<u32>) -> Vec<User>Becomes:
{
"name": "search_users",
"description": "Search for users by name",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
},
"required": ["query"]
}
}- Method name → tool name
- Doc comment → tool description
- Parameters → input schema
Option<T>→ optional parameter- Return type → tool result
Return Types
The return type is the API contract:
| Type | Meaning | HTTP | CLI |
|---|---|---|---|
T | Success with data | 200 + body | stdout |
Option<T> | Maybe not found | 200 or 404 | stdout or exit 1 |
Result<T, E> | Success or typed error | 200 or error from E | stdout or stderr |
() | Success, no data | 204 No Content | silent |
Vec<T> | Collection (collected) | 200 + JSON array | JSON array |
impl Iterator<Item=T> | Lazy sequence | SSE (via stream::iter) | newline-delimited (streamed) |
impl Stream<Item=T> | Async streaming | SSE/WebSocket | newline-delimited |
Streaming
impl Stream<Item=T> means streaming. For non-streaming protocols:
#[http(collect)] // explicitly opt-in to collecting
fn watch_events(&self) -> impl Stream<Item=Event>Without #[http(collect)], using Http derive on a streaming method is a compile error. This avoids hidden memory bombs.
Error Handling
Errors are typed via Result<T, E>:
enum UserError {
NotFound, // → 404 (convention: contains "NotFound")
InvalidEmail, // → 400 (convention: "Invalid*")
Forbidden, // → 403 (exact match)
AlreadyExists, // → 409 (convention: "AlreadyExists", "*Conflict")
}Or explicit mapping:
#[derive(Error)]
enum UserError {
#[error(http = 404, grpc = "NOT_FOUND")]
NotFound,
}Each protocol derive maps errors appropriately:
- HTTP → status code + JSON body
- gRPC → status code + details
- CLI → stderr + exit code
- GraphQL → errors array
- MCP → error response
Overrides
Conventions work 80% of the time. Override when needed:
#[route(path = "/api/v1/users", method = "POST")]
fn register(&self, ...) -> User // wouldn't infer POST from "register"
#[cli(name = "add")]
fn create_user(&self, ...) -> User // override CLI subcommand name
#[mcp(name = "find_users", description = "Search the user database")]
fn search_users(&self, ...) -> Vec<User> // override MCP tool metadataProtocol-Specific Context
Some protocols need context (headers, metadata, etc.):
fn create_user(
&self,
ctx: Context, // injected: HTTP headers, gRPC metadata, CLI env, etc.
name: String,
email: String,
) -> Result<User, UserError>Context is protocol-agnostic. Each derive provides the relevant data:
- HTTP: headers, cookies, query params
- gRPC: metadata
- CLI: env vars, config files
- MCP: conversation context (if available)
CLI Async Runtime
cli_run() is the batteries-included sync entrypoint. When async methods are present, it internally creates a Tokio runtime to drive them. Tokio is the chosen battery — this is a deliberate default, not an accident.
cli_run_async() is the runtime-agnostic escape hatch: it awaits the dispatched method directly without importing any runtime. Users who prefer async-std, smol, or want to control runtime configuration use this:
#[tokio::main] // or async_std::main, smol::block_on, etc.
async fn main() {
app.cli_run_async().await.unwrap();
}Why not make the runtime configurable? A #[cli(runtime = "async-std")] attribute would enumerate runtimes in the macro and generate different block_on call sites per choice — complexity with no real payoff. cli_run_async() already gives full control with zero macro complexity. Users who don't want Tokio use cli_run_async(); users who want to drop the entrypoints entirely use #[cli(no_sync)] / #[cli(no_async)].
Settled: tokio as default battery, cli_run_async() as the escape hatch. Do not revisit by adding a runtime attribute.