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:
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:
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:
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:
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:
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:
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
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 }); // ErrorsContext Propagation
Add context that propagates through the entire async tree:
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