Service API Architecture: Implementation Guide
Comprehensive guide for implementing the Instruction-Based Service API Architecture as standalone, independent servers.
Document
v1.1
Updated
Dec 8, 2025
Reference
nuvion-core
Status
Production Ready
Revision Notes: v1.1 incorporates architectural clarifications, idempotency implementation, transaction consistency strategy, webhook/callback server documentation, dynamic provider loading, and updated request payload structure with unique_reference field.
Executive Summary
This document provides a comprehensive guide for implementing the Instruction-Based Service API Architecture as standalone, independent servers. Each Service API (Account Issuing, Payouts, Card Issuing, etc.) is a separate Node.js application that follows the nuvion-core structural patterns.
The architecture decouples the Core Service from third-party provider implementations (Flutterwave, Stripe, Fuse, etc.) through a unified, versioned instruction protocol.
Provider Independence
Switch providers without changing Core Service code
Service Isolation
Each Service API is an independent server with its own lifecycle
Provider-Specific Validation
Each provider has custom VSL specs for their requirements
Version Management
Clients can specify instruction versions (v1, v2) for backward compatibility
Scalability
Easy addition of new providers and services
Type Safety
VSL-based validation ensures data integrity
Critical Architectural Points
- 1One Service API = One Server: Each Service API (Account Issuing, Payouts, Cards) runs as a separate Node.js application on its own port
- 2Provider-Specific Specs: Each provider has different VSL specs for the same instruction (FLW's create.account ≠ STR's create.account)
- 3Versioned Instructions: Instructions include version (v1, v2) to support multiple spec versions over time
Structural Template
Each Service API is a standalone Node.js application modeled after the nuvion-core structure:
@app-core/repository-factoryservices/ directoryCustom VSL validator@app-core/errors@app-core/loggercreateHandler() patternArchitecture Overview
The instruction protocol provides a uniform interface for all Service APIs. Instead of exposing multiple endpoints, each Service API exposes a single /instructions endpoint.
{
"instruction": "create.account",
"version": "v1",
"unique_reference": "client-generated-uuid-12345",
"provider": {
"id": "FLW",
"meta": {
"region": "NG",
"currency": "NGN"
}
},
"payload": {
"entity_id": "01HQWXYZ1234567890ENTITY",
"customer_name": "Merchant One",
"kyc_reference": "REF-12345",
"currency": "NGN"
}
}instructionThe action to perform (e.g., create.account, list.accounts)
versionAPI version for the instruction spec (e.g., v1, v2)
unique_referenceNEWClient-generated unique identifier for idempotency
provider.id3-character provider code (e.g., FLW, STR, THR)
provider.metaOptional routing hints (region, currency, etc.)
payloadProvider-specific data validated against spec
Service API as Standalone Server
Each Service API is a separate Node.js application, not a module within nuvion-core.
Deployment Architecture
Core Service (nuvion-core)
Entity Management, Authentication, Orchestration
Account Issuing Service API
/instructions endpoint
FLW, STR, THR providers
Payouts Service API
/instructions endpoint
FLW, STR, PSP providers
Card Issuing Service API
/instructions endpoint
FLW, STR, UNI providers
Blockchain Service API
/instructions endpoint
BTC, ETH, SOL providers
Repository Structure
Each Service API has its own Git repository and follows the nuvion-core structure:
nuvion-coreCore Servicenuvion-account-issuing-apiService APInuvion-payouts-apiService APInuvion-card-issuing-apiService APInuvion-blockchain-apiService APIDirectory Structure
Each Service API is a standalone application with this structure (modeled after nuvion-core):
Key Differences from nuvion-core
- 1.Single Endpoint: Only /instructions endpoint
- 2.Provider-Specific Specs: Each provider has own specs folder
- 3.Service-Focused: Handles one service domain only
- 4.No Identity Management: Auth delegated to Core Service
Highlighted Folders
The endpoints/ and services/ folders contain the core implementation logic. Click on folders in the tree to expand and explore the structure.
Implementation Layers
Instruction Handler (Endpoint Layer)
Implemented as a standard endpoint using createHandler().
Key Responsibilities:
- • Envelope Validation: Validates instruction envelope (instruction, version, provider, payload)
- • Client Authentication: Verifies service-to-service authentication
- • Context Augmentation: Adds request context from Core Service
- • Delegation: Passes validated data to orchestrator
- • Response Formatting: Returns standardized response
endpoints/instructions.js
module.exports = createHandler({
method: 'post',
path: '/instructions',
middlewares: [verifyClient],
props: {
allowedAuthMethods: { jwt: true, sk: true },
requireServiceKey: true,
},
async handler(rc, helpers) {
// Validate instruction envelope structure
const data = validator.validate(rc.body, parsedEnvelopeSpec);
// Add request context from Core Service
data.context = {
client_id: rc.meta.client?.id,
request_id: rc.headers['x-request-id'],
timestamp: Date.now(),
};
// Pass to orchestrator
const response = await orchestrator(data);
return {
status: helpers.http_statuses.HTTP_200_OK,
message: 'Instruction executed successfully',
data: response,
};
},
});Note: The payload is NOT validated here—it's validated in the orchestrator against provider-specific specs.
Provider-Specific Specs & Versioning
Each provider has different requirements for the same instruction. This is why specs are provider-specific, not universal.
Why Provider-Specific Specs?
Spec Organization
Each provider has its own specs/ folder with versioned subdirectories:
Pre-Parsed VSL Spec Examples
IMPORTANT: Specs are pre-parsed at module load time, not on each request. This improves performance since VSL specs are immutable once deployed.
services/providers/FLW/specs/v1/create-account.js
const validator = require('@app-core/validator');
// Flutterwave-specific create.account spec (v1)
const vslSpec = `root { // FLW create account v1
entity_id string<length:26>
customer_name string<trim|minLength:1|maxLength:255>
bvn string<length:11>
email string<trim|lowercase|isEmail>
mobilenumber string<minLength:10|maxLength:15>
phonenumber string<minLength:10|maxLength:15>
currency string(NGN|KES|GHS|ZAR|USD)
type string(virtual|physical)
nin? string<length:11>
meta? object
}`;
// Parse spec once at module load
const parsedSpec = validator.parse(vslSpec);
// Export the pre-parsed spec
module.exports = parsedSpec;Key Points: Specs are parsed once when the module loads, not on every request. Each spec file exports the pre-parsed spec object. This is more efficient since VSL specs don't change at runtime.
Provider Dictionary Tree Structure
The providers/index.js file is the single source of truth - it exports all providers in a dictionary tree structure using dynamic loading.
services/providers/index.js
const fs = require('fs');
const path = require('path');
const { appLogger } = require('@app-core/logger');
// Dynamically load all providers from directory
const providers = {};
const providersPath = __dirname;
// Read all directories in providers folder
const providerDirs = fs
.readdirSync(providersPath, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
.filter((name) => name.length === 3 && name === name.toUpperCase());
// Load each provider
for (const providerId of providerDirs) {
try {
const providerPath = path.join(providersPath, providerId);
providers[providerId] = require(providerPath);
appLogger.info(
{
provider: providerId,
versions: Object.keys(providers[providerId].specs),
instructions: Object.keys(providers[providerId].specs.v1 || {}),
},
'provider-loaded'
);
} catch (error) {
appLogger.errorX({ error, provider: providerId }, 'provider-load-error');
}
}
appLogger.info({ providers: Object.keys(providers) }, 'all-providers-loaded');
module.exports = providers;| Approach | Pros | Cons |
|---|---|---|
| Dynamic Loading |
|
|
| Manual Imports |
|
|
Recommendation: Use dynamic loading for maintainability. The loss of IDE autocomplete is acceptable since provider IDs come from runtime data (API requests), not hardcoded values.
Resulting Dictionary Tree Structure
Each provider exports a dictionary tree with specs organized by version and instruction for O(1) lookups:
{
FLW: {
specs: {
v1: {
'create.account': <parsed spec object>,
'list.accounts': <parsed spec object>,
'get.account': <parsed spec object>,
'update.account': <parsed spec object>
},
v2: {
'create.account': <v2 parsed spec object>,
'update.account': <v2 parsed spec object>
}
},
metadata: {
regions: ['NG', 'KE', 'GH', 'ZA'],
currencies: ['NGN', 'KES', 'GHS', 'ZAR', 'USD']
},
execute: <function>
},
STR: {
specs: {
v1: {
'create.account': <parsed spec object>,
'list.accounts': <parsed spec object>,
'get.account': <parsed spec object>
}
},
metadata: {
regions: ['US', 'EU', 'UK'],
currencies: ['USD', 'EUR', 'GBP']
},
execute: <function>
},
THR: {
specs: {
v1: {
'create.account': <parsed spec object>,
'list.accounts': <parsed spec object>
}
},
metadata: {
regions: ['NG', 'GH'],
currencies: ['NGN', 'GHS']
},
execute: <function>
}
}Cross-Provider Field Mapping
The Core Service sends a unified payload with kyc_reference, but each provider expects a different field:
Unified Field
kyc_referenceFLW
bvnSTR
ssnTHR
ninData Models & Repository Pattern
Models follow a base + provider-specific extension pattern. The base model defines the unified interface, while provider models add specific fields.
Base Model
Unified interface for all providers
_id, provider_id, provider_account_id,
entity_id, account_number, account_name,
bank_name, bank_code, currency, status,
type, metaFLW-Specific Fields
Extended from base schema
bvn_verified, flw_merchant_id,
settlement_frequency, nuban,
flw_referencerepository/FLW-account/index.js
const repositoryFactory = require('@app-core/repository-factory');
// Create repository for FLW accounts
// The model name must match the export from models/index.js
module.exports = repositoryFactory('FLW_accounts', {});Service Layer Implementation
Services follow the existing pattern with additional organization for providers and versioning.
Service Conventions
Single Responsibility
Each service handles one instruction
Input Validation
Use VSL validator for all inputs
Error Handling
Use throwAppError with standardized messages
Logging
Use appLogger with consistent log keys
Single Exit Point
One return statement per function
No Console.log
Always use appLogger
Endpoint Layer Implementation
Each Service API has exactly ONE endpoint: /instructions
app.js (Service API configuration)
// IMPORTANT: Each Service API has exactly ONE endpoint
const ENDPOINT_CONFIGS = [
{ path: './endpoints/instructions/' }, // Single endpoint: /instructions
];Why only one endpoint?
- • Each Service API is a standalone server (Account Issuing, Payouts, Cards)
- • Each exposes only /instructions endpoint
- • Different Service APIs are separate repositories and deployments
Example:
- • nuvion-account-issuing-api → Port 3001 → /instructions
- • nuvion-payouts-api → Port 3002 → /instructions
- • nuvion-card-issuing-api → Port 3003 → /instructions
Security & Authentication
Client Authentication
- JWT Tokens: For entity-level access (verifySession middleware)
- Secret Keys: For service-to-service communication
- Signature Validation: For webhook callbacks
Complete Implementation Example
End-to-end example for the Account Issuing Service API.
Request (Flutterwave v1)
POST /instructions
Content-Type: application/json
X-Service-Key: <service_key>
{
"instruction": "create.account",
"version": "v1",
"unique_reference": "req-flw-12345",
"provider": {
"id": "FLW",
"meta": { "region": "NG" }
},
"payload": {
"entity_id": "01HQWXYZ...",
"customer_name": "Test Merchant",
"bvn": "12345678901",
"email": "test@example.com",
"currency": "NGN",
"type": "virtual"
}
}Response
{
"status": 200,
"message": "Instruction executed",
"data": {
"account": {
"_id": "01HQWXYZ...",
"provider_id": "FLW",
"account_number": "1234567890",
"account_name": "Test Merchant",
"bank_name": "Wema Bank",
"currency": "NGN",
"status": "active",
"bvn_verified": true
}
}
}Implementation Steps
Inter-Service Communication
Core Service to Service API
The Core Service communicates with Service APIs via HTTP using service keys.
Core Service
nuvion-coreHTTP + X-Service-Key
Service API
/instructionsWebhook Architecture
NEW in v1.1Providers send webhooks for asynchronous events via a dedicated Callback Server with static IPs.
Static Elastic IPs whitelisted by providers → Routes to appropriate Service API
Best Practices & Conventions
Naming Conventions
| Component | Convention | Example |
|---|---|---|
| Service API Repo | nuvion-[domain]-api | nuvion-account-issuing-api |
| Instruction | verb.noun | create.account |
| Version | v[number] | v1, v2 |
| Provider ID | 3 uppercase letters | FLW, STR, THR |
| Model Name | PROVIDER_resource | FLW_accounts |
| Spec File | [instruction].vsl.js | create-account.vsl.js |
| Log Key | component-action | orchestrator-dispatch |
Code Organization
- • One Service = One Repository
- • Provider Isolation in providers/[ID]/ folders
- • Version Isolation in v1/, v2/ folders
- • Single Responsibility per file
Security Practices
- • Service-to-Service Auth via X-Service-Key
- • PII Encryption before storage
- • Provider Credentials in env variables
- • Request Validation at endpoint layer
Summary
The Service API Architecture provides a scalable, maintainable approach to integrating multiple third-party providers. Each Service API is a standalone Node.js application following the three-layer architecture.
Key Achievements
Implementation Roadmap
Create First Service API
Add Providers & Versions
Integrate with Core
Additional Services
Production Readiness
Success Criteria
- Core Service can create accounts via multiple providers
- Adding a new provider requires no Core Service changes
- Switching providers is configuration-based
- Multiple spec versions coexist without conflicts
- Service APIs can be deployed independently