Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Service Layer Patterns

The service layer lives in crates/alloy-core/src/services/ and encapsulates all business logic — validation, ownership checks, permission gates, and audit logging. Services are framework-free: no Axum, no SQLx, no Tokio. They depend only on repository traits defined in alloy-core.

ActorContext

ActorContext is the framework-free caller identity. Handlers convert their Axum-specific AuthContext into an ActorContext before calling service methods.

Defined in: crates/alloy-core/src/services/mod.rs

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct ActorContext {
    pub user_id: UserId,
    pub org_id: OrgId,
    pub email: String,
    pub role: OrgRole,
    pub scopes: String,
    pub allowed_project_ids: String,
}
}
FieldTypePurpose
user_idUserIdAuthenticated caller’s ID
org_idOrgIdOrganisation the request is scoped to
emailStringUser’s email address
roleOrgRoleHierarchical role (Owner > Admin > Member > Reporter > Viewer)
scopesStringComma-separated scopes or "*" for full access
allowed_project_idsStringComma-separated project IDs, or empty for unrestricted

Key methods:

  • has_scope(scope: &str) -> bool — checks scope membership (wildcard and admin escalation rules apply)
  • has_project_access(project_id: &str) -> bool — verifies the caller can access a specific project
  • has_role(minimum: &OrgRole) -> bool — checks role hierarchy

ServiceError

ServiceError is the domain error type returned by all service methods. It maps cleanly to HTTP status codes via a From impl in alloy-api.

Defined in: crates/alloy-core/src/services/mod.rs

#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("already exists: {0}")]
    AlreadyExists(String),
    #[error("constraint violation: {0}")]
    ConstraintViolation(String),
    #[error("permission denied: {0}")]
    PermissionDenied(String),
    #[error("validation error: {0}")]
    Validation(String),
    #[error("internal error: {0}")]
    Internal(String),
}
}

Each variant wraps a String message. Uses thiserror::Error for automatic Display and std::error::Error implementations.

From ServiceError for ApiError

Defined in: crates/alloy-api/src/error.rs

The conversion maps domain errors to HTTP semantics:

#![allow(unused)]
fn main() {
impl From<ServiceError> for ApiError {
    fn from(err: ServiceError) -> Self {
        match err {
            ServiceError::NotFound(msg) => Self::NotFound(msg),           // 404
            ServiceError::AlreadyExists(msg) => Self::Conflict(msg, vec![]), // 409
            ServiceError::ConstraintViolation(msg)
            | ServiceError::Validation(msg) => Self::Validation(vec![msg]),  // 422
            ServiceError::PermissionDenied(msg) => Self::Forbidden(msg),     // 403
            ServiceError::Internal(msg) => Self::Internal(msg),              // 500
        }
    }
}
}
ServiceErrorApiErrorHTTP Status
NotFoundNotFound404
AlreadyExistsConflict409
ConstraintViolationValidation422
ValidationValidation422
PermissionDeniedForbidden403
InternalInternal500

Handlers can use ? to propagate service errors — Axum’s IntoResponse serialises the ApiError as JSON automatically.

record_audit Helper

A pure function that builds audit log entries without touching a repository. Callers pass the result to AuditLogRepository::create().

Defined in: crates/alloy-core/src/services/mod.rs

#![allow(unused)]
fn main() {
pub fn record_audit(
    actor: &ActorContext,
    entity_type: impl Into<String>,
    entity_id: impl Into<String>,
    action: AuditAction,
    changes: Vec<FieldChange>,
) -> CreateAuditLogEntry
}
ParameterTypePurpose
actor&ActorContextCaller identity for attribution
entity_typeimpl Into<String>Resource type (e.g. "ticket", "project")
entity_idimpl Into<String>ID of the affected entity
actionAuditActionCreate, Update, or Delete
changesVec<FieldChange>Field-level mutations (may be empty)

Usage:

#![allow(unused)]
fn main() {
let audit = record_audit(
    actor,
    "ticket",
    &ticket.id.0.to_string(),
    AuditAction::Update,
    vec![FieldChange { field: "title".into(), old: old.title.clone(), new: ticket.title.clone() }],
);
self.audit_log_repo.create(audit).await?;
}

Creating a New Service Struct

All services follow the same generic-over-repos pattern:

Step 1: Define the struct with type parameters

Each type parameter corresponds to a repository trait. Every service includes an AuditLogRepository for mutation logging.

