Services
Type-safe dependency injection with services
Services are the foundation of JustScale's dependency injection system. They encapsulate business logic, data access, and shared functionality that can be injected into controllers and other services.
Creating a Service
Use createService to define a service with its dependencies and factory function. The factory receives resolved dependencies and returns your service instance:
import { createService } from '@justscale/core';
// Simple service with no dependencies
export const LoggerService = createService({
inject: {},
factory: () => ({
log: (message: string) => console.log(message),
error: (message: string) => console.error(message),
}),
});Injecting Dependencies
Services can depend on other services. List dependencies in the inject object, and they'll be automatically resolved and passed to your factory function:
import { createService } from '@justscale/core';
import { DatabaseService } from './database-service';
import { LoggerService } from './logger-service';
export const UserService = createService({
inject: {
db: DatabaseService,
logger: LoggerService,
},
factory: ({ db, logger }) => ({
findAll: async () => {
logger.log('Fetching all users');
return db.query('SELECT * FROM users');
},
findById: async (id: string) => {
logger.log(`Fetching user ${id}`);
return db.query(`SELECT * FROM users WHERE id = ${id}`);
},
}),
});Factory Pattern
The factory function is called once when the service is first needed. The returned object becomes the service instance that gets injected everywhere:
import { createService } from '@justscale/core';
export const ConfigService = createService({
inject: {},
factory: () => {
// This runs once at startup
const config = {
apiUrl: process.env.API_URL || 'http://localhost:3000',
debug: process.env.NODE_ENV === 'development',
};
return {
get: (key: keyof typeof config) => config[key],
isDebug: () => config.debug,
};
},
});Using the Resolver
The factory function receives a second parameter: a resolve function. Use this to dynamically resolve additional dependencies that aren't known at compile time:
import { createService } from '@justscale/core';
import { DatabaseService } from './database-service';
import { LoggerService } from './logger-service';
export const RepositoryFactory = createService({
inject: { db: DatabaseService },
factory: ({ db }, resolve) => ({
create: <T>(tableName: string) => {
// Dynamically resolve other services if needed
const logger = resolve(LoggerService);
return {
findAll: async () => {
logger.log(`Querying ${tableName}`);
return db.query(`SELECT * FROM ${tableName}`);
},
};
},
}),
});Service Scopes
By default, services are singletons - created once and shared across the entire application. The same instance is injected everywhere it's needed:
import { createService } from '@justscale/core';
// This service is created once
export const CacheService = createService({
inject: {},
factory: () => {
const cache = new Map<string, unknown>();
return {
get: (key: string) => cache.get(key),
set: (key: string, value: unknown) => cache.set(key, value),
};
},
});
// All controllers share the same CacheService instanceLogger Exception
The built-in Logger is the only non-singleton service. Each service that injects it receives its own logger instance with contextual information:
import { createService, Logger } from '@justscale/core';
export const UserService = createService({
inject: { logger: Logger },
factory: ({ logger }) => {
// logger is automatically scoped to "UserService"
logger.info('UserService initialized');
return {
findAll: async () => {
logger.info('Finding all users');
return [];
},
};
},
});Type Safety
JustScale validates all dependencies at compile time. If you forget to provide a service or create a circular dependency, TypeScript will show an error:
import { createClusterBuilder } from '@justscale/core';
import { UserService } from './user-service';
import { DatabaseService } from './database-service';
import { LoggerService } from './logger-service';
// TypeScript error: Missing DatabaseService!
const app = createClusterBuilder()
.add(UserService) // Error: UserService requires DatabaseService
.build();
// Correct version - add dependencies first
const app = createClusterBuilder()
.add(DatabaseService)
.add(LoggerService)
.add(UserService) // Now UserService's deps are satisfied
.build();Built-in Logger
JustScale provides a built-in Logger service that supports structured logging, context propagation, and custom implementations. Each injection site gets its own logger with automatic context about where it's being used.
import { createService, Logger } from '@justscale/core';
export const OrderService = createService({
inject: { logger: Logger },
factory: ({ logger }) => ({
createOrder: async (userId: string, items: Item[]) => {
// Structured logging with attributes
logger.info('Creating order', { userId, itemCount: items.length });
try {
const order = await processOrder(items);
logger.info('Order created', { orderId: order.id });
return order;
} catch (error) {
logger.error('Order creation failed', { userId, error });
throw error;
}
},
}),
});Log Levels
The logger supports four log levels: debug, info, warn, and error:
logger.debug('Detailed debug info', { data }); // Development details
logger.info('Operation completed', { result }); // Normal operations
logger.warn('Deprecated method used', { method }); // Potential issues
logger.error('Request failed', { error, stack }); // ErrorsChild Loggers
Create child loggers for sub-components with inherited context:
import { createService, Logger } from '@justscale/core';
export const PaymentService = createService({
inject: { logger: Logger },
factory: ({ logger }) => {
// Create child loggers for sub-components
const stripeLogger = logger.child('stripe');
const validationLogger = logger.child('validation');
return {
charge: async (amount: number) => {
validationLogger.info('Validating payment');
stripeLogger.info('Charging card', { amount });
},
};
},
});Context Propagation
Add context that propagates through the entire async tree using logger.withContext:
import { Logger } from '@justscale/core';
async function handleRequest(logger: Logger, request: Request) {
// All logs in this async tree include requestId and userId
await logger.withContext(
{ requestId: request.id, userId: request.userId },
async () => {
logger.info('Processing request'); // Has requestId, userId
await processPayment(); // Nested calls too
logger.info('Request completed'); // Has requestId, userId
}
);
}Custom Logger Factory
Replace the default console logger with your preferred logging library (Pino, Winston, etc.):
import { Container, type LoggerFactory, type Logger } from '@justscale/core';
import pino from 'pino';
class PinoLogger implements Logger {
private pino: pino.Logger;
constructor(name: string) {
this.pino = pino({ name });
}
debug(msg: string, attrs?: Record<string, unknown>) { this.pino.debug(attrs, msg); }
info(msg: string, attrs?: Record<string, unknown>) { this.pino.info(attrs, msg); }
warn(msg: string, attrs?: Record<string, unknown>) { this.pino.warn(attrs, msg); }
error(msg: string, attrs?: Record<string, unknown>) { this.pino.error(attrs, msg); }
child(name: string) { return new PinoLogger(name); }
}
// Set custom logger factory on container
container.setLoggerFactory({
create: (name) => new PinoLogger(name),
});Best Practices
- Keep services focused - Each service should have a single, clear responsibility
- Use interfaces - Export interface types for better testability and abstraction
- Avoid side effects in factory - Keep initialization logic minimal; defer work to methods
- Return plain objects - Services don't need to be classes; plain objects with methods work great
- Leverage TypeScript - Let type inference work for you; explicit types are rarely needed
- Use structured logging - Pass attributes to logger methods instead of string interpolation