Skip to main content
Version: 4.0.0-preview

Providers

Providers encapsulate reusable capabilities — email, databases, caches, third-party SDKs — behind injectable classes. Use cases and controllers depend on provider abstractions instead of wiring libraries directly, which keeps business logic testable and swappable.

Why use providers

  • Loose coupling between application layers
  • Easy mocking in unit tests
  • Optional health, metrics, and configuration capabilities the framework discovers automatically
  • Publishable npm packages for sharing across projects

Provider capabilities

ExpressoTS providers can implement optional interfaces alongside the base metadata contract:

InterfacePurposeKey method
IProviderMetadata shown in the startup banner and Studioreadonly name, version, description, author, repo
IHealthCheckContribute to aggregated health dashboardshealthCheck(): HealthCheckResult
IMetricsExpose numeric metrics for monitoringgetMetrics(): ProviderMetrics
IConfigurable<T>Validate configuration before useconfigure(config): ConfigurationResult
IBootstrapRun once after the server is listeningbootstrap()
IShutdownClean up on graceful shutdown (receives signal)shutdown(signal?)

Implement only what your provider needs. A simple wrapper may use @provideSingleton() + IProvider; a database adapter typically adds IHealthCheck, IBootstrap, and IShutdown.

Deep dives

IProvider interface

interface IProvider {
readonly name: string;
readonly version?: string;
readonly description?: string;
readonly author?: string;
readonly repo?: string;
}

Metadata from IProvider is displayed in the startup banner, Studio Architecture Map, and introspection APIs.

IHealthCheck interface

interface HealthCheckResult {
status: "healthy" | "degraded" | "unhealthy";
latency?: number;
message?: string;
details?: Record<string, unknown>;
checkedAt?: number;
}

interface IHealthCheck {
healthCheck(): HealthCheckResult | Promise<HealthCheckResult>;
}

Health checks are auto-discovered and run in parallel. Results aggregate into a HealthDashboard with an overall status (worst of all providers).

IMetrics interface

type ProviderMetrics = Record<string, number | string | boolean>;

interface IMetrics {
getMetrics(): ProviderMetrics;
}

Metrics are collected on demand and aggregated into a MetricsDashboard.

Connection pool with metrics
import { provideSingleton, IProvider, IMetrics, ProviderMetrics } from "@expressots/core";

@provideSingleton(ConnectionPoolProvider)
export class ConnectionPoolProvider implements IProvider, IMetrics {
readonly name = "Connection Pool";
readonly version = "1.0.0";

getMetrics(): ProviderMetrics {
return {
"pool.active": this.pool.activeConnections,
"pool.idle": this.pool.idleConnections,
"pool.total": this.pool.totalConnections,
"queries.total": this.stats.totalQueries,
"queries.failed": this.stats.failedQueries,
"avg.query.time.ms": this.stats.averageQueryTime,
};
}
}

IConfigurable interface

interface ConfigurationResult {
valid: boolean;
errors?: Array<string>;
warnings?: Array<string>;
}

interface IConfigurable<TConfig = unknown> {
configure(config: TConfig): ConfigurationResult;
}

Call configure() before the provider starts accepting work. Validation errors prevent the app from starting with bad configuration.

Email provider with config validation
import {
provideSingleton,
IProvider,
IConfigurable,
ConfigurationResult,
} from "@expressots/core";

interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
}

@provideSingleton(EmailProvider)
export class EmailProvider implements IProvider, IConfigurable<SmtpConfig> {
readonly name = "Email Provider";
private config?: SmtpConfig;

configure(config: SmtpConfig): ConfigurationResult {
const errors: string[] = [];
const warnings: string[] = [];

if (!config.host) errors.push("SMTP host is required");
if (!config.port || config.port < 1 || config.port > 65535)
errors.push("SMTP port must be between 1 and 65535");
if (!config.user) errors.push("SMTP user is required");
if (!config.pass) warnings.push("Password not set — falling back to env var");

if (errors.length > 0) return { valid: false, errors, warnings };

this.config = config;
return { valid: true, warnings };
}
}

Combining capabilities

