Service Traits — Defining Interfaces

The first step in defining a service is the trait — the contract between the service and its callers.

Define the Interface

#![allow(unused)]
fn main() {
use id_effect::{Effect};

// The interface contract
trait UserRepository: Send + Sync {
    fn get_user(&self, id: u64) -> Effect<User, DbError, ()>;
    fn save_user(&self, user: &User) -> Effect<(), DbError, ()>;
    fn list_users(&self) -> Effect<Vec<User>, DbError, ()>;
}
}

A few conventions:

  • Methods return Effect<_, _, ()> — the service itself has R = () because it doesn't need additional context. The R of callers (who access the service through the environment) is what carries the requirement.
  • Send + Sync on the trait means implementations can be stored in Arc<dyn Trait> and shared across fibers.
  • Method names are verb-oriented: get_user, save_user, not user or users.

Define the Tag

Each service needs a tag that identifies it in the environment:

#![allow(unused)]
fn main() {
use id_effect::service_key;

// Associates UserRepositoryTag with Arc<dyn UserRepository>
service_key!(UserRepositoryTag: Arc<dyn UserRepository>);
}

service_key! generates:

  • A zero-sized UserRepositoryTag type
  • A Tag implementation that maps UserRepositoryTagArc<dyn UserRepository>
  • A NeedsUserRepository supertrait that functions can use in bounds

Define the Needs Supertrait

#![allow(unused)]
fn main() {
use id_effect::{Get};

// Generated by service_key! or defined manually
pub trait NeedsUserRepository: Get<UserRepositoryTag> {}
impl<R: Get<UserRepositoryTag>> NeedsUserRepository for R {}
}

Now any function that uses the user repository declares this:

#![allow(unused)]
fn main() {
fn get_user_profile(id: u64) -> Effect<UserProfile, AppError, impl NeedsUserRepository> {
    effect! {
        let repo = ~ UserRepositoryTag;  // get the service
        let user = ~ repo.get_user(id);
        UserProfile::from(user)
    }
}
}

The compiler checks that NeedsUserRepository is satisfied before the function can run.

Keeping Traits Focused

A common mistake is defining one massive AppService trait with everything in it. Prefer small, focused traits:

#![allow(unused)]
fn main() {
// BAD — one God trait
trait AppService {
    fn get_user(&self, id: u64) -> Effect<User, AppError, ()>;
    fn send_email(&self, to: &str, body: &str) -> Effect<(), AppError, ()>;
    fn charge_card(&self, amount: u64) -> Effect<(), AppError, ()>;
}

// GOOD — separate concerns
trait UserRepository { fn get_user(&self, id: u64) -> Effect<User, DbError, ()>; }
trait EmailService { fn send_email(&self, to: &str, body: &str) -> Effect<(), EmailError, ()>; }
trait PaymentGateway { fn charge(&self, amount: u64) -> Effect<(), PaymentError, ()>; }
}

Small traits are composable. Functions declare exactly which services they need — NeedsUserRepository + NeedsEmailService — and the compiler enforces it.