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 hasR = ()because it doesn't need additional context. TheRof callers (who access the service through the environment) is what carries the requirement. Send + Syncon the trait means implementations can be stored inArc<dyn Trait>and shared across fibers.- Method names are verb-oriented:
get_user,save_user, notuserorusers.
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
UserRepositoryTagtype - A
Tagimplementation that mapsUserRepositoryTag→Arc<dyn UserRepository> - A
NeedsUserRepositorysupertrait 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.