Skip to content

Database-Backed Users Feature

A full DB-first users feature example with migration, module registration, DTOs, service code, controller code, and a testing path.

This page gives the complete shape that was missing from the SQL docs.

It is a DB-first example, not a ResourceService example.

The feature you build is:

  • SQL-backed from the beginning
  • mounted under /api/v1/users
  • structured like a normal NestForge feature
  • explicit about every file involved
src/
app_module.rs
main.rs
users/
controllers/
mod.rs
users_controller.rs
dto/
mod.rs
create_user_dto.rs
update_user_dto.rs
user_dto.rs
services/
mod.rs
users_service.rs
mod.rs
migrations/
20260304_create_users_table.sql

You need NestForge plus sqlx derive support for row mapping:

[dependencies]
nestforge = { version = "1", features = ["config", "testing"] }
sqlx = { version = "0.8", default-features = false, features = ["macros"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1"

Initialize the migration workspace:

Terminal window
nestforge db init
nestforge db generate create_users_table

Example migration file:

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);

Then apply it:

Terminal window
nestforge db migrate
nestforge db status

Create app_module.rs with this shape:

use nestforge::{module, Db, DbConfig};
use crate::users::UsersModule;
fn connect_db() -> anyhow::Result<Db> {
Ok(Db::connect_lazy(DbConfig::new(
"postgres://postgres:postgres@localhost/my_app",
))?)
}
#[module(
imports = [UsersModule],
controllers = [],
providers = [connect_db()?],
exports = [Db]
)]
pub struct AppModule;

This is the key rule for DB-first apps:

  • the root module owns the DB connection
  • feature modules consume it

Create src/users/dto/user_dto.rs:

#[nestforge::dto]
pub struct UserDto {
pub id: u64,
pub name: String,
pub email: String,
}

Create src/users/dto/create_user_dto.rs:

#[nestforge::dto]
pub struct CreateUserDto {
#[validate(required)]
pub name: String,
#[validate(required, email)]
pub email: String,
}

Create src/users/dto/update_user_dto.rs:

#[nestforge::dto]
pub struct UpdateUserDto {
pub name: Option<String>,
#[validate(email)]
pub email: Option<String>,
}

Create src/users/dto/mod.rs:

pub mod create_user_dto;
pub mod update_user_dto;
pub mod user_dto;
pub use create_user_dto::CreateUserDto;
pub use update_user_dto::UpdateUserDto;
pub use user_dto::UserDto;

Create src/users/services/users_service.rs:

use nestforge::Db;
use crate::users::dto::{CreateUserDto, UpdateUserDto, UserDto};
#[derive(Clone)]
pub struct UsersService {
db: Db,
}
#[derive(sqlx::FromRow)]
struct UserRow {
id: i64,
name: String,
email: String,
}
impl From<UserRow> for UserDto {
fn from(row: UserRow) -> Self {
Self {
id: row.id as u64,
name: row.name,
email: row.email,
}
}
}
impl UsersService {
pub fn new(db: Db) -> Self {
Self { db }
}
}
pub async fn list_users(service: &UsersService) -> anyhow::Result<Vec<UserDto>> {
let rows: Vec<UserRow> = service
.db
.fetch_all("SELECT id, name, email FROM users ORDER BY id")
.await?;
Ok(rows.into_iter().map(UserDto::from).collect())
}
pub async fn get_user(service: &UsersService, id: u64) -> anyhow::Result<Option<UserDto>> {
let rows: Vec<UserRow> = service
.db
.fetch_all(&format!(
"SELECT id, name, email FROM users WHERE id = {}",
id
))
.await?;
Ok(rows.into_iter().next().map(UserDto::from))
}
pub async fn create_user(service: &UsersService, dto: CreateUserDto) -> anyhow::Result<UserDto> {
service.db.execute(&format!(
"INSERT INTO users (name, email) VALUES ('{}', '{}')",
dto.name.replace('\'', "''"),
dto.email.replace('\'', "''"),
)).await?;
let rows: Vec<UserRow> = service
.db
.fetch_all("SELECT id, name, email FROM users ORDER BY id DESC LIMIT 1")
.await?;
let row = rows
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("insert succeeded but no row was returned"))?;
Ok(UserDto::from(row))
}
pub async fn update_user(
service: &UsersService,
id: u64,
dto: UpdateUserDto,
) -> anyhow::Result<Option<UserDto>> {
if let Some(name) = dto.name {
service
.db
.execute(&format!(
"UPDATE users SET name = '{}' WHERE id = {}",
name.replace('\'', "''"),
id
))
.await?;
}
if let Some(email) = dto.email {
service
.db
.execute(&format!(
"UPDATE users SET email = '{}' WHERE id = {}",
email.replace('\'', "''"),
id
))
.await?;
}
get_user(service, id).await
}
pub async fn delete_user(service: &UsersService, id: u64) -> anyhow::Result<Option<UserDto>> {
let existing = get_user(service, id).await?;
if existing.is_some() {
service
.db
.execute(&format!("DELETE FROM users WHERE id = {}", id))
.await?;
}
Ok(existing)
}

