Skip to content

Service-Orchestrator Pattern

The Upvendo backend uses a layered architecture where Orchestrators coordinate multiple Services to fulfill complex business operations. This is the primary architectural pattern for handling non-trivial requests.

Architecture Overview

Controller (thin)
    |
    v
Orchestrator (coordinates workflow)
    |
    +---> Service A (domain logic)
    |         +---> Repository A (data access)
    |
    +---> Service B (domain logic)
    |         +---> Repository B (data access)
    |
    +---> Job (async work)

Layer Responsibilities

LayerResponsibilityExample
ControllerRequest validation, response formatting. No business logic.KioskController
OrchestratorCoordinates multiple services, manages transactions, handles cross-cutting concernsKioskOrchestrator
ServiceSingle-domain business logic, data transformationPaymentService, LoyaltyService
RepositoryMongoDB data access, query buildingTransactionRepository
JobAsync background processingProcessOrderJob, CapturePaymentJob

Key Files

Orchestrators

FilePurpose
app/Services/Orchestrators/KioskOrchestrator.phpKiosk payment, loyalty, receipt flows
app/Services/Orchestrators/OnlineOrderingOrchestrator.phpOnline order creation, payment, verification
app/Services/Orchestrators/StripeWebhookOrchestrator.phpStripe webhook event routing
app/Services/Orchestrators/VivaWebhookOrchestrator.phpViva Wallet webhook event routing
app/Services/Orchestrators/DeliverooWebhookOrchestrator.phpDeliveroo webhook handling
app/Services/Orchestrators/UberEatsWebhookOrchestrator.phpUber Eats webhook handling
app/Services/Orchestrators/CustomerOrchestrator.phpCustomer-facing order flows
app/Services/Orchestrators/KitchenDisplayOrchestrator.phpKDS order management
app/Services/Orchestrators/PhotoStudioOrchestrator.phpPhoto processing workflows
app/Services/Orchestrators/GenerateDescriptionOrchestrator.phpAI description generation
app/Services/Orchestrators/AppUpdateOrchestrator.phpKiosk app update management
app/Services/Orchestrators/TaxRateOrchestrator.phpTax rate management
app/Services/Orchestrators/DynamicConstantOrchestrator.phpDynamic constant resolution

BackOffice orchestrators live in a subdirectory: app/Services/Orchestrators/BackOffice/


Lazy Service Resolution Pattern

A distinctive pattern throughout the codebase is lazy service resolution using null coalescing assignment:

php
class KioskOrchestrator
{
    private PaymentService $paymentService;
    private LoyaltyService $loyaltyService;
    private TransactionService $transactionService;

    private function getPaymentService(): PaymentService
    {
        return $this->paymentService ??= app(PaymentService::class);
    }

    private function getLoyaltyService(): LoyaltyService
    {
        return $this->loyaltyService ??= app(LoyaltyService::class);
    }

    private function getTransactionService(): TransactionService
    {
        return $this->transactionService ??= app(TransactionService::class);
    }
}

Why This Pattern?

  1. Performance: Services are only instantiated when first accessed, not at construction time
  2. Memory efficiency: Orchestrators that coordinate many services only load what each request actually needs
  3. Testability: Services can be mocked by binding alternatives in the container before the getter is called
  4. Consistency: Every class in the codebase follows this exact pattern

Rules

  • Declare a private property for the service type
  • Create a private getter method prefixed with get
  • Use ??= with app() for resolution
  • Always call $this->getXxxService() instead of accessing the property directly

Trait-Based Composition

Orchestrators and services compose shared behavior through traits:

TraitPurposeUsed By
DatabaseCachingTraitMongoDB-based cache with locks and TTLOrchestrators, services
DBTransactionTraitMongoDB transaction management with retryOrchestrators, services
FirebaseTraitFirebase Realtime Database publishingOrchestrators, services
TenancyTraitMulti-tenant database switchingWebhook orchestrators
CurrentUserTraitGet authenticated user from requestKiosk/device orchestrators
IntegrationTraitCheck integration status (Square, etc.)Payment services
LocationTraitLocation resolution helpersServices
HelperTraitCommon formatting/utility helpersServices
PriceTraitPrice calculation utilitiesTransaction services
ItemTraitItem lookup and transformationTransaction services
ItemSnapshotTraitItem snapshot creation for ordersTransaction services

Database Transaction Pattern

The DBTransactionTrait provides executeWithTransactionRetry() for MongoDB write conflict handling:

php
$this->executeWithTransactionRetry(function () use ($data) {
    // All database operations here are atomic
    $this->getTransactionRepository()->save($data);
    $this->getInventoryService()->updateStock($data);
}, 5); // Retry up to 5 times on write conflicts

Database Lock Pattern

The DatabaseCachingTrait provides tryExecuteWithDatabaseLock() for distributed locking:

