Skip to main content
Skip to main content

Service Lifetimes

HexDI provides three lifetime scopes that control when service instances are created and how long they live.

Overview

LifetimeInstance CreationScope RequiredUse Case
singletonOnce per containerNoShared resources, stateless services
scopedOnce per scopeYesRequest context, user sessions
transientEvery resolutionNoFresh instances, isolation

Singleton Lifetime

Singleton services are created once and shared across the entire application.

When to Use Singleton

  • Stateless services (loggers, validators)
  • Shared resources (database pools, HTTP clients)
  • Configuration services
  • Expensive-to-create services

Example

const LoggerAdapter = createAdapter({
provides: LoggerPort,
requires: [],
lifetime: 'singleton',
factory: () => {
console.log('Logger created'); // Only logged once
return {
log: (msg) => console.log(`[App] ${msg}`)
};
}
});

Behavior

const container = createContainer({ graph, name: "App" });

// First resolution creates the instance
const logger1 = container.resolve(LoggerPort);

// Subsequent resolutions return the same instance
const logger2 = container.resolve(LoggerPort);

console.log(logger1 === logger2); // true

// Same instance in scopes too
const scope = container.createScope();
const logger3 = scope.resolve(LoggerPort);

console.log(logger1 === logger3); // true

Singleton Dependencies

Singletons can only depend on other singletons. This prevents the "captive dependency" anti-pattern:

// This would be problematic (caught at compile-time in strict mode)
const BadSingletonAdapter = createAdapter({
provides: BadServicePort,
requires: [ScopedServicePort], // Scoped service as dependency!
lifetime: 'singleton', // Singleton depending on scoped = bug
factory: (deps) => ({ /* ... */ })
});

Scoped Lifetime

Scoped services are created once per scope and shared within that scope.

When to Use Scoped

  • Request-specific state (HTTP request context)
  • User sessions
  • Database transactions
  • Per-request caching

Example

const UserSessionAdapter = createAdapter({
provides: UserSessionPort,
requires: [],
lifetime: 'scoped',
factory: () => {
console.log('UserSession created'); // Logged once per scope
return {
userId: getCurrentUserId(),
startedAt: new Date()
};
}
});

Behavior

const container = createContainer({ graph, name: "App" });

// Cannot resolve scoped services from root container
// container.resolve(UserSessionPort); // Error: ScopeRequiredError

// Must use a scope
const scope1 = container.createScope();
const session1a = scope1.resolve(UserSessionPort);
const session1b = scope1.resolve(UserSessionPort);

console.log(session1a === session1b); // true - same scope

// Different scopes get different instances
const scope2 = container.createScope();
const session2 = scope2.resolve(UserSessionPort);

console.log(session1a === session2); // false - different scopes

// Don't forget to dispose!
await scope1.tryDispose();
await scope2.tryDispose();

Scoped Dependencies

Scoped services can depend on:

  • Singletons (safe - longer-lived than scope)
  • Other scoped services (same lifetime)
const ChatServiceAdapter = createAdapter({
provides: ChatServicePort,
requires: [LoggerPort, UserSessionPort], // singleton + scoped
lifetime: 'scoped',
factory: (deps) => ({
sendMessage: (content) => {
deps.Logger.log(`${deps.UserSession.userId} sent: ${content}`);
}
})
});

Transient Lifetime

Transient services create a fresh instance every time they're resolved.

When to Use Transient

  • Services needing unique identifiers
  • Stateful services where state shouldn't be shared
  • When isolation between calls is important
  • One-off operations

Example

let instanceCounter = 0;

const NotificationAdapter = createAdapter({
provides: NotificationPort,
requires: [],
lifetime: 'transient',
factory: () => {
instanceCounter++;
console.log(`Notification instance #${instanceCounter} created`);
return {
id: `notif-${instanceCounter}`,
createdAt: new Date(),
send: (message) => {
console.log(`[Notification #${instanceCounter}] ${message}`);
}
};
}
});

Behavior

const container = createContainer({ graph, name: "App" });

// Each resolution creates a new instance
const notif1 = container.resolve(NotificationPort);
const notif2 = container.resolve(NotificationPort);

console.log(notif1 === notif2); // false
console.log(notif1.id === notif2.id); // false

