Example 06 - External Services Integration

This example demonstrates how to integrate external APIs (like payment gateways, email services, SMS providers) as Lokstra services using proxy.Service for convention-based remote calls.

πŸ“‹ What You’ll Learn

πŸ—οΈ Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Main App (:3000)                      β”‚
β”‚                                                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  OrderService (Business Logic)                       β”‚  β”‚
β”‚  β”‚  - Create()    β†’ POST /orders                        β”‚  β”‚
β”‚  β”‚  - Get()       β†’ GET /orders/{id}                    β”‚  β”‚
β”‚  β”‚  - Refund()    β†’ POST /orders/{id}/refund            β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                    β”‚ depends on                            β”‚
β”‚                    β–Ό                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  PaymentServiceRemote (proxy.Service)                β”‚  β”‚
β”‚  β”‚  - CreatePayment()  β†’ POST /payments                 β”‚  β”‚
β”‚  β”‚  - GetPayment()     β†’ GET /payments/{id}             β”‚  β”‚
β”‚  β”‚  - Refund()         β†’ POST /payments/{id}/refund     β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                    β”‚ HTTP calls                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚   Mock Payment Gateway (:9000)                β”‚
     β”‚   (Simulates Stripe, PayPal, etc.)            β”‚
     β”‚                                               β”‚
     β”‚   POST   /payments                            β”‚
     β”‚   GET    /payments/{id}                       β”‚
     β”‚   POST   /payments/{id}/refund                β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key: All route overrides defined in RegisterServiceType in main.go!

πŸš€ How to Run

Step 1: Start Mock Payment Gateway

cd mock-payment-gateway
go run main.go

This starts the mock payment gateway on http://localhost:9000. It simulates an external payment provider like Stripe or PayPal.

Step 2: Start Main Application

# From the example root directory
go run main.go

This starts the main application on http://localhost:3000.

Step 3: Test with HTTP Requests

Use the test.http file or curl:

# Create order (processes payment via external gateway)
curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": 1,
    "items": ["Laptop", "Mouse", "Keyboard"],
    "total_amount": 1299.99,
    "currency": "USD"
  }'

# Get order
curl http://localhost:3000/orders/order_1

# Refund order (via external gateway)
curl -X POST http://localhost:3000/orders/order_1/refund

πŸ“‚ Project Structure

06-external-services/
β”œβ”€β”€ main.go                           # Main application entry point
β”œβ”€β”€ config.yaml                       # Configuration with external service definitions
β”œβ”€β”€ test.http                         # HTTP test scenarios
β”œβ”€β”€ index                         # This file
β”‚
β”œβ”€β”€ mock-payment-gateway/
β”‚   └── main.go                       # Mock external payment API
β”‚
└── service/
    β”œβ”€β”€ payment_service_remote.go     # Proxy to external payment gateway
    └── order_service.go              # Business logic using external payment

πŸ”‘ Key Concepts

1. External Service Definition

Define external services in config.yaml with URL and factory type:

external-service-definitions:
  payment-gateway:
    url: "http://localhost:9000"
    type: payment-service-remote-factory

What it does:

2. Remote Service Wrapper

Create a clean service wrapper without embedded metadata:

// PaymentServiceRemote wraps external payment API
type PaymentServiceRemote struct {
    proxyService *proxy.Service
}

func NewPaymentServiceRemote(proxyService *proxy.Service) *PaymentServiceRemote {
    return &PaymentServiceRemote{
        proxyService: proxyService,
    }
}

// Method names can be non-standard (routes defined in RegisterServiceType)
func (s *PaymentServiceRemote) CreatePayment(p *CreatePaymentParams) (*Payment, error) {
    return proxy.CallWithData[*Payment](s.proxyService, "CreatePayment", p)
}

func (s *PaymentServiceRemote) GetPayment(p *GetPaymentParams) (*Payment, error) {
    return proxy.CallWithData[*Payment](s.proxyService, "GetPayment", p)
}

func (s *PaymentServiceRemote) Refund(p *RefundParams) (*RefundResponse, error) {
    return proxy.CallWithData[*RefundResponse](s.proxyService, "Refund", p)
}

Key points:

3. Service Registration with Metadata

Register in main.go with all metadata and route overrides:

