Skip to main content
Goal: #[routes] controllers + PrismaService over SQLx—same mental model as NestJS + Prisma, with Rust macros. Canonical reference: backend-recipes.md — Recipe A.

Cargo features (PostgreSQL)

nestrs = "0.3.8"
nestrs-prisma = { version = "0.3.8", features = ["sqlx", "sqlx-postgres"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
async-trait = "0.1"
validator = { version = "0.20", features = ["derive"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "macros", "postgres"] }
Enable sqlx-postgres on nestrs-prisma (shown above). Use DATABASE_URL such as
postgresql://USER:PASSWORD@127.0.0.1:5432/myapp.

Bootstrap PrismaModule

use nestrs_prisma::{PrismaModule, PrismaOptions, PrismaService};

let _ = PrismaModule::for_root_with_options(
    PrismaOptions::from_url(std::env::var("DATABASE_URL").expect("DATABASE_URL"))
        .pool_min(1)
        .pool_max(10)
        .schema_path("prisma/schema.prisma"),
);

prisma_model! CRUD (extra example)

After declaring User with prisma_model!(User => "users", { … }):
use nestrs_prisma::SortOrder;

pub async fn page_users(
    prisma: &nestrs_prisma::PrismaService,
    skip: i64,
    take: i64,
) -> Result<Vec<UserRow>, nestrs_prisma::PrismaError> {
    prisma
        .user()
        .find_many_with_options(UserFindManyOptions {
            r#where: UserWhere::and(vec![]),
            order_by: Some(vec![user::id::order(SortOrder::Asc)]),
            take: Some(take.clamp(1, 100)),
            skip: Some(skip.max(0)),
            distinct: None,
        })
        .await
        .map(|rows| rows.into_iter().map(/* → UserRow */).collect())
}
Expose GET /users?skip=&take= with ValidatedQuery and optionally add X-Total-Count from count.

REST CRUD route map

OperationHTTPRepository call
CreatePOST /users/user().create(UserCreateInput { … })
ListGET /users/find_many_with_options (paging)
OneGET /users/:idfind_unique(user::id::equals(id))
UpdatePATCH /users/:idupdate(user::id::equals(id), …)
DeleteDELETE /users/:iddelete_many / single delete per your macro

Run the repo demo

cd examples/hello-app
export DATABASE_URL="sqlite:${PWD}/dev.db"
cargo run
curl -s "http://127.0.0.1:3000/platform/v1/api/db-health"
curl -s "http://127.0.0.1:3000/platform/v1/api/users-db"
Paths use set_global_prefix("platform") + controller version + prefix—see examples/hello-app/src/main.rs in the repo.

Production-grade REST CRUD

These patterns mirror what teams ship behind API gateways (Kong, Envoy): predictable errors, bounded queries, and observability hooks—not “demo CRUD.”

End-to-end example: SaaS users API

This is the kind of CRUD surface teams actually expose from an admin or back-office service: tenant-aware, validated, paginated, and conflict-safe.
use nestrs::prelude::*;
use nestrs_prisma::{PrismaError, PrismaService, SortOrder};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use validator::Validate;

#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserDto {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 2, max = 80))]
    pub name: String,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UpdateUserDto {
    #[validate(length(min = 2, max = 80))]
    pub name: Option<String>,
    pub active: Option<bool>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UsersPageQuery {
    pub skip: Option<i64>,
    pub take: Option<i64>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct UserIdParam {
    pub id: i64,
}

#[derive(Debug, Serialize)]
pub struct UserRow {
    pub id: i64,
    pub email: String,
    pub name: String,
    pub active: bool,
}

#[injectable]
pub struct UserService {
    prisma: Arc<PrismaService>,
}

impl UserService {
    pub async fn create(
        &self,
        tenant_id: &str,
        body: CreateUserDto,
    ) -> Result<UserRow, HttpException> {
        let row = self
            .prisma
            .user()
            .create(UserCreateInput {
                tenant_id: tenant_id.to_owned(),
                email: body.email,
                name: body.name,
                active: true,
            })
            .await
            .map_err(HttpException::from)?;
        Ok(UserRow {
            id: row.id,
            email: row.email,
            name: row.name,
            active: row.active,
        })
    }

    pub async fn list(
        &self,
        tenant_id: &str,
        query: UsersPageQuery,
    ) -> Result<Vec<UserRow>, HttpException> {
        let rows = self
            .prisma
            .user()
            .find_many_with_options(UserFindManyOptions {
                r#where: UserWhere::and(vec![user::tenant_id::equals(tenant_id.to_owned())]),
                order_by: Some(vec![user::id::order(SortOrder::Asc)]),
                take: Some(query.take.unwrap_or(20).clamp(1, 100)),
                skip: Some(query.skip.unwrap_or(0).max(0)),
                distinct: None,
            })
            .await
            .map_err(HttpException::from)?;

        Ok(rows
            .into_iter()
            .map(|row| UserRow {
                id: row.id,
                email: row.email,
                name: row.name,
                active: row.active,
            })
            .collect())
    }

    pub async fn update(
        &self,
        tenant_id: &str,
        id: i64,
        body: UpdateUserDto,
    ) -> Result<UserRow, HttpException> {
        let row = self
            .prisma
            .user()
            .update(
                user::id::equals(id),
                UserUpdateInput {
                    tenant_id: None,
                    email: None,
                    name: body.name,
                    active: body.active,
                },
            )
            .await
            .map_err(|e| match e {
                PrismaError::RowNotFound => NotFoundException::new("user not found"),
                other => HttpException::from(other),
            })?;

        if row.tenant_id != tenant_id {
            return Err(ForbiddenException::new("user not in tenant").into());
        }

        Ok(UserRow {
            id: row.id,
            email: row.email,
            name: row.name,
            active: row.active,
        })
    }
}

#[controller(prefix = "users", version = "v1")]
pub struct UserController {
    service: Arc<UserService>,
}

#[routes]
impl UserController {
    #[post("/")]
    async fn create(
        &self,
        body: ValidatedBody<CreateUserDto>,
    ) -> Result<Json<UserRow>, HttpException> {
        let tenant_id = "org_123"; // Usually derived from auth middleware / request context.
        Ok(Json(self.service.create(tenant_id, body.0).await?))
    }

    #[get("/")]
    async fn list(
        &self,
        query: ValidatedQuery<UsersPageQuery>,
    ) -> Result<Json<Vec<UserRow>>, HttpException> {
        let tenant_id = "org_123";
        Ok(Json(self.service.list(tenant_id, query.0).await?))
    }

    #[patch("/:id")]
    async fn update(
        &self,
        path: ValidatedPath<UserIdParam>,
        body: ValidatedBody<UpdateUserDto>,
    ) -> Result<Json<UserRow>, HttpException> {
        let tenant_id = "org_123";
        Ok(Json(self.service.update(tenant_id, path.0.id, body.0).await?))
    }
}
If your app issues JWTs, tenant_id should come from the verified token or a guard, not from user input. That is the difference between “works locally” and “safe in production.”

Layering (controller → service → data)

Keep #[routes] thin: parse/validate HTTP, delegate to an #[injectable] service that owns Arc<PrismaService>. That keeps transaction boundaries and mapping PrismaErrorHttpException in one place.
//Sketch — names follow your prisma_model! expansion.
#[injectable]
struct UserService {
    prisma: Arc<nestrs_prisma::PrismaService>,
}

impl UserService {
    pub async fn create(&self, body: UserCreateDto) -> Result<UserRow, HttpException> {
        let row = self
            .prisma
            .user()
            .create(UserCreateInput {
                email: body.email,
                name: body.name,
            })
            .await
            .map_err(HttpException::from)?; // UniqueViolation → Conflict (409), …
        Ok(UserRow {
            id: row.id,
            email: row.email,
            name: row.name,
        })
    }
}
PrismaErrorHttpException maps unique violations to ConflictException (409), missing rows to NotFoundException (404), pool issues to ServiceUnavailableException (503). Surface those directly instead of wrapping everything as 500.

Pagination and list safety

Always clamp take on the server (for example max 100) and default skip to 0. Return total row counts only when you need faceted UIs—otherwise count on large tables can dominate latency; consider approximate counts or cursor-based paging for very large lists. Expose query params through ValidatedQuery so invalid types never reach Prisma.

Idempotent creates (payments, orders)

For POST endpoints that must not double-charge when the client retries, accept an Idempotency-Key header (standard pattern). Store (tenant_id, key) → response in Redis or a dedicated SQL table with a TTL; on replay, return the stored body and status without calling create again. nestrs does not ship a built-in idempotency middleware—you implement storage + lookup in your UserService (or a small IdempotencyStore injectable).

Headers and versioning

Pair list responses with X-Total-Count only when clients require exact totals; otherwise prefer Link RFC 5988-style rel="next" for cursor or offset paging. Keep Api-Version or URI versioning aligned with #[controller(version = …)] so deprecations are explicit.

Operations checklist

ConcernProduction habit
MigrationsRun prisma migrate deploy in CI/CD before rolling pods; avoid applying DDL from app startup.
Pool sizeSize pool_max to (expected concurrent requests × avg query time) / target latency; monitor pool wait time in metrics.
TimeoutsPut upper bounds on raw SQL helpers; kill runaway queries at the DB or proxy when possible.
Rate limitsUse NestApplication::use_rate_limit on public CRUD surfaces to absorb abuse.
TracingAttach tracing spans per request and include request_id from headers in logs when debugging 409/503 storms.
Map PrismaError with HttpException::from where Result<_, PrismaError> flows—see Recipe A § A.14 in the mdBook chapter for full filters.