الخدمات
الخدمات (Services) تُشكّل طبقة منطق الأعمال في التطبيق، وتقع بين المعالجات والنماذج. تُغلّف قواعد المجال، وتُنسّق استعلامات قاعدة البيانات، وتضمن سلامة البيانات. كل وحدة في مشروع مُولّد بـ FORGE لديها service مخصص تُفوّض له المعالجات.
معمارية الخدمات
Handler Service Database
─────── ─────── ────────
create_user() ────────▶ UserService::create() ────▶ INSERT INTO users
├─ التحقق من التفرد
├─ تجزئة كلمة المرور
├─ إنشاء سجل المستخدم
├─ تعيين الأدوار
└─ إرجاع UserWithRolesالخدمات تمتلك منطق الأعمال. تُقرر ما يحدث عند إنشاء أو تحديث أو حذف مستخدم. المعالجات ببساطة تُمرر الطلب وتُرجع النتيجة.
نصيحة
إذا وجدت نفسك تكتب منطق أعمال في معالج، انقله للـ service. المعالجات يجب أن تحتوي فقط على تحليل الطلب وتفويض الـ service وتنسيق الاستجابة.
نمط الـ Service
كل service يتبع نفس النمط الهيكلي:
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> {
// ...
}
}مثال: UserService
تنفيذ service كامل يُوضّح جميع عمليات CRUD:
القائمة مع ترقيم الصفحات
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))
}
}الإنشاء
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 })
}
}التحديث
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 changed
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 }))
}
}الحذف (حذف ناعم)
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(()))
}
}مساعدات الأدوار والصلاحيات
الخدمات تتضمن دوال مساعدة لإدارة العلاقات:
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 مع الترجمات
service المحتوى يُوضّح العمل مع النماذج القابلة للترجمة:
pub struct ContentService;
impl ContentService {
pub async fn create(
pool: &PgPool,
payload: CreateContentRequest,
) -> Result<Content, AppError> {
// Check 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)
}
}انتشار الأخطاء
الخدمات تستخدم عامل ? لنشر الأخطاء. أخطاء SQLx تتحول تلقائياً لـ AppError:
// sqlx::Error converts automatically 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"]})
));
}تحذير
لا تستخدم panic أبداً في كود الـ service. أرجع دائماً Result<T, AppError> ودع المعالج يحوّله لاستجابة HTTP مناسبة. الـ panics تُعطّل معالج الطلب وتُنتج أخطاء 500 غير مفيدة.
تسجيل الوحدات
سجّل الخدمات في 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;انظر أيضاً
- الـ Handlers — كيف تستدعي المعالجات الخدمات
- النماذج — نماذج قاعدة البيانات التي تستخدمها الخدمات
- معالجة الأخطاء — نوع AppError والانتشار
- قاعدة البيانات — أنماط استعلام SQLx