Supporting multiple LLM vendors may seem like a simple requirement. However, things become more complex when users need to switch between vendors without rebuilding or redeploying the application. This is where a well-designed architecture becomes important.
During one of client projects, I came across a requirement to integrate multiple LLM providers into the application. The idea was to use different LLM vendors based on the user’s clinical specialty. For example, one deployment or healthcare organization might be configured to use one LLM provider, while another environment may use a different approved provider.
When a requirement like this comes in, there are several ways to implement it. One simple approach is to use conditional statements to select the appropriate provider. However, as the number of LLM vendors grows, this approach can make the code harder to maintain and extend.
This made me think about how to design a more flexible solution, one that allows the application to switch between LLM providers without tightly coupling the business logic to a specific vendor.
While exploring different approaches, I decided to use Dependency Injection (DI) in NestJS to build a multi-LLM provider architecture.
In this post, I’ll explain the problem, explore the implementation approaches, and build a solution step by step using OpenAI and DeepSeek as example providers. The same architecture can be extended to support additional LLM vendors in the future.
In healthcare specifically, vendor choice isn’t just a technical preference; it’s often dictated by what’s been approved to handle PHI.
The Problem: One Application, Several LLM Vendors
- Integrating a single AI vendor is usually straightforward: install its SDK, configure an API key, and call the service. Complexity increases when a second vendor is introduced.
- Every vendor has a different SDK, request format, authentication mechanism, and response shape.
- Provider selection may need to be decided through configuration rather than hard-coded in the application.
- Additional vendors should be introduced without rewriting existing business logic.
- The service responsible for generating a response should not need to know which vendor is currently active.
A common first attempt is to place provider selection inside one service:
class LlmService {
async generate(prompt: string) {
if (process.env.LLM_PROVIDER === 'openai') {
return callOpenAi(prompt);
} else if (process.env.LLM_PROVIDER === 'deepseek') {
return callDeepSeek(prompt);
}
throw new Error('Unknown provider');
}
}This works initially, but every new vendor adds another conditional. Vendor-specific authentication, SDK calls, and response parsing gradually become mixed with business logic. Over time, the service becomes difficult to test and risky to modify.
Why Dependency Injection Makes Sense
- The central idea behind Dependency Injection is simple: a class should not create the dependencies it needs. Instead, those dependencies should be provided to it.
- Business logic is decoupled from vendor implementations.
- The active provider can be selected during application bootstrap through configuration.
- Adding a new provider does not require changes to the service or controller.
- Tests can inject a fake provider without making real network calls.
NestJS is designed around this approach. Its IoC container manages the dependency graph and creates the required instances. Providers can be classes, values, or factory results, while modules group related providers and controllers.
Architecture That Works
HTTP Request
|
v
LlmController
|
v
LlmService (vendor-agnostic)
|
| @Inject(LLM_PROVIDER)
v
LLM_PROVIDER token
|
v
LlmProviderRegistry
|
+--> OpenAiProvider
|
+--> DeepSeekProvider
LLM_PROVIDER=openai|deepseekThe architecture uses a common interface, one implementation per vendor, a registry, and a factory provider bound to a custom DI token.
Core components
- LlmProvider interface: defines the contract every provider must implement.
- Concrete providers: encapsulate vendor-specific credentials, SDK calls, and response mapping.
- LlmProviderRegistry: maps a vendor name to its provider instance.
- Factory provider: reads configuration and resolves the active provider.
- LlmModule.forRoot(): wires the complete provider architecture into a reusable dynamic module.
- LlmService and LlmController: consume the provider without knowing which vendor is active.
Request flow
- Nest boots the application and imports LlmModule.forRoot().
- The dynamic module registers the configuration service, concrete providers, registry, bootstrap provider, factory provider, and service.
- The bootstrap provider registers the concrete provider instances in the registry.
- The factory provider reads LLM_PROVIDER from ConfigService and resolves the matching provider.
- LlmService receives the resolved provider through the LLM_PROVIDER token.
- The controller calls LlmService.generate(), which delegates to the selected provider.
Technical Implementation
1. Define the provider contract
The first step is to define a shared contract. Consumers depend on this interface instead of importing concrete provider classes.
// src/llm/interfaces/llm-provider.interface.ts
export interface LlmGenerateOptions {
maxTokens?: number;
temperature?: number;
}
export interface LlmResponse {
provider: string;
model: string;
text: string;
}
export interface LlmProvider {
getName(): string;
generate(
prompt: string,
options?: LlmGenerateOptions
): Promise;
} This interface establishes a stable boundary. The service only needs to know that a provider can expose its name and generate an LLM response.
2. Create DI tokens
TypeScript interfaces are removed during compilation, so NestJS cannot use an interface directly as a runtime injection token. A concrete token is required.
// src/llm/tokens/llm.tokens.ts
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
export enum LlmVendor {
OPENAI = 'openai',
DEEPSEEK = 'deepseek',
}A Symbol is useful because it avoids accidental collisions with unrelated string tokens in a larger application.
3. Implement concrete providers
Each vendor receives its own class. The provider reads configuration through ConfigService rather than accessing process.env directly.
// src/llm/providers/openai.provider.ts
@Injectable()
export class OpenAiProvider implements LlmProvider {
private readonly apiKey: string;
private readonly model: string;
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get(
'OPENAI_API_KEY',
'placeholder-openai-key',
);
this.model = this.configService.get(
'OPENAI_MODEL',
'gpt-4o-mini',
);
}
getName(): string {
return LlmVendor.OPENAI;
}
generate(
prompt: string,
options?: LlmGenerateOptions,
): Promise {
// A real implementation calls the OpenAI API here.
return Promise.resolve({
provider: this.getName(),
model: this.model,
text: `[simulated openai response] "${prompt}"`,
});
}
} DeepSeekProvider follows the same contract while using its own API key, model, request format, and response mapping. Neither provider needs to know that the other exists.
4. Use a registry instead of if/else statements
The registry implements a simple Strategy-pattern lookup. Providers are registered by name and resolved when needed.
// src/llm/registry/llm-provider.registry.ts
@Injectable()
export class LlmProviderRegistry {
private readonly providers = new Map<string, LlmProvider>();
register(provider: LlmProvider): void {
this.providers.set(provider.getName(), provider);
}
get(name: string): LlmProvider {
const provider = this.providers.get(name);
if (!provider) {
throw new Error(
`Unknown LLM provider "${name}". Registered providers: ${
[...this.providers.keys()].join(', ')
}`,
);
}
return provider;
}
}When a new vendor is introduced, the registry itself does not need to change. The new implementation simply needs to be registered.
5. Create the factory provider
A factory provider tells NestJS to execute a function, inject the function’s dependencies, and bind its return value to a token.
const llmProviderFactory: Provider = {
provide: LLM_PROVIDER,
inject: [
ConfigService,
LlmProviderRegistry,
'LLM_PROVIDER_BOOTSTRAP',
],
useFactory: (
configService: ConfigService,
registry: LlmProviderRegistry,
) => {
const activeVendor = configService.get(
'LLM_PROVIDER',
LlmVendor.OPENAI,
);
return registry.get(activeVendor);
},
};The bootstrap token is included as a dependency even though it is not used inside the factory body. Its purpose is to force NestJS to resolve the bootstrap provider first, ensuring that the registry has been populated before the factory performs its lookup.
6. Wire everything through a dynamic module
// src/llm/llm.module.ts
@Module({})
export class LlmModule {
static forRoot(): DynamicModule {
return {
module: LlmModule,
imports: [
ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
OpenAiProvider,
DeepSeekProvider,
LlmProviderRegistry,
llmProviderBootstrap,
llmProviderFactory,
LlmService,
],
exports: [LlmService],
global: true,
};
}
}The application module remains simple because all provider wiring is encapsulated inside LlmModule.forRoot().
7. Consume the provider without vendor knowledge
// src/llm/llm.service.ts
@Injectable()
export class LlmService {
constructor(
@Inject(LLM_PROVIDER)
private readonly provider: LlmProvider,
) {}
getActiveVendor(): string {
return this.provider.getName();
}
generate(
prompt: string,
options?: LlmGenerateOptions,
): Promise {
return this.provider.generate(prompt, options);
}
} The controller depends only on LlmService. It does not import OpenAiProvider, DeepSeekProvider, or the registry.
@Controller('llm')
export class LlmController {
constructor(private readonly llmService: LlmService) {}
@Get('active-provider')
getActiveProvider() {
return {
activeProvider: this.llmService.getActiveVendor(),
};
}
@Post('generate')
async generate(@Body() dto: GenerateDto) {
return this.llmService.generate(dto.prompt, {
maxTokens: dto.maxTokens,
temperature: dto.temperature,
});
}
}Security, Configuration, and Compliance Considerations
- Never commit real API keys. Keep .env ignored and commit only placeholder values in .env.example.
- Read secrets through ConfigService rather than accessing process.env directly inside provider classes.
- Use a global ValidationPipe with whitelist and forbidNonWhitelisted enabled for request DTOs.
- Fail fast when LLM_PROVIDER contains an unsupported value instead of silently falling back to an unexpected vendor.
- These practices are especially important when user-provided prompts are forwarded to external AI vendors.
Building AI-powered healthcare products that need to support multiple LLM vendors? Talk to our Team
Explicit Bootstrap Dependencies
NestJS resolves providers through the dependency graph, not by relying on the order of entries in the providers array. An explicit bootstrap token makes registry initialization a framework-enforced dependency rather than an assumption.
{
provide: 'LLM_PROVIDER_BOOTSTRAP',
inject: [
LlmProviderRegistry,
OpenAiProvider,
DeepSeekProvider,
],
useFactory: (
registry,
openAiProvider,
deepSeekProvider,
) => {
registry.register(openAiProvider);
registry.register(deepSeekProvider);
return true;
},
}
forRootAsync()forRoot() is sufficient when configuration comes synchronously from ConfigService. If provider selection later comes from a remote feature-flag service or database, forRootAsync() becomes a natural extension without requiring a redesign of the consumer layer.
Benefits and Outcomes
- Adding a provider does not require changes to LlmService or LlmController.
- Switching vendors becomes a configuration change rather than a business-logic change.
- Tests can inject a fake LlmProvider and avoid vendor SDKs and network calls.
- Vendor-specific bugs remain isolated inside the relevant provider implementation.
- The architecture creates a stable boundary for future providers and integrations.
Common Challenges and Solutions
- Provider initialization order: solved through an explicit bootstrap dependency.
- Invalid provider names: solved by throwing a descriptive error from the registry.
- Proving switching works: solved through integration tests that boot the module with different provider configurations.
- In regulated environments, the registry gives a single auditable point for which vendor handled which request, useful when a compliance review asks how AI vendor usage is tracked.
- Vendor approval changes: a health system revokes or adds an approved AI vendor. Solved by treating provider selection as configuration, not code, so the change doesn’t require a redeploy.
Environment Setup and Repository Walkthrough
The complete proof of concept is available in my GitHub repository. It contains the NestJS Dependency Injection setup, the common LLM provider contract, the OpenAI and DeepSeek provider implementations, the registry, the factory provider, the dynamic module, and the related tests.
You can clone the repository and run the project locally to explore the implementation in practice.
git clone https://github.com/nadeemkhan02/poc-nestjs-di-llm-providers.git
cd poc-nestjs-di-llm-providers
npm install
cp .env.example .env
npm run start:devAfter cloning the repository, copy the example environment file into .env and configure the provider selection. For example:
# .env
LLM_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-placeholder
OPENAI_MODEL=gpt-4o-mini
DEEPSEEK_API_KEY=your-deepseek-api-key-placeholder
DEEPSEEK_MODEL=deepseek-chat
PORT=3000The repository uses placeholder credentials for this proof of concept. The provider implementations demonstrate the Dependency Injection and configuration flow; real vendor API calls can be added behind the same interface.
What’s Next?
- Connect one provider to a real HTTP API while keeping the other provider implementations behind the same interface.
- Add a forRootAsync() configuration path for asynchronous provider selection.
- Introduce a streaming method such as AsyncIterable once a real vendor integration supports token streaming.
Final Thoughts
What began as a request to let customers choose an LLM became a practical lesson in designing for change. The key idea is straightforward: a class should not construct the thing it depends on; it should receive that dependency.
NestJS provides the machinery to make this principle work at scale through its IoC container, providers, injection tokens, and dynamic modules. With a shared interface and a clear provider boundary, vendor selection becomes configurable, testing becomes simpler, and new integrations can be added without rewriting business logic.
If your application is likely to support multiple implementations of the same capability, introducing this pattern early can be considerably easier than refactoring a growing set of vendor-specific conditionals later. In healthcare software, that flexibility can become particularly valuable as vendor approvals, data-handling requirements, and organizational policies evolve. Vendor approvals and compliance requirements change, and the architecture needs to absorb that change as configuration, not as a rewrite.








BLOGS
NEWSROOM
CASE STUDIES
WEBINARS
PODCASTS
ASSET HUB
EVENT CALENDAR 


