// Register remote-only service (nil local factory)
lokstra_registry.RegisterServiceType(
    "payment-service-remote-factory",
    nil,                                    // No local implementation
    svc.PaymentServiceRemoteFactory,        // Remote factory
    deploy.WithResource("payment", "payments"),
    deploy.WithConvention("rest"),
    // Route overrides for non-standard method names
    deploy.WithRouteOverride("CreatePayment", "POST /payments"),
    deploy.WithRouteOverride("GetPayment", "GET /payments/{id}"),
    deploy.WithRouteOverride("Refund", "POST /payments/{id}/refund"),
)

// Register local business service with custom action
lokstra_registry.RegisterServiceType(
    "order-service-factory",
    svc.OrderServiceFactory, nil,
    deploy.WithResource("order", "orders"),
    deploy.WithConvention("rest"),
    // Custom action route
    deploy.WithRouteOverride("Refund", "POST /orders/{id}/refund"),
)

Why route overrides?

4. Remote Factory Implementation

Framework injects proxy.Service via config["remote"]:

func PaymentServiceRemoteFactory(deps map[string]any, config map[string]any) any {
    return NewPaymentServiceRemote(
        service.CastProxyService(config["remote"]),
    )
}

What happens:

  1. Framework reads external-service-definitions.payment-gateway.url
  2. Creates proxy.Service with URL = "http://localhost:9000"
  3. Passes it via config["remote"] to factory
  4. Factory wraps it in PaymentServiceRemote

5. Business Service Using External Service

Clean service code with standard REST method names:

type OrderService struct {
    Payment *service.Cached[*PaymentServiceRemote]
}

func OrderServiceFactory(deps map[string]any, config map[string]any) any {
    return &OrderService{
        Payment: service.Cast[*PaymentServiceRemote](deps["payment-gateway"]),
    }
}

// Standard REST method names (Create, Get, not CreateOrder, GetOrder)
func (s *OrderService) Create(p *OrderCreateParams) (*Order, error) {
    // Create order
    order := &Order{
        ID:     fmt.Sprintf("order_%d", orderID),
        Status: "pending",
        ...
    }
    
    // Process payment via external gateway
    payment, err := s.Payment.MustGet().CreatePayment(&CreatePaymentParams{
        Amount:      p.TotalAmount,
        Currency:    p.Currency,
        Description: fmt.Sprintf("Payment for order %s", order.ID),
    })
    
    if err != nil {
        order.Status = "failed"
        return nil, fmt.Errorf("payment failed: %w", err)
    }
    
    order.PaymentID = payment.ID
    order.Status = "paid"
    return order, nil
}

func (s *OrderService) Get(p *OrderGetParams) (*Order, error) {
    // Retrieve order by ID
}

func (s *OrderService) Refund(p *OrderRefundParams) (*Order, error) {
    // Process refund via external gateway
    _, err := s.Payment.MustGet().Refund(&RefundParams{
        ID: order.PaymentID,
    })
    
    if err != nil {
        return nil, fmt.Errorf("refund failed: %w", err)
    }
    
    order.Status = "refunded"
    return order, nil
}

Key points:

🎯 Service Configuration

In config.yaml:

# Define external API
external-service-definitions:
  payment-gateway:
    url: "http://localhost:9000"
    type: payment-service-remote-factory

# Define local business service
service-definitions:
  order-service:
    type: order-service-factory
    depends-on:
      - payment-gateway  # Reference external service

deployments:
  app:
    servers:
      api-server:
        base-url: "http://localhost"
        addr: ":3000"
        published-services:
          - order-service
        # Framework auto-detects payment-gateway dependency

How it works:

  1. Framework reads order-service dependencies
  2. Finds payment-gateway in external-service-definitions
  3. Creates proxy.Service with URL from config
  4. Calls PaymentServiceRemoteFactory with proxy
  5. Injects into OrderService via deps["payment-gateway"]

πŸ”„ Request Flow

  1. Client β†’ POST /orders to main app (:3000)
  2. OrderService β†’ Validate request, create order
  3. OrderService β†’ Call Payment.MustGet().CreatePayment()
  4. PaymentServiceRemote β†’ HTTP call to :9000/payments
  5. Mock Gateway β†’ Process payment, return payment ID
  6. OrderService β†’ Update order with payment ID, status = β€œpaid”
  7. Client ← Return order with payment details

πŸ“Š Comparison: proxy.Service vs proxy.Router

