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/MplusKassaWebhookOrchestrator.phpMplus POS webhook routing
app/Services/Orchestrators/CustomerOrchestrator.phpCustomer-facing order flows
app/Services/Orchestrators/InvoiceCollectionOrchestrator.phpB2B invoice collection
app/Services/Orchestrators/GenerateDescriptionOrchestrator.phpAI description generation
app/Services/Orchestrators/AppUpdateOrchestrator.phpKiosk app update management
app/Services/Orchestrators/DynamicConstantOrchestrator.phpDynamic constant resolution
app/Services/Orchestrators/PosOrderOrchestrator.phpFirst-party POS order flows
app/Services/Orchestrators/PosRefundOrchestrator.phpFirst-party POS cash refunds
app/Services/Orchestrators/PosBusinessDayCloseOrchestrator.phpPOS business-day close
app/Services/Orchestrators/PrinterPairingOrchestrator.phpPrinter pairing / re-home / retire

Deliveroo and Uber Eats webhooks are queued directly (ProcessDeliverooWebhookJob, UberEats/ProcessUberEatsWebhookJob) with no orchestrator layer. There is no KDS, photo-studio or tax-rate orchestrator. Beyond the flat Orchestrators/ directory there are also nested ones — Orchestrators/BackOffice/** (14, incl. Settings/), Orchestrators/FieldOps/, Services/MplusKassa/OrderSyncOrchestrator.php and Services/Subscription/SubscriptionOrchestrator.php.

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


Dependency Injection (services) + lazy resolution (repositories)

Production has completed a dependency-injection refactor (refactor(di): inject domain services into service classes). Services are injected as promoted constructor properties and called directly. The ??= app() lazy getter survives only for repositories.

php
class KioskOrchestrator
{
    public function __construct(
        private LoyaltyService $loyaltyService,
        private PaymentService $paymentService,
        private OfferService $offerService,
        private TransactionService $transactionService,
        private AuthCustomerService $authCustomerService,
        private CustomerService $customerService,
        private GiftCardService $giftCardService,
        private InventoryService $inventoryService,
        private KioskService $kioskService,
        private ReceiptService $receiptService,
    ) {}

    // Repositories keep the lazy getter.
    private ItemRepository $itemRepository;

    private function getItemRepository(): ItemRepository
    {
        return $this->itemRepository ??= app(ItemRepository::class);
    }
}

Call sites read $this->receiptService->send(...) — no getter. (KioskOrchestrator.php:38-48, :50-55, :76. Same shape in ItemOrchestrator, PaymentCaptureService.)

Rules

  • Services: add a promoted constructor parameter and call $this->xxxService->… directly.
  • Repositories: declare a private property, add a private get…() getter using ??= with app(), and always call the getter rather than touching the property.
  • Do not add new ??= app(SomeService::class) service getters — that is the pre-refactor style.

Trait-Based Composition

Orchestrators and services compose shared behavior through traits:

TraitPurposeUsed By
CachingTraitMongoDB-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 CachingTrait 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->transactionService->createTransaction($request);

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

        // 3. Handle loyalty via LoyaltyService (if applicable)
        if ($request['customer_token']) {
            $this->loyaltyService->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 CachingTrait
  +-> 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()
              |
              |   (Firebase publish happens INSIDE the lock closure above)
              |
              +-> past the closure, once the transaction has COMMITTED:
                    +-> Re-read the transaction by _id (never by idempotency_key,
                    |   which rotates on a kiosk retry)
                    +-> event(PaymentCaptured)  # StoreKitchenDisplayItems writes the
                    |   transaction_item docs synchronously
                    +-> processOrderJob()       # MUST follow PaymentCaptured: it reads
                    |   those items to build the POS payload
                    +-> ReleaseD1StockReservationJob
                    +-> UpdateMerchantDailyStatsJob

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
  BaseMerchantService.php            # Merchant-scoped base service
  DescriptionGeneratorService.php    # AI description generation
  RestaurantSuggestionService.php    # Restaurant suggestion logic
  RestaurantUpvoteAnalyticsService.php # Upvote analytics
  TableQrOrderingService.php         # Table QR ordering logic
  ThirdPartyAuthService.php          # OAuth callback handling
  DeviceHeartbeatService.php         # Device liveness tracking
  IntegrationMonitoringService.php   # Integration health monitoring
  NotificationService.php            # Notification creation + fan-out
  SetupStatusService.php             # Guided-setup status computation
  DeviceLivenessThrottle.php         # Throttles device liveness writes
  Print/                             # Printing services
  SplitPaymentService.php            # Split-payment handling
  TransactionLifecycleLogService.php # Lifecycle audit-trail writes
  GoogleIdTokenVerifier.php          # Google Sign-In token verification
  SentryBeforeSendCallback.php       # Sentry scrubbing hook
  |
  BackOffice/                        # BackOffice-specific services
  Commission/                        # Reseller / rep commissions
  Common/                            # Shared utilities (VivaWallet, Receipt, etc.)
  Customer/                          # Customer domain services
  Dashboard/                         # Dashboard aggregation
  DataMappers/                       # Cross-system data mapping
  FieldOps/                          # Field rep app
  GiftCard/                          # Gift-card domain
  Inventory/                         # Inventory management
  Kiosk/                             # Kiosk-specific services
  KitchenDisplay/                    # KDS services
  Loyalty/                           # Loyalty program logic
  Monitoring/                        # Platform monitoring
  MplusKassa/                        # Mplus POS domain
  Odoo/                              # Odoo CRM sync
  Offer/                             # Offer/promotion logic
  OnlineOrdering/                    # Online ordering domain
  Orchestrators/                     # All orchestrators
  OrderCapacity/                     # Order capacity management
  Payment/                           # Payment domain services
  PhotoStudio/                       # Photo processing
  Pos/                               # First-party POS
  QueryBuilders/                     # Reusable query builders
  Reseller/                          # Reseller domain
  Stripe/                            # Stripe platform services
  Subscription/                      # Subscription lifecycle
  ThirdParty/                        # Third-party integration services
  Translations/                      # i18n / translation services
  UberEats/                          # Uber Eats domain

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