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​