A single provider can implement any combination of interfaces. The built-in InMemoryDBProvider is a real-world example that implements all six:

@provideSingleton(InMemoryDBProvider)
export class InMemoryDBProvider
implements IProvider, IHealthCheck, IMetrics, IBootstrap, IShutdown {
readonly name = "In-Memory Database Provider";
readonly version = "4.0.0";

async bootstrap(): Promise<void> { /* load persisted data */ }
async shutdown(): Promise<void> { /* flush to disk */ }
async healthCheck(): Promise<HealthCheckResult> { /* check table count */ }
getMetrics(): ProviderMetrics { /* queries, tables, records */ }
}

Create an in-app provider

Scaffold a provider inside your application:

expressots g p mailSender

The CLI creates src/providers/mail-sender.provider.ts with a @provide() stub. Customize it for your integration:

src/providers/email.provider.ts
import {
provideSingleton,
IProvider,
IHealthCheck,
HealthCheckResult,
} from "@expressots/core";
import nodemailer from "nodemailer";
import type Transporter from "nodemailer/lib/mailer";

@provideSingleton(EmailProvider)
export class EmailProvider implements IProvider, IHealthCheck {
readonly name = "Email Provider";
readonly version = "1.0.0";
readonly description = "SMTP email delivery";

private transporter: Transporter;

constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}

async sendVerification(to: string): Promise<void> {
await this.transporter.sendMail({
to,
from: process.env.SMTP_FROM ?? "[email protected]",
subject: "Verify your account",
html: "<p>Please verify your email address.</p>",
});
}

async healthCheck(): Promise<HealthCheckResult> {
const started = Date.now();
try {
await this.transporter.verify();
return { status: "healthy", latency: Date.now() - started };
} catch (error) {
return {
status: "unhealthy",
latency: Date.now() - started,
message: error instanceof Error ? error.message : String(error),
};
}
}
}

Providers decorated with @provide(), @provideSingleton(), or @provideTransient() are registered automatically when their module loads. Manual registration is only required for external npm packages (see below).

Use a provider from a use case

Inject the provider through the constructor and call its methods from execute():

src/usecases/register-user.usecase.ts
import { provide, inject } from "@expressots/core";
import { EmailProvider } from "../providers/email.provider";
import { RegisterUserRequestDTO } from "../dto/register-user.request.dto";
import { RegisterUserResponseDTO } from "../dto/register-user.response.dto";

@provide(RegisterUserUseCase)
export class RegisterUserUseCase {
constructor(@inject(EmailProvider) private email: EmailProvider) {}

async execute(dto: RegisterUserRequestDTO): Promise<RegisterUserResponseDTO> {
const userId = await this.persistUser(dto);

await this.email.sendVerification(dto.email);

return { userId, status: "pending_verification" };
}

private async persistUser(dto: RegisterUserRequestDTO): Promise<string> {
// repository logic
return "user-id";
}
}

This matches the Send verification email flow from the Use cases diagram — the use case owns orchestration; the provider owns SMTP details.

Registration and scopes

Scope decorators

DecoratorScopeTypical use
@provide(Provider)Request (default)Per-request services
@provideSingleton(Provider)SingletonDB pools, config, shared clients
@provideTransient(Provider)TransientNew instance on every injection
@provideInScope(Provider, "tenant")Custom named scopeMulti-tenant, transaction-scoped
import {
provide,
provideSingleton,
provideTransient,
provideInScope,
} from "@expressots/core";

@provide(RequestScopedService)
export class RequestScopedService {}

@provideSingleton(EmailProvider)
export class EmailProvider {}

@provideTransient(CorrelationId)
export class CorrelationId {}

Use @provideSingleton() for providers implementing IBootstrap or IShutdown so lifecycle hooks run on a single shared instance.

Custom scopes with @provideInScope()

For multi-tenant or transaction-scoped services, use @provideInScope() with a custom scope name. Instances are shared within the same scope context but isolated across different scope values.

Tenant-scoped permission service
import { provideInScope } from "@expressots/core";

