DEV Community

Yusuf Öztoprak
Yusuf Öztoprak

Posted on Originally published at yusufoztoprak.hashnode.dev

Swapping OpenAI for a Fake Embedder With One Environment Variable

There is a standard story about design patterns. You learn them from a catalogue, you memorize the diagrams, and later, in an interview, someone asks you to explain the Factory Method and you draw boxes labelled Creator and ConcreteProduct. The story is not exactly false. But it hides the more interesting question, which is not what a pattern is but what problem in your own code it actually removes. If you cannot answer that, you have learned a vocabulary, not a technique.

This week I tried to answer it for my own project. I am building rag-service, a retrieval-augmented generation API in NestJS with PostgreSQL and pgvector. I had just read the creational patterns on refactoring.guru, written the toy examples, and felt reasonably confident. Then I tried to apply one of them to real code, and it took me much longer than the toy version did. That gap is what this post is about.

The problem, stated plainly

Here is what my embedding code looked like:

@Injectable()
export class EmbeddingService {
  private readonly openai: OpenAI;

  constructor(private readonly configService: ConfigService) {
    this.openai = new OpenAI({
      apiKey: this.configService.get<string>('OPENAI_API_KEY'),
    });
  }

  async embed(text: string): Promise<number[]> {
    const response = await this.openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: text,
    });
    return response.data[0].embedding;
  }
}
Enter fullscreen mode Exit fullscreen mode

Nothing is wrong with this code in isolation. It works. The issue appears only when you look at who depends on it. Both DocumentService (which ingests documents) and SearchService (which answers questions) import this concrete class directly. So every time I ingest a test document during development, I pay OpenAI. Without an API key, the app cannot do anything useful at all. And if I ever want to try a local embedding model, I have to open both services and change them.

Notice that none of these are hypothetical. The first one costs me money every day. This is the "new Truck() everywhere" problem from the textbook, except the truck is a paid API.

Step 1: say what you need, not who provides it

The first move is to describe what the services actually need from an embedder. Not a name, not a model, just a capability: give me a text, I give you a vector.

// embedding-provider.ts
export interface EmbeddingProvider {
  embed(text: string): Promise<number[]>;
}

export const EMBEDDING_PROVIDER = 'EMBEDDING_PROVIDER';
Enter fullscreen mode Exit fullscreen mode

My first attempt at this interface had name and description fields. That was a mistake worth noting, because I think it is a common one. An interface should describe behaviour, what the thing does, not properties, what the thing is. The services never ask the embedder for its name. They ask it for vectors.

The second line, the string constant, looks strange at first. Why do we need it? I will come back to this, because it turned out to be the most instructive part of the whole exercise.

Step 2: two implementations

The real one is the old service, moved almost unchanged:

// openai-embedding.provider.ts
export class OpenAIEmbeddingProvider implements EmbeddingProvider {
  private readonly openai: OpenAI;

  constructor(apiKey: string) {
    this.openai = new OpenAI({ apiKey });
  }

  async embed(text: string): Promise<number[]> {
    const response = await this.openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: text,
    });
    return response.data[0].embedding;
  }
}
Enter fullscreen mode Exit fullscreen mode

One change: the constructor takes a plain apiKey string instead of ConfigService. The provider no longer knows that NestJS exists. Reading configuration is somebody else's job, and as a side effect the class becomes trivial to construct in a test.

The fake one is a single line:

// fake-embedding.provider.ts
export class FakeEmbeddingProvider implements EmbeddingProvider {
  async embed(text: string): Promise<number[]> {
    return new Array(1536).fill(0.01);
  }
}
Enter fullscreen mode Exit fullscreen mode

That line hides two decisions, and both came from mistakes. More on that below.

Step 3: the factory

Now something has to decide which implementation to use. In NestJS, that place is the module, through useFactory:

// embedding.module.ts
@Module({
  providers: [
    {
      provide: EMBEDDING_PROVIDER,
      inject: [ConfigService],
      useFactory: (config: ConfigService): EmbeddingProvider => {
        if (config.get<string>('EMBEDDING_PROVIDER') === 'fake') {
          return new FakeEmbeddingProvider();
        }
        return new OpenAIEmbeddingProvider(
          config.getOrThrow<string>('OPENAI_API_KEY'),
        );
      },
    },
  ],
  exports: [EMBEDDING_PROVIDER],
})
export class EmbeddingModule {}
Enter fullscreen mode Exit fullscreen mode

I used getOrThrow for the API key on purpose. config.get returns undefined silently when a variable is missing, which means a typo in .env shows up much later as a confusing error from OpenAI. getOrThrow fails at startup, where the cause is obvious.

The services now ask for the token, not the class:

constructor(
  @Inject(PG_POOL) private readonly pool: Pool,
  @Inject(EMBEDDING_PROVIDER) private readonly embedder: EmbeddingProvider,
) {}
Enter fullscreen mode Exit fullscreen mode

After this change, the word OpenAI does not appear anywhere in DocumentService or SearchService. Setting EMBEDDING_PROVIDER=fake in .env makes the whole ingestion pipeline run without a single network call. No code changes, only configuration.

A note on honesty: this is not the Factory Method

I should be careful about naming here, because it is easy to inflate what one has done. The GoF Factory Method relies on inheritance: an abstract creator declares a factory method, and subclasses override it. What I wrote above has no inheritance at all. It is one function with an if. This is usually called a Simple Factory.

I wrote the inheritance version too, as an exercise, and it works. But in a framework with a dependency injection container, the container already does the job that subclassing was invented for. So the simpler form is the one you will actually meet in backend code. I think it is more useful to know why the two differ than to pretend the simpler one is the textbook one.

Incidentally, the same file that holds my database pool uses exactly this shape:

{
  provide: PG_POOL,
  inject: [ConfigService],
  useFactory: (config: ConfigService) =>
    new Pool({ connectionString: config.get<string>('DATABASE_URL') }),
}
Enter fullscreen mode Exit fullscreen mode

Nest runs that factory once and hands every service the same Pool. That is a Singleton, managed by the container rather than written by hand. I had been using two patterns for weeks without noticing.

Three things the tutorials did not mention

1. Interfaces do not exist at runtime

Back to the string constant. In a toy example you write new FakeIngestion() and everything is visible. In NestJS, dependencies are resolved at runtime, and TypeScript interfaces are erased when the code is compiled to JavaScript. The container cannot look up EmbeddingProvider because, at runtime, there is no such thing. A string token is the only handle that survives compilation.

I had already met this idea in a different form. In my Singleton exercise, I marked the constructor private and then called new AppConfig() anyway, to see the error. With plain node, which strips types without checking them, it ran without complaint and created a second instance. TypeScript's private is a compile-time promise, not a runtime wall. It is the same lesson twice: what the type system says and what the program does are two different things.

2. TS1272

Once I injected the interface, the compiler stopped with this:

TS1272: A type referenced in a decorated signature must be imported with
'import type' or a namespace import when 'isolatedModules' and
'emitDecoratorMetadata' are enabled.
Enter fullscreen mode Exit fullscreen mode

It is the same issue again, from another angle. Nest asks the compiler to emit type metadata for decorated constructor parameters. For a class that works, because the class exists at runtime. For an interface there is nothing to emit, so TypeScript asks you to state explicitly that this import is only a type:

import { type EmbeddingProvider, EMBEDDING_PROVIDER } from '../embeddings/embedding-provider';
Enter fullscreen mode Exit fullscreen mode

type applies only to the interface. The token is a real value and stays a normal import.

3. The fake vector has to respect the database

My first fake embedder returned three numbers, as in my exercise. It compiled. It would have failed on the first insert, because the column is defined as VECTOR(1536) and pgvector rejects anything of another dimension. The type number[] says nothing about length, so the compiler cannot help you here. The database schema is part of the contract, even if it is not written in TypeScript.

My second attempt filled the array with zeros, which is also wrong in a quieter way. Cosine distance divides by the length of the vector, and a zero vector has length zero, so the distance is undefined. Hence 0.01.

One more caveat, so nobody misreads what the fake is for. Every fake vector is identical, so every chunk is equally "similar" to every question. The fake lets you test that the pipeline runs: chunking, inserting, querying, error handling. It tells you nothing about retrieval quality. And if you ingest documents with the fake and later switch back to OpenAI, those rows still hold fake vectors and should be re-embedded.

What I take from this

The pattern itself was the easy part. I understood the diagram in an afternoon. What took time was everything around it: the runtime model of the framework, the gap between types and values, a constraint that lives in SQL rather than in TypeScript. None of that is in the catalogue, and all of it is what an actual codebase forces you to confront.

So when someone asks me now what the Factory pattern is for, I hope I will not draw boxes. I will say: it is what let me stop paying for API calls I did not need, and it taught me that interfaces disappear at runtime. That answer is less elegant, but I think it is more true.


A note how this was written: the refactor, the code and every mistake described here are mine.I used Claude as a writing assistant to draft the post from my notes and code, then reviewed and edited every line.

The code is on GitHub: YusufOztoprak/rag-service. My weekly study notes, including the exercise versions of Factory Method, Singleton and Builder, are in learning-notes.

Top comments (1)

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

The caveat about fake vectors staying in the database is worth keeping prominent. Do you use a separate development database for that mode, or a reset step before switching providers?