#![allow(unused)]
fn main() {
pub struct ProjectService<P, A> {
    pub project_repo: P,
    pub audit_log_repo: A,
}
}

Step 2: Implement with trait bounds

Bound each type parameter to its repository trait in the impl block:

#![allow(unused)]
fn main() {
impl<P: ProjectRepository, A: AuditLogRepository> ProjectService<P, A> {
    pub fn new(project_repo: P, audit_log_repo: A) -> Self {
        Self {
            project_repo,
            audit_log_repo,
        }
    }

    pub async fn create_project(
        &self,
        actor: &ActorContext,
        params: CreateProjectParams,
    ) -> Result<Project, ServiceError> {
        // 1. Validate inputs
        // 2. Call repository
        // 3. Record audit entry
        // 4. Return result
    }
}
}

Step 3: Wire into AppService

AppService in alloy-api composes all individual services and their repository implementations. New services are added as fields on AppService and constructed in its new() method.

Template

For a new FooService managing Foo resources:

#![allow(unused)]
fn main() {
use crate::models::Foo;
use crate::repos::{AuditLogRepository, FooRepository};
use crate::services::{ActorContext, ServiceError, record_audit};
use crate::models::audit::{AuditAction, FieldChange};

pub struct FooService<F, A> {
    pub foo_repo: F,
    pub audit_log_repo: A,
}

impl<F: FooRepository, A: AuditLogRepository> FooService<F, A> {
    pub fn new(foo_repo: F, audit_log_repo: A) -> Self {
        Self { foo_repo, audit_log_repo }
    }

    pub async fn create_foo(
        &self,
        actor: &ActorContext,
        params: CreateFooParams,
    ) -> Result<Foo, ServiceError> {
        // Validate
        if params.name.is_empty() {
            return Err(ServiceError::Validation("name is required".into()));
        }

        // Persist
        let foo = self.foo_repo.create(params).await?;

        // Audit
        let audit = record_audit(
            actor, "foo", &foo.id.0.to_string(),
            AuditAction::Create, vec![],
        );
        self.audit_log_repo.create(audit).await?;

        Ok(foo)
    }
}
}

Existing services

ServiceFileType ParamsRepos Used
ProjectServiceservices/project.rs<P, A>ProjectRepository, AuditLogRepository
TimeEntryServiceservices/time_entry.rs<T, A>TimeEntryRepository, AuditLogRepository
CommentServiceservices/comment.rs<C, A>CommentRepository, AuditLogRepository
ApiKeyServiceservices/api_key.rs<K, A>ApiKeyRepository, AuditLogRepository
AttachmentServiceservices/attachment.rs<T, A>AttachmentRepository, AuditLogRepository

Ownership Checks Pattern

Most mutations follow a consistent ownership-check pattern: fetch the resource, verify the caller owns it or is Admin+, then proceed.

Standard pattern

#![allow(unused)]
fn main() {
let resource = self.repo.get_by_id(id).await?;

if resource.owner_id != actor.user_id
    && actor.role.level() < OrgRole::Admin.level()
{
    return Err(ServiceError::PermissionDenied(
        "only the owner or an admin can modify this resource".into(),
    ));
}
}

Rules:

  1. Always fetch first — this naturally produces NotFound for missing resources before checking permissions.
  2. Compare the owner field against actor.user_id. The field name varies by entity: author_id (comments), user_id (time entries), uploaded_by (attachments).
  3. Admin bypassactor.role.level() < OrgRole::Admin.level() gates the bypass. Admins and Owners can mutate any resource.
  4. Return PermissionDenied — not NotFound — so the caller knows the resource exists but they lack access.

Role-only checks

Some operations skip ownership entirely and gate on role alone:

#![allow(unused)]
fn main() {
if actor.role.level() < OrgRole::Admin.level() {
    return Err(ServiceError::PermissionDenied(
        "only admins can approve time entries".into(),
    ));
}
}

Used for administrative operations like time entry approval.

Org membership checks

Creation operations verify the caller belongs to the target org:

#![allow(unused)]
fn main() {
if params.org_id != actor.org_id {
    return Err(ServiceError::PermissionDenied(
        "org membership mismatch".into(),
    ));
}
}

Cross-org isolation

Repository methods that take org_id as a parameter provide defense-in-depth against cross-tenant access, supplementing PostgreSQL’s RLS policies:

#![allow(unused)]
fn main() {
let project = self.project_repo.get_by_id(id, actor.org_id).await?;
}

If the project belongs to a different org, the query returns no rows and the service returns NotFound.