@provideInScope(TenantConfigService, "tenant")
export class TenantConfigService {
private tenantId?: string;

setTenant(id: string): void {
this.tenantId = id;
}

getTenantSettings(): TenantSettings {
return loadSettings(this.tenantId);
}
}

Custom scope names must not conflict with the built-in names ("Singleton", "Request", "Transient"). Using a built-in name throws an error at startup.

Provider source tracking

Every provider decorator accepts an optional source parameter that classifies the provider origin:

SourceDescriptionExample
"user" (default)Application-level provider@provideSingleton(MyService)
"builtin"Core framework provider@provideSingleton(Logger, "builtin")
"external"Third-party npm package@provideSingleton(JwtProvider, "external")
@provideSingleton(JwtProvider, "external")
export class JwtProvider implements IProvider {
readonly name = "JWT Provider";
readonly version = "2.0.0";
}

Source tracking powers banner output, Studio views, and introspection queries like getBySource().

Manual registration (this.Provider)

Register external packages or bind an interface to a concrete class inside configureServices():

src/app.ts
import { AppExpress } from "@expressots/adapter-express";
import { Scope } from "@expressots/core";
import { GreeterProvider } from "@acme/expressots-greeter";

export class App extends AppExpress {
async configureServices(): Promise<void> {
// Self-binding with scope
this.Provider.register(GreeterProvider, Scope.Singleton);

// Interface-to-implementation binding
this.Provider.register("ICache", RedisCache, Scope.Singleton);
}
}

The register() method has two overloads:

register(Provider, Scope?) // bind Provider to itself
register("IToken", ConcreteClass, Scope?) // bind token to implementation

Retrieve a provider at runtime:

const email = this.Provider.get(EmailProvider);

Check if a provider is registered:

if (this.Provider.has(EmailProvider)) {
// already bound
}

Built-in providers

ExpressoTS ships these providers out of the box (source "builtin"):

ProviderScopeCapabilitiesDocumentation
LoggerSingletonIProviderLogging
InMemoryDBProviderSingletonIProvider, IHealthCheck, IMetrics, IBootstrap, IShutdownIn-Memory DB
ValidateDTO— (function)DTO validation middlewareValidation

Logger is always available. InMemoryDBProvider must be explicitly bound when needed:

this.Provider.register(InMemoryDBProvider, Scope.Singleton);

Plugin pattern

External providers are npm packages that export @provide()-decorated classes. The application installs them with expressots add, registers them through this.Provider.register(), and resolves them with this.Provider.get() under the chosen scope.

ExpressoTS provider plugin pattern: ProviderManager, scopes, and optional capabilities

External provider packages

Install an existing package

expressots add @expressots/provider-jwt

See CLI Providers for add, remove, and version flags.

Scaffold a new package

expressots create --provider my-provider

This clones the official provider template with dual ESM + CJS builds, Jest, and CI workflows. See Building a Provider Package for the full publish workflow.

A publishable provider exports a DI-friendly class from its package entry point:

src/index.ts
export { GreeterProvider, type GreeterOptions } from "./greeter.provider";
src/greeter.provider.ts
import { provide, IProvider } from "@expressots/core";

@provide(GreeterProvider)
export class GreeterProvider implements IProvider {
readonly name = "Greeter Provider";
readonly version = "1.0.0";

greet(name: string): string {
return `Hello, ${name}!`;
}
}

Consuming apps install the package, register it, and inject it like any in-app provider.

Introspection

ProviderManager (available as this.Provider on AppExpress) exposes discovery and query APIs. Auto-discovery runs automatically during startup — you do not need to call discover() manually.

Query providers

// All registered providers
const all = this.Provider.getAll();

// Filter by scope
const singletons = this.Provider.getByScope("Singleton");

// Filter by capability
const withHealth = this.Provider.getWithCapability("hasHealthCheck");
const withMetrics = this.Provider.getWithCapability("hasMetrics");
const withConfig = this.Provider.getWithCapability("hasConfigurable");
const withBootstrap = this.Provider.getWithCapability("hasBootstrap");
const withShutdown = this.Provider.getWithCapability("hasShutdown");

