This guide explains how to write and structure services for MicroQL. Services are the core building blocks that contain your business logic and can be composed together to create complex workflows.
- Service Basics
- Service Structure
- Argument Types
- Validation
- Error Handling
- Testing Services
- Best Practices
- Built-in Services
A MicroQL service is a JavaScript object containing async methods. Each method represents an operation that can be called from queries.
const userService = {
async getUser({id}) {
// Fetch user from database
const user = await db.users.findById(id)
return user
},
async createUser({userData}) {
// Validate and create user
const user = await db.users.create(userData)
return user
},
async updateUser({id, updates}) {
// Update user data
const user = await db.users.updateById(id, updates)
return user
}
}- Async Methods: All service methods should be
asyncfunctions - Single Argument: Methods take exactly one argument object (destructured)
- Return Values: Methods return the data that will be available to other queries
- Pure Logic: Services should contain pure business logic, not MicroQL-specific code
All service methods must follow this pattern:
async methodName({arg1, arg2, ...otherArgs}) {
// Your logic here
return result
}Important: The method receives a single object argument. Use destructuring to extract the values you need.
const mathService = {
async add({a, b}) {
return a + b
},
async multiply({values}) {
return values.reduce((acc, val) => acc * val, 1)
},
async statistics({numbers}) {
const sum = numbers.reduce((a, b) => a + b, 0)
const avg = sum / numbers.length
const min = Math.min(...numbers)
const max = Math.max(...numbers)
return {sum, avg, min, max, count: numbers.length}
}
}const result = await query({
given: {numbers: [1, 2, 3, 4, 5]},
services: {math: mathService},
queries: {
sum: ['math:add', {a: 10, b: 20}],
product: ['math:multiply', {values: '$.given.numbers'}],
stats: ['math:statistics', {numbers: '$.given.numbers'}]
}
})You can specify type information for service arguments using the _argtypes property. This helps with validation and documentation.
const dataService = {
async processData({input, options, settings}) {
// Process the data
return processedData
}
}
// Define argument types
dataService.processData._argtypes = {
input: {type: 'object'},
options: {type: 'object', optional: true},
settings: {type: 'settings'} // Special type for MicroQL settings
}For methods that accept other services (like callbacks or transformers), use type: 'service':
const transformService = {
async transform({data, transformer}) {
return data.map(transformer)
}
}
transformService.transform._argtypes = {
data: {type: 'array'},
transformer: {type: 'service'}
}
// Usage
const queries = {
result: ['transform:transform', {
data: '$.given.items',
transformer: ['util:template', {name: '@.name', upper: '@.name.toUpperCase()'}]
}]
}MicroQL provides built-in validation using Zod schemas. Add validation to enforce contracts on your service inputs and outputs. See the Validation Guide for complete details.
const userService = {
async createUser({userData}) {
// Create user logic
return {id: generateId(), ...userData}
}
}
// Add input validation
userService.createUser._validators = {
precheck: {
userData: {
name: ['string'],
email: ['string', 'email'],
age: ['number', 'positive', {min: 13}]
}
},
postcheck: {
id: ['string'],
name: ['string'],
email: ['string', 'email'],
age: ['number']
}
}const orderService = {
async createOrder({order}) {
// Order processing logic
return processedOrder
}
}
orderService.createOrder._validators = {
precheck: {
order: {
customerId: ['string', 'uuid'],
items: ['array', [{
productId: ['string'],
quantity: ['number', 'positive', 'int'],
price: ['number', 'positive']
}], {min: 1}],
shippingAddress: {
street: ['string'],
city: ['string'],
zipCode: ['string', {regex: /^\d{5}(-\d{4})?$/}],
country: ['enum', ['US', 'CA', 'MX']]
},
paymentMethod: ['enum', ['credit', 'debit', 'paypal']]
}
}
}Services should throw errors for exceptional conditions. MicroQL will handle these automatically and provide context.
const userService = {
async getUser({id}) {
if (!id) {
throw new Error('User ID is required')
}
const user = await db.users.findById(id)
if (!user) {
throw new Error(`User not found: ${id}`)
}
return user
}
}const queries = {
user: ['users:getUser', {
id: '$.given.userId',
onError: ['users:getDefaultUser'], // Fallback on error
retry: 3, // Retry 3 times
timeout: 5000 // 5 second timeout
}]
}Services are just regular JavaScript objects, so they're easy to unit test.
import assert from 'node:assert'
import {describe, it} from 'node:test'
describe('Math Service', () => {
it('should add two numbers', async () => {
const result = await mathService.add({a: 5, b: 3})
assert.strictEqual(result, 8)
})
it('should calculate statistics', async () => {
const result = await mathService.statistics({numbers: [1, 2, 3, 4, 5]})
assert.deepStrictEqual(result, {
sum: 15,
avg: 3,
min: 1,
max: 5,
count: 5
})
})
})import query from 'microql'
describe('User Service Integration', () => {
it('should create and retrieve user', async () => {
const result = await query({
given: {userData: {name: 'John', email: 'john@example.com', age: 25}},
services: {users: userService},
queries: {
created: ['users:createUser', {userData: '$.given.userData'}],
retrieved: ['users:getUser', {id: '$.created.id'}]
}
})
assert.strictEqual(result.retrieved.name, 'John')
assert.strictEqual(result.retrieved.email, 'john@example.com')
})
})Each service method should do one thing well:
// Good: Focused responsibilities
const userService = {
async getUser({id}) { /* ... */ },
async createUser({userData}) { /* ... */ },
async updateUser({id, updates}) { /* ... */ },
async deleteUser({id}) { /* ... */ }
}
// Avoid: Mixed responsibilities
const messyService = {
async getUserAndSendEmail({id}) { /* ... */ } // Does too many things
}Use descriptive argument names that make the intent clear:
// Good: Clear intent
async sendEmail({to, subject, body, attachments}) { /* ... */ }
// Avoid: Unclear arguments
async send({a, b, c, d}) { /* ... */ }Be consistent about what your methods return:
// Good: Consistent structure
const apiService = {
async getUser({id}) {
return {id, name, email, createdAt}
},
async getOrder({id}) {
return {id, items, total, createdAt} // Same structure pattern
}
}Add validation to catch errors early and document your contracts:
const apiService = {
async fetchData({url, options}) {
// Implementation
}
}
apiService.fetchData._validators = {
precheck: {
url: ['string', 'url'],
options: {
method: ['enum', ['GET', 'POST', 'PUT', 'DELETE'], 'optional'],
headers: ['object', 'optional'],
timeout: ['number', 'positive', 'optional']
}
}
}Think about and handle edge cases in your services:
const mathService = {
async divide({numerator, denominator}) {
if (denominator === 0) {
throw new Error('Cannot divide by zero')
}
return numerator / denominator
},
async average({numbers}) {
if (!numbers || numbers.length === 0) {
throw new Error('Cannot calculate average of empty array')
}
return numbers.reduce((a, b) => a + b, 0) / numbers.length
}
}Services should not maintain state between calls:
// Good: Stateless
const mathService = {
async calculate({operation, values}) {
// Uses only the arguments provided
switch (operation) {
case 'sum': return values.reduce((a, b) => a + b, 0)
case 'product': return values.reduce((a, b) => a * b, 1)
default: throw new Error(`Unknown operation: ${operation}`)
}
}
}
// Avoid: Stateful services
const statefulService = {
lastResult: null, // Don't do this
async calculate({values}) {
this.lastResult = values.reduce((a, b) => a + b, 0) // Don't do this
return this.lastResult
}
}MicroQL provides several built-in utility services:
The util service provides common data transformation operations:
import {util} from 'microql/services'
const queries = {
// Array operations
filtered: ['util:filter', {on: '$.data', fn: item => item.active}],
mapped: ['util:map', {on: '$.filtered', fn: item => item.name}],
flattened: ['util:flatMap', {on: '$.nested', fn: item => item.children}],
// Object operations
picked: ['util:pick', {on: '$.user', fields: ['name', 'email']}],
// Conditional logic
result: ['util:when', {
condition: '$.user.isAdmin',
then: 'admin_data',
else: 'regular_data'
}],
// Utilities
length: ['util:length', {on: '$.items'}],
exists: ['util:exists', {on: '$.optionalField'}]
}map({on, fn})- Transform array elementsfilter({on, fn})- Filter array elementsflatMap({on, fn})- Map and flatten resultsconcat({arrays})- Concatenate arrayspick({on, fields})- Extract object fieldslength({on})- Get length/sizeexists({on})- Check if value existswhen({condition, then, else})- Conditional logictemplate({...})- Create object templates
See the util service source for complete details.
For services that need configuration:
function createApiService(baseUrl, apiKey) {
return {
async get({endpoint, params}) {
const url = new URL(endpoint, baseUrl)
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
}
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
}
}
// Usage
const services = {
github: createApiService('https://api.github.com', process.env.GITHUB_TOKEN),
stripe: createApiService('https://api.stripe.com', process.env.STRIPE_KEY)
}Services can use other services internally:
const dataService = {
async processUserData({userId}) {
// This service composes multiple operations
const user = await userService.getUser({id: userId})
const preferences = await userService.getPreferences({userId})
const orders = await orderService.getOrderHistory({userId})
return {
profile: user,
settings: preferences,
recentOrders: orders.slice(0, 10)
}
}
}Good MicroQL services are:
- Simple: One responsibility per method
- Async: All methods are async functions
- Stateless: Don't maintain internal state
- Well-typed: Use
_argtypesand validation - Documented: Clear method and argument names
- Testable: Easy to unit test in isolation
- Composable: Can be combined with other services
Following these patterns will make your services reliable, maintainable, and easy to work with in MicroQL queries.