php
$result = $this->tryExecuteWithDatabaseLock(
    $lockKey,           // Unique lock identifier
    function () {
        // Critical section code
        return $result;
    },
    $ownerId,           // Lock owner identifier
    $ttlSeconds         // Lock timeout
);

This is used extensively in payment processing to prevent duplicate captures.


Controller-Orchestrator Relationship

Controllers are deliberately thin. They validate the request and delegate to the orchestrator:

php
// Controller (thin - validation + delegation)
class KioskController extends Controller
{
    public function __construct(private KioskOrchestrator $orchestrator) {}

    public function storePayment(StorePaymentRequest $request): JsonResponse
    {
        try {
            $intent = $this->orchestrator->storePayment($request->validated());
        } catch (\Throwable $th) {
            $this->handleException($th);
        }
        return response()->json($intent);
    }
}
php
// Orchestrator (coordination logic)
class KioskOrchestrator
{
    public function storePayment(array $request): mixed
    {
        // 1. Create transaction via TransactionService
        $transaction = $this->getTransactionService()->createTransaction($request);

        // 2. Process payment via PaymentService
        $result = $this->getPaymentService()->processPaymentIntent($transaction, $request);

        // 3. Handle loyalty via LoyaltyService (if applicable)
        if ($request['customer_token']) {
            $this->getLoyaltyService()->handleCustomerLoyalty($transaction);
        }

        return $result;
    }
}

Error Handling

Controllers use a standardized handleException() method from the base Controller class:

php
try {
    $intent = $this->service->someMethod($request->validated());
} catch (\Throwable $th) {
    $this->handleException($th);
}

This ensures consistent error responses across the API.


Example: Payment Capture Flow

The payment capture demonstrates the full pattern in action:

VivaWebhookOrchestrator::handle()
  |
  +-> Validates webhook payload
  +-> Detects duplicate via DatabaseCachingTrait
  +-> Resolves tenant database via TenancyTrait
  +-> Routes to handlePaymentCreated()
        |
        +-> Acquires database lock (prevents duplicate processing)
        +-> PaymentCaptureService::capturePayment()
              |
              +-> Acquires cache lock (payment_verification_{key})
              +-> executeWithTransactionRetry() (up to 5 retries)
                    |
                    +-> TransactionService::setTransactionAwaitingCapture()
                    +-> PaymentService::capturePayment()
                    +-> OrderCapacityService::setTransactionSentAtByOrderCapacity()
                    +-> InventoryService::manageLocationStocks()
                    +-> KitchenDisplayService::storeTransactionItems()
                    +-> LoyaltyService::handleCustomerLoyalty()
                    +-> CustomerService::addTimelines()
                    +-> OfferService::redeemOffers()
              |
              +-> defer() (after transaction commits):
                    +-> Publish payment status via Firebase
                    +-> Release stock reservation via Cloudflare D1
                    +-> Dispatch ProcessOrderJob

Service Directory Structure

app/Services/
  AuthService.php                    # BackOffice auth
  AuthCustomerService.php            # Customer auth
  AuthDeviceService.php              # Device auth
  JwtService.php                     # JWT token management
  PasskeyService.php                 # WebAuthn passkey management
  PermissionService.php              # RBAC permission checking
  TransactionService.php             # Shared transaction helpers
  BaseVendorService.php              # Vendor-scoped base service
  DescriptionGeneratorService.php    # AI description generation
  RestaurantSuggestionService.php    # Restaurant suggestion logic
  TableQrOrderingService.php         # Table QR ordering logic
  ThirdPartyAuthService.php          # OAuth callback handling
  |
  BackOffice/                        # BackOffice-specific services
  Common/                            # Shared utilities (VivaWallet, Receipt, etc.)
  Customer/                          # Customer domain services
  Inventory/                         # Inventory management
  Kiosk/                             # Kiosk-specific services
  KitchenDisplay/                    # KDS services
  Loyalty/                           # Loyalty program logic
  Offer/                             # Offer/promotion logic
  OnlineOrdering/                    # Online ordering domain
  Orchestrators/                     # All orchestrators
  OrderCapacity/                     # Order capacity management
  Payment/                           # Payment domain services
  PhotoStudio/                       # Photo processing
  QueryBuilders/                     # Reusable query builders
  ThirdParty/                        # Third-party integration services

Guidelines for New Code

When to Create an Orchestrator

Create an orchestrator when:

  • A controller action needs to coordinate 2+ services
  • The workflow involves database transactions spanning multiple collections
  • The operation requires distributed locking
  • There are deferred/async operations after the main transaction

When to Use a Service Directly

Use a service directly from a controller when:

  • The operation is simple CRUD on a single domain
  • No cross-service coordination is needed
  • Most BackOffice CRUD operations follow this simpler pattern

Naming Conventions

  • Orchestrators: {Domain}Orchestrator (e.g., KioskOrchestrator)
  • Services: {Domain}Service (e.g., PaymentService)
  • Repositories: {Model}Repository (e.g., TransactionRepository)
  • Webhook orchestrators: {Provider}WebhookOrchestrator