// Same in scopes - still fresh each time
const scope = container.createScope();
const notif3 = scope.resolve(NotificationPort);
const notif4 = scope.resolve(NotificationPort);

console.log(notif3 === notif4); // false

Transient Dependencies

Transient services can depend on any lifetime (they're the shortest-lived):

const RequestServiceAdapter = createAdapter({
provides: RequestServicePort,
requires: [LoggerPort, UserSessionPort, NotificationPort],
lifetime: 'transient',
factory: (deps) => ({
// Fresh instance every time, but deps follow their own lifetimes
// - Logger: same singleton each time
// - UserSession: same scoped instance within scope
// - Notification: fresh instance each time
})
});

Lifetime Hierarchy

Lifetimes have a hierarchy based on how long instances live:

singleton (longest) > scoped > transient (shortest)

Dependency Rules

A service can only depend on services with equal or longer lifetimes:

Service LifetimeCan Depend On
singletonsingleton only
scopedsingleton, scoped
transientsingleton, scoped, transient

Captive Dependency Prevention

A "captive dependency" occurs when a longer-lived service captures a shorter-lived one:

// BAD: Singleton holding onto scoped service
const BadAdapter = createAdapter({
provides: BadServicePort,
requires: [UserSessionPort], // scoped
lifetime: 'singleton', // singleton
factory: (deps) => {
// This UserSession is captured forever!
// It won't update when the user changes.
const session = deps.UserSession;
return { getUser: () => session.userId };
}
});

HexDI helps prevent this with compile-time validation in strict mode.

Scope Management Patterns

HTTP Request Pattern

import { fromPromise } from '@hex-di/result';

async function handleRequest(req: Request, res: Response) {
const scope = container.createScope();
const result = await scope.tryResolve(UserServicePort)
.asyncAndThen((userService) => fromPromise(userService.processRequest(req), (e) => e));
await scope.tryDispose();
result.match(
(data) => res.json(data),
(error) => res.status(500).json({ error: String(error) }),
);
}

React Pattern (AutoScopeProvider)

function UserDashboard() {
return (
<AutoScopeProvider>
{/* Children get scoped services */}
<UserProfile />
<UserSettings />
</AutoScopeProvider>
);
}

Worker Thread Pattern

import { fromPromise } from '@hex-di/result';

async function processJob(jobId: string) {
const scope = container.createScope();
const result = await scope.tryResolve(JobProcessorPort)
.asyncAndThen((processor) => fromPromise(processor.process(jobId), (e) => e));
await scope.tryDispose();
return result;
}

Disposal and Cleanup

Finalizers

Adapters can define cleanup logic via finalizers:

const DatabaseAdapter = createAdapter({
provides: DatabasePort,
requires: [],
lifetime: 'singleton',
factory: () => new DatabasePool(),
finalizer: async (pool) => {
await pool.close();
console.log('Database pool closed');
}
});

Disposal Order

Finalizers are called in reverse creation order (LIFO):

// Creation order: A → B → C
// Disposal order: C → B → A

This ensures dependencies are available during cleanup.

Scope Disposal

When a scope is disposed:

  1. Scoped service finalizers are called (LIFO)
  2. Transient services don't have finalizers (too many instances)
  3. Singletons are NOT disposed (they belong to the container)
const scope = container.createScope();
const userSession = scope.resolve(UserSessionPort); // scoped
const logger = scope.resolve(LoggerPort); // singleton

await scope.tryDispose();
// Only userSession's finalizer is called
// logger (singleton) stays alive

Container Disposal

When the container is disposed:

  1. All scopes should already be disposed (warning if not)
  2. Singleton finalizers are called (LIFO)
await container.tryDispose();
// All singleton finalizers called
// Container can no longer resolve services

Choosing the Right Lifetime

Decision Flowchart

Is the service stateless?
├─ Yes → Consider singleton
└─ No → Does state need to persist across requests?
├─ Yes → Is it per-user/per-resolution?
│ ├─ Yes → Use scoped
│ └─ No → Use singleton
└─ No → Use transient

Common Patterns

Service TypeRecommended Lifetime
Loggersingleton
Configurationsingleton
Database poolsingleton
HTTP clientsingleton
User sessionscoped
Request contextscoped
Database transactionscoped
Notification sendertransient
Request ID generatortransient

Next Steps