Adapter Pattern for Multi-Cloud Portability

Abstract base classes with minimal interfaces let the same RAG pipeline run on four different cloud providers without conditional logic in business code.

Tags

Adapter Pattern for Multi-Cloud Portability

The Lesson

When building an application that must run on multiple cloud providers, define the narrowest possible abstract interface for each external dependency, then inject the concrete implementation at startup. The business logic should never import, reference, or branch on any specific provider. The adapter boundary is the only place cloud-specific code lives.

Context

A RAG chatbot needed two external capabilities: embedding/chat (LLM) and similarity search (vector store). The system had to run locally during development (Ollama + ChromaDB, free, no credentials) and on three cloud providers in production (AWS Bedrock + OpenSearch, Azure OpenAI + AI Search, GCP Vertex AI + Vector Search). Four LLM implementations and four vector store implementations — 16 possible combinations, though only 4 are used in practice.

What Happened

  1. Defined LLMAdapter as an abstract base class with two methods: embed(texts) -> list[list[float]] and chat(messages) -> str. No other methods. This was the smallest interface that covered all usage in the RAG pipeline.
  2. Defined VectorAdapter with four methods: index_chunks(chunks, embeddings) -> int, query(embedding, top_k, filters) -> list[dict], delete_collection(), and count() -> int.
  3. Built the Retriever and Generator classes to accept these abstract types via constructor injection. Neither class imports any concrete adapter — only from app.adapters.llm.base import LLMAdapter.
  4. Each cloud adapter handles provider-specific quirks internally. Bedrock separates system messages from chat messages (its API requires it). Vertex AI concatenates messages into a single prompt string for Gemini's generate_content(). Azure OpenAI uses the standard OpenAI client with an Azure endpoint. None of this leaks into the RAG pipeline.
  5. ChromaDB returns distances (lower = more similar); the adapter converts to similarity scores (1.0 - distance). OpenSearch returns scores directly. The query() return format is identical regardless.
  6. The dependency injection point (_deps.py) reads a DEPLOYMENT_PROFILE environment variable and instantiates the correct adapter pair. This is the only file that knows which concrete adapters exist.

Key Insights

Examples

Business logic sees only the interface:

class Retriever:
    def __init__(self, vector: VectorAdapter, llm: LLMAdapter):
        self._vector = vector
        self._llm = llm

    def retrieve(self, query: str, top_k: int = 8) -> list[dict]:
        embedding = self._llm.embed([query])[0]
        return self._vector.query(embedding, top_k=top_k)

Adapter selection is one place, one lookup:

ADAPTERS = {
    "local": (OllamaAdapter, ChromaDBAdapter),
    "aws":   (BedrockAdapter, OpenSearchAdapter),
    "azure": (AzureOpenAIAdapter, AzureSearchAdapter),
    "gcp":   (VertexAIAdapter, VertexVectorSearchAdapter),
}
profile = os.getenv("DEPLOYMENT_PROFILE", "local")
LLMClass, VectorClass = ADAPTERS[profile]

Applicability

This pattern works when:

It does not work well when:

Related Lessons

Related Lessons