// Filter by source
const builtin = this.Provider.getBuiltinProviders();
const user = this.Provider.getUserProviders();
const external = this.Provider.getExternalProviders();
const bySource = this.Provider.getBySource("external");

// Lifecycle providers (bootstrap or shutdown)
const lifecycle = this.Provider.getLifecycleProviders();

// Count
const total = this.Provider.getCount();

Health and metrics dashboards

// Aggregated health (runs all IHealthCheck providers in parallel)
const health = await this.Provider.checkHealth();
console.log(health.overall); // "healthy" | "degraded" | "unhealthy"
console.log(health.providers); // [{ name, result: HealthCheckResult }]
console.log(health.checkedAt); // timestamp

// Aggregated metrics (collects from all IMetrics providers)
const metrics = this.Provider.collectMetrics();
console.log(metrics.providers); // { "Connection Pool": { "pool.active": 5, ... } }
console.log(metrics.collectedAt); // timestamp

Formatted banner view

const view = this.Provider.getFormattedView(5);
// view.entries — first N providers with name, scope, source, capability flags
// view.total — total count
// view.remaining — providers not shown
// view.bySource — { builtin: 2, user: 5, external: 1 }

ProviderInfo structure

Each entry from introspection APIs returns a ProviderInfo object:

interface ProviderInfo {
name: string;
target: new (...args: unknown[]) => unknown;
scope: "Singleton" | "Request" | "Transient" | string;
capabilities: ProviderCapabilities;
version?: string;
description?: string;
source: "builtin" | "user" | "external";
author?: string;
repo?: string;
dependencies?: string[];
priority?: number;
}

interface ProviderCapabilities {
hasBootstrap: boolean;
hasShutdown: boolean;
hasHealthCheck: boolean;
hasMetrics: boolean;
hasConfigurable: boolean;
}

Testing providers

Unit test with a mock transporter

import { EmailProvider } from "../providers/email.provider";

describe("EmailProvider", () => {
it("sends verification email", async () => {
const provider = new EmailProvider();
(provider as any).transporter = {
sendMail: jest.fn().mockResolvedValue({ messageId: "1" }),
verify: jest.fn().mockResolvedValue(true),
};

await provider.sendVerification("[email protected]");

expect((provider as any).transporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({ to: "[email protected]" }),
);
});
});

Replace a provider in tests

class MockEmailProvider implements Pick<EmailProvider, "sendVerification"> {
sent: string[] = [];
async sendVerification(to: string) {
this.sent.push(to);
}
}

const useCase = new RegisterUserUseCase(new MockEmailProvider() as EmailProvider);
await useCase.execute({ email: "[email protected]", password: "secret" });
expect((useCase as any).email.sent).toContain("[email protected]");

Test health checks and metrics

describe("ConnectionPoolProvider", () => {
it("returns healthy when pool responds", async () => {
const provider = new ConnectionPoolProvider();
const result = await provider.healthCheck();
expect(result.status).toBe("healthy");
expect(result.latency).toBeDefined();
});

it("exposes pool metrics", () => {
const provider = new ConnectionPoolProvider();
const metrics = provider.getMetrics();
expect(metrics["pool.active"]).toBeDefined();
expect(metrics["pool.total"]).toBeGreaterThanOrEqual(0);
});
});

Best practices

  1. Implement IProvider — metadata powers banner, Studio, and introspection
  2. Add IHealthCheck for infrastructure providers (DB, cache, queues)
  3. Add IMetrics for providers you need to monitor (connection pools, caches)
  4. Use IConfigurable to validate required settings before opening connections
  5. Use @provideSingleton() for shared connections and lifecycle hooks
  6. Use @provideInScope() for multi-tenant or transaction-scoped services
  7. Mark source as "external" when building publishable provider packages
  8. Define interfaces when you need swappable implementations in tests
  9. Publish with semver — follow provider package guide for npm releases
  10. Combine capabilities as needed — a database provider can implement IProvider + IHealthCheck + IMetrics + IBootstrap + IShutdown

Support the Project

ExpressoTS is MIT-licensed open source. See the support guide to contribute.