Skip to content

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:

CategoryTraitAvailable Drivers
SMSSmsProvidertwilio, unifonic, log
EmailEmailProvidersmtp, sendgrid, log
StorageStorageProviderlocal, s3
PaymentsPaymentProviderhyperpay, 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:

rust
#[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

MethodTwilioUnifonicLog
send()Twilio Messages APIUnifonic Send APIWrites to log
send_otp()Twilio Verify APICustom implementationWrites to log
verify_otp()Twilio Verify CheckCustom implementationAlways returns true
validate_phone()E.164 validationE.164 validationAlways returns true
get_status()Twilio Message StatusUnifonic Status APIReturns Delivered

The Email Provider Contract

rust
#[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

rust
#[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

rust
#[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:

rust
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:

yaml
# forge.yaml
providers:
  sms: twilio
  email: smtp
  storage: local
  payment: hyperpay    # optional

This 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:

rust
// 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:

  1. Create a new file in the provider directory (e.g., src/providers/sms/vonage.rs)
  2. Implement the provider trait:
rust
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
    }
}
  1. Register the new driver in the factory:
rust
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:

yaml
# .env.test
SMS_DRIVER=log
EMAIL_DRIVER=log
STORAGE_DRIVER=local

This 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.

Released under the MIT License.