Skip to content

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 UserWithRoles

Services 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:

rust
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

rust
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

rust
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

rust
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)

rust
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:

rust
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:

rust
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:

rust
// 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:

rust
// 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

Released under the MIT License.