Skip to content

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 defineService to create a service class with its dependencies and factory function. The factory receives resolved dependencies and returns your service instance:

notification-service.tsTypeScript
import { defineService } from '@justscale/core';

export class NotificationService extends defineService({
  inject: {},
  factory: () => ({
    sendTicketCreated: async (email: string, subject: string) => {
      console.log(`[Notification] New ticket "${subject}" for ${email}`);
    },

    sendTicketResolved: async (email: string, subject: string) => {
      console.log(`[Notification] Ticket "${subject}" resolved — ${email}`);
    },
  }),
}) {}

Injecting Dependencies

Services can depend on other services and repositories. List dependencies in the inject object, and they'll be automatically resolved and passed to your factory function:

src/application/helpdesk-service.tsTypeScript
import { defineService } from '@justscale/core';
import { ModelRepository, type Locked, type Persistent, type Ref } from '@justscale/core/models';
import { Ticket, Customer, Comment } from '../domain/models';

export class HelpdeskService extends defineService({
  inject: {
    tickets: ModelRepository.of(Ticket),
    comments: ModelRepository.of(Comment),
  },
  factory: ({ tickets, comments }) => ({
    async createTicket(
      subject: string,
      body: string,
      priority: 'low' | 'medium' | 'high' | 'critical',
      customer: Ref<Customer>,
    ) {
      return tickets.insert({ subject, body, priority, customer });
    },

    async addComment(
      ticket: Persistent<Ticket>,
      body: string,
      authorType: 'customer' | 'agent',
      authorName: string,
    ) {
      return comments.insert({ ticket, body, authorType, authorName });
    },

    // Mutations require Locked<T> — the caller acquires and passes proof.
    async resolveTicket(ticket: Locked<Ticket>) {
      return tickets.update(ticket, { status: 'resolved' });
    },

    listForCustomer(customer: Ref<Customer>) {
      return tickets.find({ where: Ticket.fields.customer.eq(customer) });
    },

    listAll() {
      return tickets.find({});
    },
  }),
}) {}

The HelpdeskService injects two repositories — tickets and comments. JustScale resolves them automatically when the service is first needed.

Factory Pattern

The factory function is called once when the service is first needed. The returned object becomes the singleton instance that gets injected everywhere:

notification-service.tsTypeScript
import { defineService } from '@justscale/core';

export class NotificationService extends defineService({
  inject: {},
  factory: () => {
    // This runs once at startup
    const emailConfig = {
      from: process.env.EMAIL_FROM || 'support@helpdesk.com',
      smtpHost: process.env.SMTP_HOST || 'localhost',
    };

    return {
      sendTicketCreated: async (email: string, subject: string) => {
        // Use emailConfig — captured once in the closure
        console.log(`[${emailConfig.from}] New ticket: ${subject}`);
      },

      sendTicketResolved: async (email: string, subject: string) => {
        console.log(`[${emailConfig.from}] Resolved: ${subject}`);
      },
    };
  },
}) {}

Using the Resolver

The factory function receives a second parameter: a resolve function. Use this to dynamically resolve dependencies that aren't known at compile time:

src/application/principal-provider.tsTypeScript
import { defineService } from '@justscale/core';
import { ModelRepository } from '@justscale/core/models';
import { Customer, Agent } from '../domain/models';

export class HelpdeskPrincipalProvider extends defineService({
  inject: {},
  factory: (_, resolve) => ({
    async resolve(user: { email: string }) {
      // Dynamically resolve repositories at call time
      const customers = await resolve(ModelRepository.of(Customer));
      const agents = await resolve(ModelRepository.of(Agent));

      const customer = await customers.findOne(
        Customer.fields.email.eq(user.email),
      );
      if (customer) return { type: 'customer', ref: Customer.ref(customer) };

      const agent = await agents.findOne(
        Agent.fields.email.eq(user.email),
      );
      if (agent) return { type: 'agent', ref: Agent.ref(agent) };

      return null;
    },
  }),
}) {}

Service Scopes

By default, services are singletons — created once and shared across the entire application. The same instance is injected everywhere it's needed.

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:

helpdesk-service.tsTypeScript
import { defineService, Logger } from '@justscale/core';
import { ModelRepository } from '@justscale/core/models';
import { Ticket } from '../domain/models';

export class HelpdeskService extends defineService({
  inject: {
    tickets: ModelRepository.of(Ticket),
    logger: Logger,
  },
  factory: ({ tickets, logger }) => ({
    async createTicket(subject: string, priority: string) {
      logger.info('Creating ticket', { subject, priority });
      const ticket = await tickets.insert({ subject, priority });
      logger.info('Ticket created', { ticketId: ticket });
      return ticket;
    },
  }),
}) {}

Type Safety

JustScale validates all dependencies at compile time. If you forget to provide a required dependency, TypeScript shows an error before your code runs:

Files
src/app.broken.tsTypeScript
import JustScale from '@justscale/core';
import { HelpdeskService } from './application/helpdesk-service';

// HelpdeskService declares `inject: { tickets: ModelRepository.of(Ticket), comments: ... }`.
// The cluster builder tracks provided vs required tokens at the type level —
// calling .add(HelpdeskService) before its repositories are registered returns
// `MissingDepsError<..., ModelRepository.of(Ticket) | ModelRepository.of(Comment)>`
// instead of a builder, and .build() refuses to compile against that.
const broken = JustScale()
  .add(HelpdeskService)
  .build();

void broken;

Built-in Logger

JustScale provides a built-in Logger service that supports structured logging and context propagation. Each injection site gets its own logger scoped to the service name.

Log Levels

log-levels.tsTypeScript
logger.debug('Detailed debug info', { data });   // Development details
logger.info('Ticket created', { ticketId });       // Normal operations
logger.warn('SLA breach approaching', { ticket }); // Potential issues
logger.error('Failed to send email', { error });   // Errors

Context Propagation

Add context that propagates through the entire async tree:

request-handler.tsTypeScript
import { Logger } from '@justscale/core';

async function handleRequest(logger: Logger, request: Request) {
  await logger.withContext(
    { requestId: request.id, userId: request.userId },
    async () => {
      logger.info('Processing ticket creation');  // Has requestId, userId
      await createTicket();                        // Nested calls too
      logger.info('Ticket created');               // Has requestId, userId
    }
  );
}

Best Practices

  • Keep services focused — each service should have a single, clear responsibility
  • Inject repositories, not raw database — services depend on abstract ModelRepository, not SQL
  • Return plain objects — the factory returns methods, not class instances
  • Use structured logging — pass attributes to logger methods instead of string interpolation