Feature proxy.Service (This Example) proxy.Router (Example 07)
Use Case Structured external services Quick API access
Convention βœ… REST/JSON-RPC auto-routing ❌ Manual paths
Type Safety βœ… Typed methods ❌ Generic calls
Overrides βœ… Custom route overrides N/A
Service Wrapper βœ… Required ❌ Not needed
Best For Payment, Email, SMS APIs Weather, Maps, Ad-hoc APIs

When to use proxy.Service:

When to use proxy.Router:

πŸ§ͺ Mock Payment Gateway

The mock gateway simulates a real payment provider using Lokstra framework:

package main

import (
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/primadi/lokstra"
)

// In-memory storage
var (
    payments   = make(map[string]*Payment)
    paymentsMu sync.RWMutex
    nextID     = 1
)

// Handlers using Lokstra's handler form variations
func createPayment(req *CreatePaymentRequest) (*Payment, error) {
    if req.Currency == "" {
        req.Currency = "USD"
    }
    
    paymentsMu.Lock()
    id := fmt.Sprintf("pay_%d", nextID)
    nextID++
    
    payment := &Payment{
        ID:          id,
        Amount:      req.Amount,
        Currency:    req.Currency,
        Status:      "completed",
        Description: req.Description,
        CreatedAt:   time.Now(),
    }
    payments[id] = payment
    paymentsMu.Unlock()
    
    log.Printf("βœ… Payment created: %s - $%.2f %s", id, req.Amount, req.Currency)
    return payment, nil
}

func getPayment(req *GetPaymentRequest) (*Payment, error) {
    paymentsMu.RLock()
    payment, exists := payments[req.ID]
    paymentsMu.RUnlock()
    
    if !exists {
        return nil, fmt.Errorf("payment not found: %s", req.ID)
    }
    
    return payment, nil
}

func refundPayment(req *RefundRequest) (*RefundResponse, error) {
    paymentsMu.Lock()
    defer paymentsMu.Unlock()
    
    payment, exists := payments[req.ID]
    if !exists {
        return nil, fmt.Errorf("payment not found: %s", req.ID)
    }
    
    if payment.Status != "completed" {
        return nil, fmt.Errorf("only completed payments can be refunded")
    }
    
    now := time.Now()
    payment.Status = "refunded"
    payment.RefundedAt = &now
    
    log.Printf("πŸ’Έ Payment refunded: %s", req.ID)
    
    return &RefundResponse{
        PaymentID:  req.ID,
        RefundedAt: now,
        Status:     "refunded",
        Message:    fmt.Sprintf("Payment %s has been refunded", req.ID),
    }, nil
}

func main() {
    // Create router with Lokstra
    r := lokstra.NewRouter("payment-api")
    
    // Register routes
    r.POST("/payments", createPayment)
    r.GET("/payments/{id}", getPayment)
    r.POST("/payments/{id}/refund", refundPayment)
    
    // Start server
    app := lokstra.NewApp("payment-gateway", ":9000", r)
    if err := app.Run(30 * time.Second); err != nil {
        log.Fatalf("Failed to run app: %v", err)
    }
}

Key points:

Endpoints:

πŸŽ“ Learning Points

1. External Service Integration Pattern

External API β†’ proxy.Service β†’ Service Wrapper β†’ Business Service

This pattern:

2. Route Overrides for Non-Standard APIs

Use deploy.WithRouteOverride() when:

Standard REST methods (no override needed):

Non-standard (override required):

3. Clean Separation of Concerns

This makes services:

4. Error Handling

When external service fails:

payment, err := s.Payment.MustGet().CreatePayment(...)
if err != nil {
    order.Status = "failed"
    return nil, fmt.Errorf("payment failed: %w", err)
}

Always handle external failures gracefully and update your domain state accordingly!

πŸ”„ Next Steps

  1. βœ… Example 06 - External Services (You are here)
  2. πŸ“– Example 07 - Remote Router (proxy.Router for quick API access)

🎯 Real-World Examples

This pattern works for any external API:

Payment Gateways:

Communication:

Storage:

All follow the same pattern: define external service β†’ create wrapper β†’ use in business services!


πŸ’‘ Key Takeaway: Use proxy.Service to wrap external APIs as typed Lokstra services with convention-based routing and custom overrides. For simpler one-off calls, use proxy.Router (Example 07).