While building an application with LLMs, the most straightforward thing to do is call the provider directly.
Let's say I'm using OpenAI.
My application needs to generate a heading from a conversation.
I could simply write:
@Injectable()
export class ConversationService {
async generateHeading(conversation: string) {
const response = await openai.responses.create({
model: "...",
input: `
Generate a short heading for this conversation:
${conversation}
`,
});
return response.output_text;
}
}
And there's absolutely nothing wrong with this if my application is small and I know I'm only going to use OpenAI.
But let's say the application grows.
Now I have multiple places using an LLM:
ConversationService
↓
OpenAI SDK
BondService
↓
OpenAI SDK
SummaryService
↓
OpenAI SDK
My application is slowly becoming coupled to OpenAI.
Then one day I decide:
What if I want to try Anthropic instead?
Now I have a problem.
Anthropic doesn't necessarily expose the same SDK methods as OpenAI.
So code like:
openai.responses.create(...)
would have to change.
And if OpenAI-specific code is spread throughout my application, switching providers means changing multiple services.
But if I think about it, my business logic doesn't actually care about OpenAI.
ConversationService just wants:
"Generate a heading."
BondService might just want:
"Generate structured data."
And another service might want:
"Stream some text."
Those are my application's requirements.
How OpenAI, Anthropic, or Gemini perform them is an implementation detail.
And this is where provider abstraction starts to make sense.
Defining What My Application Needs
Instead of letting every service understand OpenAI's SDK, I can define a common interface.
export interface LLMProvider {
generate(prompt: string): Promise<string>;
generateStructured<T>(
prompt: string,
schema: unknown,
): Promise<T>;
}
I'm essentially saying:
Any LLM provider that wants to work with my application must provide these capabilities.
Now my application has its own language:
provider.generate(prompt);
instead of being built around a vendor's language:
openai.responses.create(...)
Different Providers Can Implement the Same Requirement
OpenAI can implement my interface:
export class OpenAIProvider implements LLMProvider {
async generate(prompt: string): Promise<string> {
// Translate generate() into the OpenAI SDK call
const response = await openai.responses.create({
model: "...",
input: prompt,
});
return response.output_text;
}
}
Anthropic can implement the same interface:
export class AnthropicProvider implements LLMProvider {
async generate(prompt: string): Promise<string> {
// Translate generate() into the Anthropic SDK call
// ...
return "...";
}
}
Now I have:
LLMProvider
generate()
/ \
/ \
↓ ↓
OpenAIProvider AnthropicProvider
↓ ↓
OpenAI SDK Anthropic SDK
My application cares about:
generate()
The provider cares about:
How do I make that happen using this particular vendor?
That is the basic idea behind provider abstraction.
This Is Where Dependency Inversion Appears
At this point, something interesting has happened to our architecture.
Originally we had:
ConversationService
↓
OpenAIProvider
Our higher-level application code directly depended on a concrete implementation.
After introducing LLMProvider, we have:
ConversationService
↓
LLMService
↓
LLMProvider
↑
OpenAIProvider
Our higher-level code now depends on an abstraction rather than directly depending on OpenAI.
This is the idea behind the Dependency Inversion Principle.
High-level code should depend on abstractions rather than concrete implementations.
I didn't introduce an interface because someone told me:
"You must use the Dependency Inversion Principle."
I introduced it because I didn't want my application tightly coupled to OpenAI.
The design principle is simply a name for what this architecture is achieving.
But How Does LLMService Get an OpenAIProvider?
Now we have another question.
Suppose LLMService looks like this:
@Injectable()
export class LLMService {
constructor(
private readonly provider: LLMProvider,
) {}
generate(prompt: string) {
return this.provider.generate(prompt);
}
}
LLMService isn't doing this:
const provider = new OpenAIProvider();
Instead, we're saying:
constructor(
private readonly provider: LLMProvider,
) {}
In other words:
LLMService, don't create your own dependency. Someone else will give it to you.
That idea is Dependency Injection.
Without DI
LLMService
↓
new OpenAIProvider()
With DI
OpenAIProvider
↓
injected into
↓
LLMService
NestJS has a Dependency Injection container that handles this for us.
But now we run into another problem.
And this is where Symbols finally become relevant.
But There Is One Problem: Interfaces Don't Exist at Runtime
So far, our LLMService looks something like this:
@Injectable()
export class LLMService {
constructor(
private readonly provider: LLMProvider,
) {}
generate(prompt: string) {
return this.provider.generate(prompt);
}
}
Conceptually, this is exactly what we want.
We're saying:
LLMServicedoesn't care whether it gets OpenAI, Anthropic, or something else. Just give it something that follows theLLMProvidercontract.
But now we run into an interesting TypeScript + NestJS problem.
TypeScript Understands Interfaces, JavaScript Doesn't
We defined:
export interface LLMProvider {
generate(prompt: string): Promise<string>;
}
TypeScript understands this perfectly.
It can check:
class OpenAIProvider implements LLMProvider {
async generate(prompt: string): Promise<string> {
// ...
}
}
If OpenAIProvider forgets to implement generate(), TypeScript can complain.
So during development:
LLMProvider interface
↓
TypeScript understands it
↓
Checks our code
But TypeScript doesn't run directly in production.
It gets compiled/transpiled into JavaScript.
And JavaScript does not have TypeScript's interface construct.
So something like:
interface LLMProvider {
generate(prompt: string): Promise<string>;
}
class OpenAIProvider implements LLMProvider {
// ...
}
roughly becomes:
class OpenAIProvider {
// ...
}
The interface is gone.
TYPESCRIPT
LLMProvider ✅
OpenAIProvider ✅
↓
compilation
JAVASCRIPT
LLMProvider ❌
OpenAIProvider ✅
This is intentional.
The interface was only needed for type checking while developing the application.
Why Is That a Problem for NestJS?
Because NestJS Dependency Injection happens at runtime.
When the application is actually running, Nest needs to know:
Which dependency should I put inside
LLMService?
For classes, this is often straightforward because classes exist at runtime.
For example:
constructor(
private readonly userService: UserService,
) {}
UserService is a class.
The class still exists in JavaScript at runtime, so Nest can use it as a DI token.
But:
constructor(
private readonly provider: LLMProvider,
) {}
is different.
LLMProvider is an interface.
By the time NestJS is running:
LLMProvider
↓
doesn't exist
So Nest effectively has no runtime identifier it can use to answer:
"What exactly should I inject here?"
We need to give Nest something that does exist at runtime.
And that's where Symbols come in.
Enter Symbols
JavaScript has a primitive called Symbol.
const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Every Symbol is a unique runtime value.
For example:
const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");
console.log(a === b); // false
Even though both have the same description, they are two different values.
So we can create:
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
and use this Symbol as a runtime identifier for our dependency.
Now we have two similarly named things, but they have completely different jobs:
LLMProvider
↓
TypeScript interface
↓
Defines WHAT a provider must be able to do
↓
Compile-time only
LLM_PROVIDER
↓
JavaScript Symbol
↓
Identifies the dependency for NestJS
↓
Exists at runtime
Connecting the Symbol to OpenAIProvider
Now we tell Nest:
{
provide: LLM_PROVIDER,
useClass: OpenAIProvider,
}
You can almost read this as plain English:
For the dependency identified by
LLM_PROVIDER, useOpenAIProvider.
Nest's DI container now conceptually has:
DI CONTAINER
KEY IMPLEMENTATION
────────────────────────────────────────────
LLM_PROVIDER ──────────→ OpenAIProvider
The Symbol is essentially the key.
The provider class is the value/implementation associated with that key.
Now LLMService Can Ask for That Dependency
We can write:
@Injectable()
export class LLMService {
constructor(
@Inject(LLM_PROVIDER)
private readonly provider: LLMProvider,
) {}
generate(prompt: string) {
return this.provider.generate(prompt);
}
}
This one constructor parameter actually contains two separate concepts:
@Inject(LLM_PROVIDER)
is for NestJS.
It says:
At runtime, find whatever dependency is registered under this Symbol.
While:
provider: LLMProvider
is for TypeScript.
It says:
During development/type checking, make sure this variable follows the
LLMProvidercontract.
So:
@Inject(LLM_PROVIDER)
↓
NestJS
↓
Runtime lookup
↓
LLM_PROVIDER
↓
OpenAIProvider
provider: LLMProvider
↓
TypeScript
↓
Type checking
↓
Must satisfy the LLMProvider contract
At runtime, the object stored inside provider is actually an instance of:
OpenAIProvider
Therefore:
this.provider.generate(prompt);
actually executes:
OpenAIProvider.generate(prompt);
Why a Symbol Instead of Just a String?
Technically, NestJS also allows other values such as strings to be used as injection tokens.
We could have something like:
"LLM_PROVIDER"
But strings can accidentally have the same value:
const a = "LLM_PROVIDER";
const b = "LLM_PROVIDER";
a === b; // true
Symbols are unique:
const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");
a === b; // false
That makes Symbols useful as unique dependency identifiers.
One important detail is that we must reuse the same Symbol instance.
For example:
// llm.tokens.ts
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Then import it wherever we need it:
import { LLM_PROVIDER } from "./llm.tokens";
We should not create a new:
Symbol("LLM_PROVIDER")
in every file because every call to Symbol() creates a different value.
So Where Should the Symbol Live?
A simple project structure could be:
llm/
├── llm-provider.interface.ts
├── llm.tokens.ts
├── llm.service.ts
├── llm.module.ts
└── providers/
├── openai.provider.ts
└── anthropic.provider.ts
Inside:
llm.tokens.ts
we define:
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Then llm.module.ts registers it:
@Module({
providers: [
{
provide: LLM_PROVIDER,
useClass: OpenAIProvider,
},
LLMService,
],
})
export class LLMModule {}
And llm.service.ts asks Nest for it:
@Injectable()
export class LLMService {
constructor(
@Inject(LLM_PROVIDER)
private readonly provider: LLMProvider,
) {}
}
So the complete connection becomes:
LLMProvider
TypeScript contract
↑
│ implements
│
OpenAIProvider
↑
│ registered as
│
LLM_PROVIDER Symbol
↑
│ @Inject(...)
│
LLMService
The interface tells TypeScript what the provider should look like.
The Symbol tells NestJS how to find that provider at runtime.
And OpenAIProvider contains the actual implementation.
What If I Want to Use Multiple LLM Providers?
So far, we've designed our application so that the underlying provider can be changed without changing our business logic.
For example, today I could register:
{
provide: LLM_PROVIDER,
useClass: OpenAIProvider,
}
and tomorrow change it to:
{
provide: LLM_PROVIDER,
useClass: AnthropicProvider,
}
Our LLMService doesn't have to change.
But there's another scenario.
What if I don't want to switch providers?
What if I want to use multiple providers at the same time?
For example:
Generate titles → OpenAI
Complex analysis → Anthropic
Another task → Gemini
Now I need more than one provider available inside my application.
Registering Multiple Providers
Instead of having one token:
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
I could have separate tokens:
export const OPENAI_PROVIDER = Symbol("OPENAI_PROVIDER");
export const ANTHROPIC_PROVIDER = Symbol("ANTHROPIC_PROVIDER");
And register both:
@Module({
providers: [
{
provide: OPENAI_PROVIDER,
useClass: OpenAIProvider,
},
{
provide: ANTHROPIC_PROVIDER,
useClass: AnthropicProvider,
},
LLMService,
],
})
export class LLMModule {}
Nest's DI container now conceptually has:
OPENAI_PROVIDER
↓
OpenAIProvider
ANTHROPIC_PROVIDER
↓
AnthropicProvider
Both still follow the same contract:
LLMProvider
So I can inject both:
@Injectable()
export class LLMService {
constructor(
@Inject(OPENAI_PROVIDER)
private readonly openai: LLMProvider,
@Inject(ANTHROPIC_PROVIDER)
private readonly anthropic: LLMProvider,
) {}
}
Now my application has multiple implementations of the same capability.
Which introduces another question:
How do I decide which implementation should handle a particular request?
This is where the Strategy Pattern can come into the picture.
The Strategy Pattern
Let's say both providers can generate text:
LLMProvider
generate()
/ \
↓ ↓
OpenAIProvider AnthropicProvider
But I want to choose between them depending on the task.
I could create a simple selection mechanism:
type ProviderName = "openai" | "anthropic";
@Injectable()
export class LLMService {
constructor(
@Inject(OPENAI_PROVIDER)
private readonly openai: LLMProvider,
@Inject(ANTHROPIC_PROVIDER)
private readonly anthropic: LLMProvider,
) {}
private getProvider(provider: ProviderName): LLMProvider {
if (provider === "openai") {
return this.openai;
}
return this.anthropic;
}
generate(
prompt: string,
provider: ProviderName,
) {
const selectedProvider = this.getProvider(provider);
return selectedProvider.generate(prompt);
}
}
Now:
llmService.generate(prompt, "openai");
uses:
OpenAIProvider
while:
llmService.generate(prompt, "anthropic");
uses:
AnthropicProvider
Conceptually:
LLMService
↓
Choose provider
/ \
↓ ↓
OpenAIProvider AnthropicProvider
↓ ↓
OpenAI SDK Anthropic SDK
The different providers are different strategies for performing the same operation.
That's the basic idea behind the Strategy Pattern.
Putting Everything Together
What started as:
openai.responses.create(...)
inside my business logic eventually led to:
Business Service
↓
LLMService
↓
Choose Provider
/ \
↓ ↓
OpenAIProvider AnthropicProvider
↓ ↓
OpenAI SDK Anthropic SDK
with:
LLMProvider
↓
Defines the common contract
Dependency Inversion
↓
Business logic depends on the abstraction,
not a specific provider
Dependency Injection
↓
Providers are supplied to our services
Symbols
↓
Give NestJS runtime identifiers for
interface-based dependencies
Adapter Pattern
↓
Hides differences between provider APIs
Strategy Pattern
↓
Lets us choose between provider implementations
when we actually need multiple strategies
One important thing though: having multiple providers doesn't automatically mean I need the Strategy Pattern.
If one service always uses OpenAI and another service always uses Anthropic, simply injecting the correct provider may be enough.
Strategy becomes useful when the provider itself needs to be selected dynamically based on the task, configuration, cost, fallback logic, or some other condition.
Top comments (0)