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:

logger-service.tsTypeScript
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:

user-service.tsTypeScript
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:

config-service.tsTypeScript
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:

repository-factory.tsTypeScript
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:

cache-service.tsTypeScript
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 instance

Logger Exception

The built-in Logger is the only non-singleton service. Each service that injects it receives its own logger instance with contextual information:

user-service.tsTypeScript
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:

app.tsTypeScript
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.

order-service.tsTypeScript
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:

log-levels.tsTypeScript
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 }); // Errors

Child Loggers

Create child loggers for sub-components with inherited context:

payment-service.tsTypeScript
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:

request-handler.tsTypeScript
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.):

custom-logger.tsTypeScript
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