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:
| Interface | Purpose | Key method |
|---|---|---|
IProvider | Metadata shown in the startup banner and Studio | readonly name, version, description, author, repo |
IHealthCheck | Contribute to aggregated health dashboards | healthCheck(): HealthCheckResult |
IMetrics | Expose numeric metrics for monitoring | getMetrics(): ProviderMetrics |
IConfigurable<T> | Validate configuration before use | configure(config): ConfigurationResult |
IBootstrap | Run once after the server is listening | bootstrap() |
IShutdown | Clean 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.
- Health & Monitoring — middleware endpoint +
IHealthCheckdashboard - Lifecycle Hooks —
IBootstrap/IShutdownexecution order - In-Memory DB — built-in provider that implements all six interfaces
- Logging — the built-in
Loggerprovider
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.
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.
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:
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,
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():
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
| Decorator | Scope | Typical use |
|---|---|---|
@provide(Provider) | Request (default) | Per-request services |
@provideSingleton(Provider) | Singleton | DB pools, config, shared clients |
@provideTransient(Provider) | Transient | New instance on every injection |
@provideInScope(Provider, "tenant") | Custom named scope | Multi-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.
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:
| Source | Description | Example |
|---|---|---|
"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():
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"):
| Provider | Scope | Capabilities | Documentation |
|---|---|---|---|
Logger | Singleton | IProvider | Logging |
InMemoryDBProvider | Singleton | IProvider, IHealthCheck, IMetrics, IBootstrap, IShutdown | In-Memory DB |
ValidateDTO | — (function) | DTO validation middleware | Validation |
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.

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:
export { GreeterProvider, type GreeterOptions } from "./greeter.provider";
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),
};
expect((provider as any).transporter.sendMail).toHaveBeenCalledWith(
);
});
});
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);
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
- Implement
IProvider— metadata powers banner, Studio, and introspection - Add
IHealthCheckfor infrastructure providers (DB, cache, queues) - Add
IMetricsfor providers you need to monitor (connection pools, caches) - Use
IConfigurableto validate required settings before opening connections - Use
@provideSingleton()for shared connections and lifecycle hooks - Use
@provideInScope()for multi-tenant or transaction-scoped services - Mark source as
"external"when building publishable provider packages - Define interfaces when you need swappable implementations in tests
- Publish with semver — follow provider package guide for npm releases
- 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.