Create src/users/services/mod.rs:

pub mod users_service;
pub use users_service::{
UsersService, create_user, delete_user, get_user, list_users, update_user,
};

Important note:

  • this example is intentionally explicit so the full file context is visible
  • in production code, prefer parameterized queries instead of string formatting

Step 6: register the service in the feature module

Section titled “Step 6: register the service in the feature module”

Create src/users/mod.rs:

pub mod controllers;
pub mod dto;
pub mod services;
use nestforge::{module, register_provider, Container, ControllerDefinition, Db, Provider};
use self::controllers::UsersController;
use self::services::UsersService;
fn register_users_service(container: &Container) -> anyhow::Result<()> {
register_provider(
container,
Provider::factory(|c| {
let db = c.resolve::<Db>()?;
Ok(UsersService::new(db))
}),
)?;
Ok(())
}
pub struct UsersModule;
impl nestforge::ModuleDefinition for UsersModule {
fn register(container: &Container) -> anyhow::Result<()> {
register_users_service(container)
}
fn controllers() -> Vec<axum::Router<Container>> {
vec![<UsersController as ControllerDefinition>::router()]
}
}

This is why the earlier docs felt unclear: the missing piece was the actual provider registration shape for a DB-backed service. The service is not a type alias anymore. It is a real provider built from Db.

Create src/users/controllers/users_controller.rs:

use axum::Json;
use nestforge::{
controller, routes, ApiResult, Inject, List, OptionHttpExt, Param, ValidatedBody,
};
use crate::users::{
dto::{CreateUserDto, UpdateUserDto, UserDto},
services::{UsersService, create_user, delete_user, get_user, list_users, update_user},
};
#[controller("/users")]
pub struct UsersController;
#[routes]
impl UsersController {
#[nestforge::get("/")]
#[nestforge::version("1")]
async fn list(users: Inject<UsersService>) -> ApiResult<List<UserDto>> {
let users = list_users(users.as_ref())
.await
.map_err(|err| nestforge::HttpException::internal_server_error(err.to_string()))?;
Ok(Json(users))
}
#[nestforge::get("/{id}")]
#[nestforge::version("1")]
async fn get_user_by_id(id: Param<u64>, users: Inject<UsersService>) -> ApiResult<UserDto> {
let id = id.value();
let user = get_user(users.as_ref(), id)
.await
.map_err(|err| nestforge::HttpException::internal_server_error(err.to_string()))?
.or_not_found_id("User", id)?;
Ok(Json(user))
}
#[nestforge::post("/")]
#[nestforge::version("1")]
async fn create(
users: Inject<UsersService>,
body: ValidatedBody<CreateUserDto>,
) -> ApiResult<UserDto> {
let user = create_user(users.as_ref(), body.value())
.await
.map_err(|err| nestforge::HttpException::internal_server_error(err.to_string()))?;
Ok(Json(user))
}
#[nestforge::put("/{id}")]
#[nestforge::version("1")]
async fn update(
id: Param<u64>,
users: Inject<UsersService>,
body: ValidatedBody<UpdateUserDto>,
) -> ApiResult<UserDto> {
let id = id.value();
let updated = update_user(users.as_ref(), id, body.value())
.await
.map_err(|err| nestforge::HttpException::internal_server_error(err.to_string()))?
.or_not_found_id("User", id)?;
Ok(Json(updated))
}
#[nestforge::delete("/{id}")]
#[nestforge::version("1")]
async fn delete(id: Param<u64>, users: Inject<UsersService>) -> ApiResult<UserDto> {
let id = id.value();
let deleted = delete_user(users.as_ref(), id)
.await
.map_err(|err| nestforge::HttpException::internal_server_error(err.to_string()))?
.or_not_found_id("User", id)?;
Ok(Json(deleted))
}
}

Create src/users/controllers/mod.rs:

pub mod users_controller;
pub use users_controller::UsersController;

Create src/main.rs:

mod app_module;
mod users;
use app_module::AppModule;
use nestforge::{NestForgeFactory, NestForgeFactoryOpenApiExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
NestForgeFactory::<AppModule>::create()?
.with_global_prefix("api")
.with_openapi_docs("My API", "1.0.0")?
.listen(3000)
.await
}

Example testing pattern:

#[tokio::test]
async fn users_route_is_mounted() {
let module = nestforge::TestFactory::<AppModule>::create()
.build()
.expect("module should build");
let response = module
.http_router()
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/users")
.body(axum::body::Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
  1. the migration creates the users table
  2. Db resolves from the root module
  3. UsersService resolves from the feature module
  4. /api/v1/users is mounted
  5. create, read, update, and delete routes all hit SQL-backed service code

This is the full code context the workflow page did not previously provide:

  • which files exist
  • what goes in each file
  • how the DB-backed provider is actually registered
  • how the controller uses it
  • how the app is bootstrapped and tested