Services
Services form the business logic layer of the application, sitting between handlers and models. They encapsulate domain rules, orchestrate database queries, and ensure data integrity. Every module in a FORGE-generated project has a dedicated service that handlers delegate to.
Service Architecture
Handler Service Database
─────── ─────── ────────
create_user() ────────▶ UserService::create() ────▶ INSERT INTO users
├─ Validate uniqueness
├─ Hash password
├─ Create user record
├─ Assign roles
└─ Return UserWithRolesServices own the business logic. They decide what happens when a user is created, updated, or deleted. Handlers simply forward the request and return the result.
TIP
If you find yourself writing business logic in a handler, move it to the service. Handlers should contain only request parsing, service delegation, and response formatting.
Service Pattern
Every service follows the same structural pattern:
use sqlx::PgPool;
use uuid::Uuid;
use crate::dto::{CreateUserRequest, UpdateUserRequest, PaginatedRequest};
use crate::models::user::{User, UserWithRoles};
use crate::utils::error::AppError;
use crate::utils::password::hash_password;
pub struct UserService;
impl UserService {
/// List users with pagination and search
pub async fn list(
pool: &PgPool,
params: &PaginatedRequest,
) -> Result<(Vec<UserWithRoles>, i64), AppError> {
// ...
}
/// Find a single user by ID
pub async fn find_by_id(
pool: &PgPool,
id: Uuid,
) -> Result<Option<UserWithRoles>, AppError> {
// ...
}
/// Create a new user
pub async fn create(
pool: &PgPool,
payload: CreateUserRequest,
) -> Result<UserWithRoles, AppError> {
// ...
}
/// Update an existing user
pub async fn update(
pool: &PgPool,
id: Uuid,
payload: UpdateUserRequest,
) -> Result<Option<UserWithRoles>, AppError> {
// ...
}
/// Soft-delete a user
pub async fn delete(
pool: &PgPool,
id: Uuid,
) -> Result<Option<()>, AppError> {
// ...
}
}Example: UserService
A complete service implementation demonstrating all CRUD operations:
List with Pagination
impl UserService {
pub async fn list(
pool: &PgPool,
params: &PaginatedRequest,
) -> Result<(Vec<UserWithRoles>, i64), AppError> {
let offset = (params.page - 1) * params.per_page;
let search = params.search.as_deref();
// Get total count
let total = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!"
FROM users
WHERE deleted_at IS NULL
AND ($1::text IS NULL
OR name ILIKE '%' || $1 || '%'
OR email ILIKE '%' || $1 || '%')
"#,
search
)
.fetch_one(pool)
.await?;
// Get paginated results
let users = sqlx::query_as!(
User,
r#"
SELECT id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
FROM users
WHERE deleted_at IS NULL
AND ($1::text IS NULL
OR name ILIKE '%' || $1 || '%'
OR email ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
search,
params.per_page,
offset
)
.fetch_all(pool)
.await?;
// Load roles for each user
let mut result = Vec::new();
for user in users {
let roles = Self::load_roles(pool, user.id).await?;
result.push(UserWithRoles { user, roles });
}
Ok((result, total))
}
}Create
impl UserService {
pub async fn create(
pool: &PgPool,
payload: CreateUserRequest,
) -> Result<UserWithRoles, AppError> {
// Check email uniqueness
let existing = sqlx::query_scalar!(
"SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL",
&payload.email
)
.fetch_optional(pool)
.await?;
if existing.is_some() {
return Err(AppError::ValidationError(
serde_json::json!({
"email": ["This email is already in use"]
})
));
}
// Hash password
let password_hash = hash_password(&payload.password)?;
// Insert user
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (name, email, password, mobile, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
"#,
payload.name,
payload.email,
password_hash,
payload.mobile,
payload.is_active.unwrap_or(true)
)
.fetch_one(pool)
.await?;
// Assign roles if provided
if let Some(role_ids) = payload.role_ids {
Self::assign_roles(pool, user.id, &role_ids).await?;
}
// Return user with roles
let roles = Self::load_roles(pool, user.id).await?;
Ok(UserWithRoles { user, roles })
}
}Update
impl UserService {
pub async fn update(
pool: &PgPool,
id: Uuid,
payload: UpdateUserRequest,
) -> Result<Option<UserWithRoles>, AppError> {
// Check if user exists
let existing = sqlx::query!(
"SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL",
id
)
.fetch_optional(pool)
.await?;
if existing.is_none() {
return Ok(None);
}
// Check email uniqueness if changing email
if let Some(ref email) = payload.email {
let conflict = sqlx::query_scalar!(
"SELECT id FROM users WHERE email = $1 AND id != $2 AND deleted_at IS NULL",
email,
id
)
.fetch_optional(pool)
.await?;
if conflict.is_some() {
return Err(AppError::ValidationError(
serde_json::json!({
"email": ["This email is already in use"]
})
));
}
}
// Build dynamic update query
let password_hash = match payload.password {
Some(ref pw) => Some(hash_password(pw)?),
None => None,
};
let user = sqlx::query_as!(
User,
r#"
UPDATE users SET
name = COALESCE($1, name),
email = COALESCE($2, email),
password = COALESCE($3, password),
mobile = COALESCE($4, mobile),
is_active = COALESCE($5, is_active),
updated_at = now()
WHERE id = $6 AND deleted_at IS NULL
RETURNING id, name, email, password, mobile, avatar,
email_verified_at, is_active,
created_at, updated_at, deleted_at
"#,
payload.name,
payload.email,
password_hash,
payload.mobile,
payload.is_active,
id
)
.fetch_one(pool)
.await?;
// Update roles if provided
if let Some(role_ids) = payload.role_ids {
Self::sync_roles(pool, id, &role_ids).await?;
}
let roles = Self::load_roles(pool, user.id).await?;
Ok(Some(UserWithRoles { user, roles }))
}
}Delete (Soft Delete)
impl UserService {
pub async fn delete(
pool: &PgPool,
id: Uuid,
) -> Result<Option<()>, AppError> {
let result = sqlx::query!(
r#"
UPDATE users
SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
"#,
id
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(Some(()))
}
}Role and Permission Helpers
Services include helper methods for relationship management:
impl UserService {
/// Load all roles for a user
async fn load_roles(pool: &PgPool, user_id: Uuid) -> Result<Vec<Role>, AppError> {
let roles = sqlx::query_as!(
Role,
r#"
SELECT r.id, r.name, r.display_name, r.description,
r.is_system, r.created_at, r.updated_at
FROM roles r
INNER JOIN role_user ru ON ru.role_id = r.id
WHERE ru.user_id = $1
"#,
user_id
)
.fetch_all(pool)
.await?;
Ok(roles)
}
/// Replace all roles for a user (sync)
async fn sync_roles(
pool: &PgPool,
user_id: Uuid,
role_ids: &[Uuid],
) -> Result<(), AppError> {
// Remove existing roles
sqlx::query!("DELETE FROM role_user WHERE user_id = $1", user_id)
.execute(pool)
.await?;
// Assign new roles
Self::assign_roles(pool, user_id, role_ids).await
}
/// Assign roles to a user (additive)
async fn assign_roles(
pool: &PgPool,
user_id: Uuid,
role_ids: &[Uuid],
) -> Result<(), AppError> {
for role_id in role_ids {
sqlx::query!(
r#"
INSERT INTO role_user (user_id, role_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
user_id,
role_id
)
.execute(pool)
.await?;
}
Ok(())
}
}ContentService with Translations
The content service demonstrates working with translatable models:
pub struct ContentService;
impl ContentService {
pub async fn create(
pool: &PgPool,
payload: CreateContentRequest,
) -> Result<Content, AppError> {
// Validate slug uniqueness
let existing = sqlx::query_scalar!(
"SELECT id FROM contents WHERE slug = $1 AND deleted_at IS NULL",
&payload.slug
)
.fetch_optional(pool)
.await?;
if existing.is_some() {
return Err(AppError::ValidationError(
serde_json::json!({
"slug": ["This slug is already in use"]
})
));
}
let content = sqlx::query_as!(
Content,
r#"
INSERT INTO contents (slug, content_type, translations, metadata, is_active, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
payload.slug,
payload.content_type,
payload.translations,
payload.metadata,
payload.is_active.unwrap_or(true),
payload.sort_order.unwrap_or(0)
)
.fetch_one(pool)
.await?;
Ok(content)
}
pub async fn find_by_slug(
pool: &PgPool,
slug: &str,
) -> Result<Option<Content>, AppError> {
let content = sqlx::query_as!(
Content,
"SELECT * FROM contents WHERE slug = $1 AND deleted_at IS NULL",
slug
)
.fetch_optional(pool)
.await?;
Ok(content)
}
}Error Propagation
Services use the ? operator to propagate errors. SQLx errors are automatically converted to AppError:
// sqlx::Error automatically converts to AppError via From trait
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(pool)
.await?; // sqlx::Error -> AppError::InternalError
// Business logic errors are returned explicitly
if existing.is_some() {
return Err(AppError::ValidationError(
serde_json::json!({"email": ["Already in use"]})
));
}WARNING
Never panic in service code. Always return Result<T, AppError> and let the handler convert it to an appropriate HTTP response. Panics crash the request handler and produce uninformative 500 errors.
Module Registration
Register services in src/services/mod.rs:
// src/services/mod.rs
pub mod auth;
pub mod content;
pub mod lookup;
pub mod media;
pub mod menu;
pub mod user;
pub mod role;
pub use auth::AuthService;
pub use content::ContentService;
pub use lookup::LookupService;
pub use media::MediaService;
pub use menu::MenuService;
pub use user::UserService;
pub use role::RoleService;See Also
- Handlers — How handlers call services
- Models — Database models used by services
- Error Handling — AppError type and propagation
- Database — SQLx query patterns