Appearance
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
| Layer | Responsibility | Example |
|---|---|---|
| Controller | Request validation, response formatting. No business logic. | KioskController |
| Orchestrator | Coordinates multiple services, manages transactions, handles cross-cutting concerns | KioskOrchestrator |
| Service | Single-domain business logic, data transformation | PaymentService, LoyaltyService |
| Repository | MongoDB data access, query building | TransactionRepository |
| Job | Async background processing | ProcessOrderJob, CapturePaymentJob |
Key Files
Orchestrators
| File | Purpose |
|---|---|
app/Services/Orchestrators/KioskOrchestrator.php | Kiosk payment, loyalty, receipt flows |
app/Services/Orchestrators/OnlineOrderingOrchestrator.php | Online order creation, payment, verification |
app/Services/Orchestrators/StripeWebhookOrchestrator.php | Stripe webhook event routing |
app/Services/Orchestrators/VivaWebhookOrchestrator.php | Viva Wallet webhook event routing |
app/Services/Orchestrators/DeliverooWebhookOrchestrator.php | Deliveroo webhook handling |
app/Services/Orchestrators/UberEatsWebhookOrchestrator.php | Uber Eats webhook handling |
app/Services/Orchestrators/CustomerOrchestrator.php | Customer-facing order flows |
app/Services/Orchestrators/KitchenDisplayOrchestrator.php | KDS order management |
app/Services/Orchestrators/PhotoStudioOrchestrator.php | Photo processing workflows |
app/Services/Orchestrators/GenerateDescriptionOrchestrator.php | AI description generation |
app/Services/Orchestrators/AppUpdateOrchestrator.php | Kiosk app update management |
app/Services/Orchestrators/TaxRateOrchestrator.php | Tax rate management |
app/Services/Orchestrators/DynamicConstantOrchestrator.php | Dynamic 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?
- Performance: Services are only instantiated when first accessed, not at construction time
- Memory efficiency: Orchestrators that coordinate many services only load what each request actually needs
- Testability: Services can be mocked by binding alternatives in the container before the getter is called
- Consistency: Every class in the codebase follows this exact pattern
Rules
- Declare a
privateproperty for the service type - Create a
privategetter method prefixed withget - Use
??=withapp()for resolution - Always call
$this->getXxxService()instead of accessing the property directly
Trait-Based Composition
Orchestrators and services compose shared behavior through traits:
| Trait | Purpose | Used By |
|---|---|---|
DatabaseCachingTrait | MongoDB-based cache with locks and TTL | Orchestrators, services |
DBTransactionTrait | MongoDB transaction management with retry | Orchestrators, services |
FirebaseTrait | Firebase Realtime Database publishing | Orchestrators, services |
TenancyTrait | Multi-tenant database switching | Webhook orchestrators |
CurrentUserTrait | Get authenticated user from request | Kiosk/device orchestrators |
IntegrationTrait | Check integration status (Square, etc.) | Payment services |
LocationTrait | Location resolution helpers | Services |
HelperTrait | Common formatting/utility helpers | Services |
PriceTrait | Price calculation utilities | Transaction services |
ItemTrait | Item lookup and transformation | Transaction services |
ItemSnapshotTrait | Item snapshot creation for orders | Transaction 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 conflictsDatabase 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 ProcessOrderJobService 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 servicesGuidelines 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