Adding a New Endpoint Checklist
This checklist walks through every file you need to touch when adding a new CRUD endpoint to Alloy. The example assumes a new entity called Widget.
Step 1: Define the Domain Model (crates/alloy-core/src/models.rs)
Add the domain struct and any associated types (ID newtype, create/update params).
#![allow(unused)]
fn main() {
// crates/alloy-core/src/models.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetId(pub Uuid);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Widget {
pub id: WidgetId,
pub org_id: OrgId,
pub project_id: ProjectId,
pub name: String,
pub created_by: UserId,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct CreateWidgetParams {
pub name: String,
pub project_id: ProjectId,
}
#[derive(Debug, Clone)]
pub struct UpdateWidgetParams {
pub name: Option<String>,
}
}
Re-export the new types from crates/alloy-core/src/lib.rs.
Step 2: Define the Repository Trait (crates/alloy-core/src/repos.rs)
Add an #[async_trait] repository trait with standard CRUD methods.
#![allow(unused)]
fn main() {
// crates/alloy-core/src/repos.rs
#[async_trait]
pub trait WidgetRepository: Send + Sync {
async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError>;
async fn get_by_id(&self, id: WidgetId, org_id: OrgId) -> Result<Widget, RepoError>;
async fn list(&self, org_id: OrgId, cursor: Option<String>, limit: i64) -> Result<Page<Widget>, RepoError>;
async fn update(&self, id: WidgetId, org_id: OrgId, params: UpdateWidgetParams) -> Result<Widget, RepoError>;
async fn delete(&self, id: WidgetId, org_id: OrgId) -> Result<(), RepoError>;
}
}
Always include org_id in every method signature for tenant isolation.
Step 3: Write the SQLite Implementation (crates/alloy-api/src/repos/sqlite_widget.rs)
Implement the trait using sqlx::SqlitePool. Follow the existing pattern in
files like sqlite_project.rs or sqlite_label.rs.
- Use
TEXTfor UUIDs and timestamps - Include
AND org_id = ?in every WHERE clause - Register the module in
crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/sqlite_widget.rs
pub struct SqliteWidgetRepo {
pool: SqlitePool,
}
#[async_trait]
impl WidgetRepository for SqliteWidgetRepo {
async fn create(&self, org_id: OrgId, params: CreateWidgetParams, created_by: UserId) -> Result<Widget, RepoError> {
let id = Uuid::new_v4();
sqlx::query("INSERT INTO widgets (id, org_id, project_id, name, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
// ...bind and execute...
}
// ... other methods with AND org_id = ? in every query ...
}
}
Step 4: Write the PostgreSQL Implementation (crates/alloy-api/src/repos/pg_widget.rs)
Same trait, using sqlx::PgPool. PostgreSQL uses RLS via SET LOCAL app.tenant_id
but also include explicit AND org_id = $N as defense-in-depth.
- Use
TIMESTAMPTZfor timestamps,UUIDfor IDs - Register the module in
crates/alloy-api/src/repos/mod.rs
#![allow(unused)]
fn main() {
// crates/alloy-api/src/repos/pg_widget.rs
pub struct PgWidgetRepo {
pool: PgPool,
}
}
Step 5: Write Database Migrations
Create matching migration files in both directories with the same version
number. Migrations are embedded at compile time via refinery embed_migrations!.
| PostgreSQL | SQLite | |
|---|---|---|
| Path | migrations/postgres/V{N}__create_widgets.sql | migrations/sqlite/V{N}__create_widgets.sql |
| IDs | UUID | TEXT |
| Timestamps | TIMESTAMPTZ | TEXT |
| Strings | TEXT (not VARCHAR) | TEXT |
| Auto-increment | BIGSERIAL | INTEGER PRIMARY KEY |
For PostgreSQL, add an RLS policy:
-- migrations/postgres/V{N}__create_widgets.sql
CREATE TABLE widgets ( ... );
ALTER TABLE widgets ENABLE ROW LEVEL SECURITY;
CREATE POLICY widgets_tenant_isolation ON widgets
USING (org_id = current_setting('app.tenant_id')::UUID);
Step 6: Add the Service Method (crates/alloy-core/src/services/widget.rs)
Create a service struct generic over WidgetRepository and AuditLogRepository.
All business logic — ownership checks, role gates, validation, and audit
logging — goes here. See Service Layer Patterns for the
full pattern.
#![allow(unused)]
fn main() {
// crates/alloy-core/src/services/widget.rs
pub struct WidgetService<W: WidgetRepository, A: AuditLogRepository> {
pub widget_repo: W,
pub audit_repo: A,
}
impl<W: WidgetRepository, A: AuditLogRepository> WidgetService<W, A> {
pub async fn create_widget(&self, actor: &ActorContext, params: CreateWidgetParams) -> Result<Widget, ServiceError> {
actor.require_member()?;
let widget = self.widget_repo.create(actor.org_id, params, actor.user_id).await?;
record_audit(&self.audit_repo, actor, "widget.created", &widget.id.0.to_string()).await;
Ok(widget)
}
// ... get, list, update, delete with ownership/role checks ...
}
}
Register the module in crates/alloy-core/src/services/mod.rs and re-export
from crates/alloy-core/src/lib.rs.
Step 7: Add the HTTP Handler (crates/alloy-api/src/handlers/widget.rs)
Create the Axum handler module. Each handler converts AuthContext to
ActorContext, calls the service, and maps ServiceError to ApiError.
Define routes with role-based grouping:
#![allow(unused)]
fn main() {
// crates/alloy-api/src/handlers/widget.rs
pub fn widget_routes<W: WidgetRepository + 'static, A: AuditLogRepository + 'static>(
state: Arc<WidgetAppState<W, A>>,
) -> Router {
let read_routes = Router::new()
.route("/api/v1/widgets", get(list_widgets::<W, A>))
.route("/api/v1/widgets/{id}", get(get_widget::<W, A>))
.with_state(state.clone())
.layer(middleware::from_fn(require_role(OrgRole::Viewer)));
let write_routes = Router::new()
.route("/api/v1/widgets", post(create_widget::<W, A>))
.route("/api/v1/widgets/{id}", patch(update_widget::<W, A>))
.route("/api/v1/widgets/{id}", delete(delete_widget::<W, A>))
.with_state(state)
.layer(middleware::from_fn(require_role(OrgRole::Member)));
read_routes.merge(write_routes)
}
}
Register the module in crates/alloy-api/src/handlers/mod.rs.
Step 8: Wire the Route into the App (crates/alloy-api/src/lib.rs)
- Add a
widget: Option<Router>field to theRoutersstruct - Include
routers.widgetin theoptional_routersarray insidefn app() - Construct the
WidgetAppStateand callwidget_routes()in your server startup code (e.g.,main.rsorbuild_routers())
Step 9: Add the CLI Command (crates/alloy-cli/src/commands/widget.rs)
Add a Clap subcommand module with create, get, list, update, and delete
subcommands. Follow the pattern in crates/alloy-cli/src/commands/project.rs.
#![allow(unused)]
fn main() {
// crates/alloy-cli/src/commands/widget.rs
#[derive(Subcommand)]
pub enum WidgetCmd {
Create { ... },
Get { id: String },
List { ... },
Update { id: String, ... },
Delete { id: String },
}
}
Register the module in crates/alloy-cli/src/commands/mod.rs and wire it
into the top-level CLI enum.
Step 10: Add the MCP Tool (crates/alloy-mcp/src/lib.rs)
Add #[tool] annotated methods to the MCP server for each operation.
Follow the existing pattern for tools like create_project or list_tickets.
#![allow(unused)]
fn main() {
// crates/alloy-mcp/src/lib.rs
#[tool(description = "Create a new widget")]
async fn create_widget(&self, name: String, project_id: String) -> Result<CallToolResult, McpError> {
// POST /api/v1/widgets with JSON body
}
#[tool(description = "List widgets")]
async fn list_widgets(&self, cursor: Option<String>, limit: Option<i64>) -> Result<CallToolResult, McpError> {
// GET /api/v1/widgets
}
}
Step 11: Add TUI Support (crates/alloy-tui/src/api.rs)
Add API client methods for the new endpoints. Follow the pattern in
api.rs for existing entities.
#![allow(unused)]
fn main() {
// crates/alloy-tui/src/api.rs
pub async fn list_widgets(&self) -> Result<Vec<Widget>> { ... }
pub async fn create_widget(&self, params: CreateWidgetParams) -> Result<Widget> { ... }
}
Wire the new data into the appropriate TUI views if there is a natural place for it (e.g., a project detail screen).
Step 12: Update Documentation and Seed Data
Update all affected documentation in the same commit:
| File | What to update |
|---|---|
docs/api-reference.md | Endpoint docs, field tables, curl examples |
docs/mcp-tools-reference.md | New MCP tool parameters and behavior |
docs/getting-started.md | If the entity is part of the onboarding flow |
docs/guides/*.md | Any concept guide that now covers widgets |
scripts/seed-demo.sh | Add seed data for the new entity |
Quick-Reference File Map
| Step | File(s) |
|---|---|
| Domain model | crates/alloy-core/src/models.rs, crates/alloy-core/src/lib.rs |
| Repo trait | crates/alloy-core/src/repos.rs |
| SQLite repo | crates/alloy-api/src/repos/sqlite_widget.rs, crates/alloy-api/src/repos/mod.rs |
| PostgreSQL repo | crates/alloy-api/src/repos/pg_widget.rs, crates/alloy-api/src/repos/mod.rs |
| Migrations | migrations/postgres/V{N}__*.sql, migrations/sqlite/V{N}__*.sql |
| Service | crates/alloy-core/src/services/widget.rs, crates/alloy-core/src/services/mod.rs |
| Handler + routes | crates/alloy-api/src/handlers/widget.rs, crates/alloy-api/src/handlers/mod.rs |
| App wiring | crates/alloy-api/src/lib.rs (Routers struct + app() fn) |
| CLI | crates/alloy-cli/src/commands/widget.rs, crates/alloy-cli/src/commands/mod.rs |
| MCP | crates/alloy-mcp/src/lib.rs |
| TUI | crates/alloy-tui/src/api.rs |
| Docs | docs/api-reference.md, docs/mcp-tools-reference.md, scripts/seed-demo.sh |