Provider Pattern
FORGE uses a trait-based provider pattern to integrate with external services. Instead of coupling your application to a specific SMS gateway, email service, or storage backend, FORGE generates code that programs against abstract interfaces. A factory selects the concrete implementation at runtime based on configuration.
This means you can switch from Twilio to Unifonic, from S3 to local storage, or from SendGrid to SMTP by changing a single value in your configuration -- no code changes required.
Architecture
Application Code
│
▼
┌─────────────┐
│ Provider │ ← Abstract trait (interface)
│ Contract │
└──────┬──────┘
│
┌───────┼───────┐
▼ ▼ ▼
┌───────┐ ┌─────┐ ┌─────┐
│Twilio │ │Uni- │ │ Log │ ← Concrete implementations (drivers)
│ │ │fonic│ │ │
└───────┘ └─────┘ └─────┘Your application code calls sms_provider.send(to, message). Whether that sends a real SMS through Twilio, routes through Unifonic, or simply writes to a log file depends entirely on the configured driver.
Provider Categories
FORGE generates four provider categories, each with multiple driver implementations:
| Category | Trait | Available Drivers |
|---|---|---|
| SMS | SmsProvider | twilio, unifonic, log |
EmailProvider | smtp, sendgrid, log | |
| Storage | StorageProvider | local, s3 |
| Payments | PaymentProvider | hyperpay, checkout, log |
TIP
Every category includes a log driver that writes operations to the application log instead of calling external APIs. This is the default during development, so you can build and test without configuring third-party credentials.
The SMS Provider Contract
Here is the complete SMS provider trait that every SMS driver must implement:
#[async_trait]
pub trait SmsProvider: Send + Sync {
/// Get the provider name (e.g., "twilio", "unifonic")
fn name(&self) -> &'static str;
/// Send a plain SMS message
async fn send(&self, to: &str, message: &str) -> Result<SmsResult, SmsError>;
/// Send an OTP code (may use provider's verify API)
async fn send_otp(&self, to: &str, code: &str) -> Result<SmsResult, SmsError>;
/// Verify an OTP code (if provider supports server-side verification)
async fn verify_otp(&self, to: &str, code: &str) -> Result<bool, SmsError>;
/// Validate a phone number format
fn validate_phone(&self, phone: &str) -> bool;
/// Check delivery status of a sent message
async fn get_status(&self, message_id: &str) -> Result<SmsStatus, SmsError>;
}
pub struct SmsResult {
pub message_id: String,
pub status: SmsStatus,
pub cost: Option<f64>,
}
pub enum SmsStatus {
Queued,
Sent,
Delivered,
Failed(String),
}Every driver -- Twilio, Unifonic, or any custom implementation -- must provide all five methods. The application code never needs to know which driver is active.
Implementation Matrix
| Method | Twilio | Unifonic | Log |
|---|---|---|---|
send() | Twilio Messages API | Unifonic Send API | Writes to log |
send_otp() | Twilio Verify API | Custom implementation | Writes to log |
verify_otp() | Twilio Verify Check | Custom implementation | Always returns true |
validate_phone() | E.164 validation | E.164 validation | Always returns true |
get_status() | Twilio Message Status | Unifonic Status API | Returns Delivered |
The Email Provider Contract
#[async_trait]
pub trait EmailProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Send an email message
async fn send(&self, message: EmailMessage) -> Result<EmailResult, EmailError>;
/// Send using a provider-hosted template
async fn send_template(
&self,
template_id: &str,
to: &str,
data: HashMap<String, Value>,
) -> Result<EmailResult, EmailError>;
/// Validate an email address format
fn validate_email(&self, email: &str) -> bool;
}
pub struct EmailMessage {
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub subject: String,
pub body_text: Option<String>,
pub body_html: Option<String>,
pub attachments: Vec<Attachment>,
pub reply_to: Option<String>,
pub headers: HashMap<String, String>,
}The Storage Provider Contract
#[async_trait]
pub trait StorageProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Store a file from bytes
async fn put(&self, path: &str, content: &[u8]) -> Result<StoredFile, StorageError>;
/// Store from a stream (for large files)
async fn put_stream(
&self, path: &str, stream: impl AsyncRead
) -> Result<StoredFile, StorageError>;
/// Retrieve file contents
async fn get(&self, path: &str) -> Result<Vec<u8>, StorageError>;
/// Get the public URL for a file
fn url(&self, path: &str) -> String;
/// Get a temporary signed URL (for private files)
fn temporary_url(
&self, path: &str, expires: Duration
) -> Result<String, StorageError>;
/// Delete a file
async fn delete(&self, path: &str) -> Result<(), StorageError>;
/// Check if a file exists
async fn exists(&self, path: &str) -> bool;
/// Copy a file to a new path
async fn copy(&self, from: &str, to: &str) -> Result<StoredFile, StorageError>;
/// Move a file to a new path
async fn mv(&self, from: &str, to: &str) -> Result<StoredFile, StorageError>;
}The Payment Provider Contract
#[async_trait]
pub trait PaymentProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Create a checkout session (returns redirect URL)
async fn create_checkout(
&self, request: CheckoutRequest
) -> Result<CheckoutSession, PaymentError>;
/// Get payment status by ID
async fn get_payment(
&self, payment_id: &str
) -> Result<PaymentResult, PaymentError>;
/// Capture an authorized payment
async fn capture(
&self, payment_id: &str, amount: Option<Decimal>
) -> Result<PaymentResult, PaymentError>;
/// Refund a payment (full or partial)
async fn refund(
&self, payment_id: &str, amount: Option<Decimal>
) -> Result<PaymentResult, PaymentError>;
/// Verify a webhook signature from the provider
fn verify_webhook(&self, payload: &[u8], signature: &str) -> bool;
/// Parse a webhook payload into a structured event
fn parse_webhook(
&self, payload: &[u8]
) -> Result<WebhookEvent, PaymentError>;
}The Factory Pattern
FORGE generates a ProviderFactory that reads the application configuration and returns the correct driver:
pub struct ProviderFactory;
impl ProviderFactory {
pub fn sms(config: &Config) -> Box<dyn SmsProvider> {
match config.sms_driver.as_str() {
"twilio" => Box::new(TwilioProvider::new(config)),
"unifonic" => Box::new(UnifonicProvider::new(config)),
_ => Box::new(LogProvider::new("sms")),
}
}
pub fn email(config: &Config) -> Box<dyn EmailProvider> {
match config.email_driver.as_str() {
"smtp" => Box::new(SmtpProvider::new(config)),
"sendgrid" => Box::new(SendGridProvider::new(config)),
_ => Box::new(LogProvider::new("email")),
}
}
pub fn storage(config: &Config) -> Box<dyn StorageProvider> {
match config.storage_driver.as_str() {
"local" => Box::new(LocalStorageProvider::new(config)),
"s3" => Box::new(S3StorageProvider::new(config)),
_ => Box::new(LocalStorageProvider::new(config)),
}
}
pub fn payment(config: &Config) -> Option<Box<dyn PaymentProvider>> {
match config.payment_driver.as_deref() {
Some("hyperpay") => Some(Box::new(HyperPayProvider::new(config))),
Some("checkout") => Some(Box::new(CheckoutProvider::new(config))),
_ => None,
}
}
}WARNING
The payment provider returns Option because not every application needs payment processing. Always check for None before attempting to charge a customer.
Configuration
Providers are configured in forge.yaml at generation time and in the settings database table at runtime.
Generation-Time Configuration
Set the default drivers in forge.yaml:
# forge.yaml
providers:
sms: twilio
email: smtp
storage: local
payment: hyperpay # optionalThis determines which driver implementations are included in the generated code.
Runtime Configuration
Provider credentials and settings are stored in the settings table and loaded at application startup:
┌──────────────────────────┬──────────────────────────────────┐
│ Key │ Value │
├──────────────────────────┼──────────────────────────────────┤
│ sms.driver │ twilio │
│ sms.twilio.account_sid │ AC... │
│ sms.twilio.auth_token │ •••••••• │
│ sms.twilio.from_number │ +1234567890 │
├──────────────────────────┼──────────────────────────────────┤
│ email.driver │ smtp │
│ email.smtp.host │ smtp.gmail.com │
│ email.smtp.port │ 587 │
│ email.smtp.username │ app@example.com │
│ email.smtp.password │ •••••••• │
├──────────────────────────┼──────────────────────────────────┤
│ storage.driver │ s3 │
│ storage.s3.bucket │ my-app-uploads │
│ storage.s3.region │ us-east-1 │
│ storage.s3.access_key │ AKIA... │
│ storage.s3.secret_key │ •••••••• │
└──────────────────────────┴──────────────────────────────────┘TIP
You can change the active driver at runtime through the admin settings panel. For example, switching sms.driver from twilio to unifonic takes effect immediately without redeployment.
Using Providers in Application Code
The generated application injects providers via Axum's state mechanism:
// In a handler
pub async fn send_verification(
State(app_state): State<AppState>,
Json(payload): Json<VerifyRequest>,
) -> Result<Json<MessageResponse>, ApiError> {
let code = generate_otp_code();
// Uses whatever SMS driver is configured
app_state.sms_provider
.send_otp(&payload.phone, &code)
.await?;
Ok(Json(MessageResponse {
message: "Verification code sent".to_string(),
}))
}The handler does not know or care whether the SMS is sent through Twilio, Unifonic, or simply logged. The factory resolved the correct driver when the application started.
Adding a Custom Provider
To add a new driver for an existing provider category:
- Create a new file in the provider directory (e.g.,
src/providers/sms/vonage.rs) - Implement the provider trait:
pub struct VonageProvider {
api_key: String,
api_secret: String,
from: String,
}
#[async_trait]
impl SmsProvider for VonageProvider {
fn name(&self) -> &'static str { "vonage" }
async fn send(&self, to: &str, message: &str) -> Result<SmsResult, SmsError> {
// Vonage API implementation
}
async fn send_otp(&self, to: &str, code: &str) -> Result<SmsResult, SmsError> {
self.send(to, &format!("Your code is: {}", code)).await
}
async fn verify_otp(&self, _to: &str, _code: &str) -> Result<bool, SmsError> {
// Custom OTP verification logic
Ok(false) // Must verify against stored code
}
fn validate_phone(&self, phone: &str) -> bool {
phone.starts_with('+') && phone.len() >= 10
}
async fn get_status(&self, message_id: &str) -> Result<SmsStatus, SmsError> {
// Vonage status check implementation
}
}- Register the new driver in the factory:
pub fn sms(config: &Config) -> Box<dyn SmsProvider> {
match config.sms_driver.as_str() {
"twilio" => Box::new(TwilioProvider::new(config)),
"unifonic" => Box::new(UnifonicProvider::new(config)),
"vonage" => Box::new(VonageProvider::new(config)), // new
_ => Box::new(LogProvider::new("sms")),
}
}Because the new driver implements the same trait, no other code in the application needs to change.
Testing with Providers
The log driver makes testing straightforward. In your test configuration, set all providers to log:
# .env.test
SMS_DRIVER=log
EMAIL_DRIVER=log
STORAGE_DRIVER=localThis ensures tests run without external API calls while still exercising the full provider pipeline. For integration tests that need to verify actual provider behavior, use the real drivers with test credentials.
TIP
The log driver records every call with its parameters. In tests, you can inspect the log output to verify that the correct provider methods were called with the expected arguments.