Install any skill in seconds. Free to start, no credit card required.
Get Started Free →A reference layered architecture for production LangChain 1.0 / LangGraph 1.0 services — LLM factory with version-safe defaults, chain/graph registry, retriever and tool DI, Pydantic-validated config, per-request tenant scoping, middleware ordering, checkpointer selection per environment. Use when starting a new service, refactoring a tangled chain, or onboarding a team to existing code. Trigger with "langchain architecture", "langchain llm factory", "langchain chain registry", "langchain depend
.claude/skills/jeremylongshore-langchain-reference-architecture/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 43% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 48% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 40% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 144% | 0% |
Eight months into a LangChain service, a code review surfaces the mess. Twelve chain definitions live inlined inside FastAPI route handlers. Three retrievers are constructed at module-global scope, one bound to tenant_id="acme" because that was the first tenant in the pilot — that retriever now returns Acme's documents to every other tenant, a P33 leak that has been live in production for six weeks. max_retries=6 is hardcoded at four separate call sites. A RunnableWithMessageHistory backed by the default InMemoryChatMessageHistory loses every conversation on pod restart (P22) — which is most days, because Cloud Run scales to zero. Config is read from os.environ in three modules with three different fallback strategies. There is no place to put a new provider without touching seven files, and nobody remembers why the retriever is built at import time.
The fix is not "rename a variable." The fix is an architecture that made every one of those mistakes hard to write. This skill is the target layered architecture:
app/ — FastAPI routes. Thin. Parses HTTP, calls into services,serializes response. No chain logic, no vendor clients, no env vars.
services/ — chain and graph definitions. Take dependencies throughconstructor args, not module-level imports.
adapters/ — vendor clients, LLM factory, retriever factory, toolfactory. This is where langchain-anthropic is imported. Nowhere else.
config/ — one Pydantic Settings class. SecretStr for keys,Literal["dev","staging","prod"] for env names, .env file loader.
domain/ — Pydantic models, typed LangGraph state, enums. No I/O.Five layers, five imports deep at most. Dependency direction is strictly downward. app imports services; services imports adapters; adapters imports config and domain. Never the reverse. Import-linter enforces this in CI. Pain-catalog anchors: P22 (in-memory history loses messages — architectural fix is persistent history injected via DI) and P33 (per-tenant vector stores leak if retriever bound at import — architectural fix is per-request factory). Adjacent: P10 (recursion limits), P24 (middleware order), P28 (callback inheritance). Pin: langchain-core 1.0.x, langgraph 1.0.x, langchain-anthropic 1.0.x, langchain-openai 1.0.x, pydantic 2.x, import-linter 2.x.
langchain-core >= 1.0, < 2.0, langgraph >= 1.0, < 2.0pydantic >= 2.5 and pydantic-settings >= 2.1import-linter >= 2.0 for layer enforcement in CIlangchain-anthropic, langchain-openai, etc.langgraph-checkpoint-postgres and a Postgres instancelangchain-model-inference for the LLM factory's version-safe defaultssrc/my_service/
├── app/ # Layer 1: HTTP boundary (FastAPI)
│ ├── __init__.py
│ ├── main.py # FastAPI instance, DI wiring, lifespan
│ ├── routes/
│ │ ├── support.py # POST /support → services.support.run(...)
│ │ └── health.py
│ └── deps.py # FastAPI Depends() providers
├── services/ # Layer 2: chain and graph definitions
│ ├── __init__.py
│ ├── registry.py # name → builder lookup
│ ├── support/
│ │ ├── chain.py # SupportChain(llm, retriever, memory)
│ │ └── graph.py # SupportGraph (LangGraph StateGraph)
│ └── triage/
│ └── chain.py
├── adapters/ # Layer 3: vendor integrations
│ ├── __init__.py
│ ├── llm_factory.py # chat_model(provider, **kwargs) → BaseChatModel
│ ├── retriever_factory.py # retriever_for(tenant_id) → Retriever
│ ├── tool_factory.py # tools_for(tenant_id) → list[BaseTool]
│ ├── checkpointer.py # checkpointer_for(env) → BaseCheckpointSaver
│ └── history.py # history_for(session_id, tenant_id) → BaseChatMessageHistory
├── config/ # Layer 4: configuration
│ ├── __init__.py
│ └── settings.py # Pydantic Settings
└── domain/ # Layer 5: pure models, no I/O
├── __init__.py
├── state.py # TypedDict / Pydantic for LangGraph state
└── models.py # request/response schemas
tests/
├── unit/ # fake adapters, assert service logic
├── integration/ # real adapters against ephemeral infra
└── contract/ # schema snapshots (e.g., tool specs)
pyproject.toml # includes [tool.importlinter] contractsTypical depth is 5 layers. See Directory Layout for the full tree with file-naming conventions.
adapters/llm_factory.pyChains depend on the BaseChatModel protocol, not a concrete class. The factory is the one place version-safe defaults live:
python# src/my_service/adapters/llm_factory.py from langchain_core.language_models import BaseChatModel from langchain_anthropic import ChatAnthropic from langchain_openai import ChatOpenAI _SAFE_DEFAULTS = {"timeout": 30, "max_retries": 2} def chat_model(provider: str, **overrides) -> BaseChatModel: defaults = {**_SAFE_DEFAULTS, **overrides} # caller wins if provider == "anthropic": return ChatAnthropic(model="claude-sonnet-4-6", **defaults) if provider == "openai": return ChatOpenAI(model="gpt-4o", **defaults) raise ValueError(f"Unknown provider: {provider!r}")
The max_retries=6 scatter in the mess-case becomes max_retries=2 in exactly one file. Services that want a longer timeout pass timeout=60 — but they never set max_retries=6 by accident. Cross-reference langchain-model-inference Step 3 for the factory pattern's provenance; see LLM Factory Pattern for per-provider variants and caching.
python# src/my_service/services/registry.py from typing import Callable, Protocol from langchain_core.runnables import Runnable class ChainBuilder(Protocol): def __call__(self, *, tenant_id: str) -> Runnable: ... _BUILDERS: dict[str, ChainBuilder] = {} def register(name: str): def decorator(fn: ChainBuilder) -> ChainBuilder: _BUILDERS[name] = fn return fn return decorator def get(name: str, *, tenant_id: str) -> Runnable: try: return _BUILDERSname except KeyError: raise KeyError(f"No chain registered under {name!r}. Known: {list(_BUILDERS)}")
Each service module registers itself:
python# src/my_service/services/support/chain.py from my_service.services.registry import register from my_service.adapters.llm_factory import chat_model from my_service.adapters.retriever_factory import retriever_for @register("support_agent") def build_support_agent(*, tenant_id: str): llm = chat_model("anthropic") retriever = retriever_for(tenant_id=tenant_id) # ... compose chain ... return chain
Routes become one line: chain = registry.get("support_agent", tenant=req.tenant_id). There is one place to look, not twelve.
This is the P33 architectural fix. The factory takes tenant_id as a runtime argument. Nothing is bound at import:
python# src/my_service/adapters/retriever_factory.py from functools import lru_cache from langchain_core.retrievers import BaseRetriever from langchain_pinecone import PineconeVectorStore from my_service.config.settings import get_settings @lru_cache(maxsize=256) # cache the *store*, not the retriever def _store_for(tenant_id: str) -> PineconeVectorStore: s = get_settings() return PineconeVectorStore( index_name=s.pinecone_index, namespace=f"tenant:{tenant_id}", # per-tenant namespace embedding=..., ) def retriever_for(*, tenant_id: str, k: int = 6) -> BaseRetriever: # Retriever construction <5ms because store is cached — do it per-request. return _store_for(tenant_id).as_retriever(search_kwargs={"k": k})
The retriever is cheap to build (<5ms typical) so per-request construction is fine. Unit test with two tenants and assert non-overlap. See Dependency Rules for the import-linter contract that forbids services/*.py from importing langchain_pinecone directly.
Settingspython# src/my_service/config/settings.py from functools import lru_cache from typing import Literal from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_prefix="MYSVC_") env: Literal["dev", "staging", "prod"] = "dev" anthropic_api_key: SecretStr openai_api_key: SecretStr pinecone_api_key: SecretStr pinecone_index: str postgres_dsn: SecretStr | None = None # required when env != "dev" @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings() # reads env/.env at first call, caches
SecretStr prevents keys from leaking into logs. Literal[...] catches typos (env="staing") at validation time, not at deploy time.
Middleware order is a correctness concern (P24 — redaction before caching, or cached responses leak PII across tenants). Wire the stack once in adapters/ and hand the composed runnable to every service:
python# src/my_service/adapters/middleware.py from langchain_core.runnables import Runnable def wrap(model: Runnable) -> Runnable: # Order matters: redact -> cache -> retry -> model # Cross-reference L31 (langchain-middleware-patterns) for the full rationale. return ( model .with_config(tags=["mysvc"]) # | redaction_middleware() # | cache_middleware() # | retry_middleware() )
Cross-reference langchain-middleware-patterns (L31) for the middleware stack rationale and P25 (retry double-counting tokens).
This is the P22 architectural fix. MemorySaver is fine for dev; it is not an option for staging or prod:
python# src/my_service/adapters/checkpointer.py from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.checkpoint.memory import MemorySaver def checkpointer_for(env: str) -> BaseCheckpointSaver: if env == "dev": return MemorySaver() # Staging/prod: Postgres-backed. Async variant for FastAPI. from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from my_service.config.settings import get_settings dsn = get_settings().postgres_dsn assert dsn is not None, "POSTGRES_DSN required outside dev" return AsyncPostgresSaver.from_conn_string(dsn.get_secret_value())
Same for chat history when you use RunnableWithMessageHistory instead of a graph: InMemoryChatMessageHistory in dev, PostgresChatMessageHistory or RedisChatMessageHistory in staging/prod. See Per-Env Checkpointer for the MemorySaver / SqliteSaver / PostgresSaver / AsyncPostgresSaver decision matrix and the migration script between them. Cross-reference langchain-langgraph-checkpointing (L27) for checkpoint schema details.
The factory boundary is also the fake boundary. Unit tests inject a FakeListChatModel where production injects ChatAnthropic:
python# tests/unit/test_support_chain.py from langchain_core.language_models.fake_chat_models import FakeListChatModel from my_service.services.support.chain import build_support_agent def test_support_agent_returns_expected_shape(monkeypatch): monkeypatch.setattr( "my_service.services.support.chain.chat_model", lambda provider, **kw: FakeListChatModel(responses=["fixed answer"]), ) chain = build_support_agent(tenant_id="acme") assert chain.invoke({"input": "hi"}).content == "fixed answer"
Integration tests use the real adapters against ephemeral Postgres and a sandbox Pinecone namespace. Contract tests snapshot tool JSON schemas so a silent bind_tools change fails CI.
toml# pyproject.toml [tool.importlinter] root_package = "my_service" [[tool.importlinter.contracts]] name = "Layered architecture" type = "layers" layers = [ "my_service.app", "my_service.services", "my_service.adapters", "my_service.config", "my_service.domain", ] [[tool.importlinter.contracts]] name = "Services do not import vendor SDKs" type = "forbidden" source_modules = ["my_service.services"] forbidden_modules = [ "langchain_anthropic", "langchain_openai", "langchain_pinecone", ]
CI runs lint-imports. A PR that puts from langchain_anthropic import ChatAnthropic inside services/support/chain.py fails — forcing the author to go through adapters/llm_factory.chat_model("anthropic") instead.
app / services / adapters / config / domainadapters/llm_factory.py as the single source of version-safe defaultsservices/registry.py with register(...) / get(name, tenant=...) lookuptenant_id (P33 closed)Settings with SecretStr keys and Literal[...] env namesadaptersMemorySaver dev, AsyncPostgresSaver staging/prod (P22 closed)import-linter contracts enforced in CI| Error | Cause | Fix | |-------|-------|-----| | KeyError: "No chain registered under 'support_agent'" | Registry imported before service module registered | Import services.support.chain from services/__init__.py or app.main startup | | Retriever returns wrong tenant's documents (P33) | Retriever bound at module-import scope with hardcoded tenant | Construct retriever_for(tenant_id=...) per request; retriever build <5ms with cached store | | Chat history empty after pod restart (P22) | RunnableWithMessageHistory backed by InMemoryChatMessageHistory in staging/prod | Switch to PostgresChatMessageHistory / RedisChatMessageHistory via history_for(env=...) factory | | pydantic.ValidationError on env="staing" typo | Literal["dev","staging","prod"] caught at Settings init | Fix env var before deploy; this is the intended behavior | | import-linter failure services imports langchain_anthropic | Vendor SDK imported in services layer | Route through adapters.llm_factory.chat_model("anthropic") | | GraphRecursionError on vague prompts (P10) | create_react_agent default recursion_limit=25 | Set recursion_limit=5-10 at graph compile time in the service | | Cached response contains another tenant's PII (P24) | Middleware order was cache before redaction | Compose in adapters/middleware.py as redact → cache → model | | Subgraph traces missing (P28) | Parent callbacks not inherited into subgraphs | Pass config={"callbacks": [...]} explicitly when invoking subgraph | | AssertionError: POSTGRES_DSN required outside dev | Settings.postgres_dsn None in staging | Fail fast at startup; do not fall back to MemorySaver silently |
Because retrievers are built per request from tenant_id, onboarding a new tenant is a data concern (create Pinecone namespace, seed documents), not a code concern. No file in services/ changes. No redeploy is required to add tenant_id="zeta".
adapters/llm_factory.py grows one elif branch. config/settings.py grows one SecretStr field. No service module changes — they all depend on BaseChatModel, not ChatAnthropic. Cross-reference langchain-model-inference for the list of provider packages and their 1.0 import paths.
The migration is layer by layer, bottom up:
config/settings.py first — it has no dependencies and unlocks the restadapters/llm_factory.py and replace scattered ChatAnthropic(...) callsadapters/retriever_factory.py with tenant_id as a runtime arg — this is the P33 fixservices/registry.py and move one chain at a time from routes into registered buildersimport-linter in CI with ignore_imports for routes that have not migrated yet; remove ignores as you goMemorySaver for AsyncPostgresSaver in staging last — it is the lowest-risk step once factories existdocs/pain-catalog.md (entries P10, P22, P24, P28, P33)plugins/saas-packs/langchain-py-pack/skills/ directory):langchain-model-inference — LLM factory defaults provenancelangchain-embeddings-search — retriever and vector-store selectionlangchain-sdk-patterns — composition patterns referenced by service builders| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 37,961 | 29,564 | -22% | 1 | 1 | 0% | 6,996 | 10,016 | +43% | 0 | 0 | — |
case-02 | fail→pass | 42,113 | 34,401 | -18% | 1 | 1 | 0% | 7,605 | 11,239 | +48% | 0 | 0 | — |
case-03 | fail→pass | 43,820 | 39,588 | -10% | 1 | 1 | 0% | 7,504 | 10,542 | +40% | 0 | 0 | — |
case-04 | fail→pass | 20,649 | 13,895 | -33% | 1 | 1 | 0% | 2,976 | 6,526 | +119% | 0 | 0 | — |
case-05 | fail→pass | 22,095 | 21,421 | -3% | 1 | 1 | 0% | 3,313 | 8,089 | +144% | 0 | 0 | — |
case-06 | fail→pass | 21,543 | 25,596 | +19% | 1 | 1 | 0% | 3,261 | 7,943 | +144% | 0 | 0 | — |
case-07 | fail→pass | 20,613 | 14,733 | -29% | 1 | 1 | 0% | 2,925 | 7,572 | +159% | 0 | 0 | — |
case-08 | fail→pass | 15,824 | 12,005 | -24% | 1 | 1 | 0% | 2,962 | 6,634 | +124% | 0 | 0 | — |
case-09 | fail→pass | 21,256 | 16,793 | -21% | 1 | 1 | 0% | 2,782 | 7,133 | +156% | 0 | 0 | — |
case-10 | fail→pass | 18,216 | 16,165 | -11% | 1 | 1 | 0% | 2,979 | 6,411 | +115% | 0 | 0 | — |
case-11 | fail→pass | 17,405 | 10,728 | -38% | 1 | 1 | 0% | 2,205 | 6,839 | +210% | 0 | 0 | — |
case-17 | fail→fail | 16,098 | 18,064 | +12% | 1 | 1 | 0% | 2,641 | 7,349 | +178% | 0 | 0 | — |
case-12 | fail→fail | 10,356 | 11,168 | +8% | 1 | 1 | 0% | 971 | 5,745 | +492% | 0 | 0 | — |
case-13 | pass→fail | 17,187 | 14,501 | -16% | 1 | 1 | 0% | 2,474 | 7,375 | +198% | 0 | 0 | — |
case-14 | fail→pass | 23,632 | 30,007 | +27% | 1 | 1 | 0% | 3,401 | 10,343 | +204% | 0 | 0 | — |
case-15 | fail→pass | 25,142 | 22,461 | -11% | 1 | 1 | 0% | 2,961 | 8,182 | +176% | 0 | 0 | — |
case-16 | pass→pass | 21,424 | 25,585 | +19% | 1 | 1 | 0% | 2,873 | 7,800 | +171% | 0 | 0 | — |
case-18 | pass→pass | 12,940 | 10,899 | -16% | 1 | 1 | 0% | 1,966 | 5,836 | +197% | 0 | 0 | — |
case-19 | fail→pass | 19,684 | 16,516 | -16% | 1 | 1 | 0% | 2,792 | 6,874 | +146% | 0 | 0 | — |
case-20 | pass→pass | 21,393 | 23,338 | +9% | 1 | 1 | 0% | 2,583 | 8,210 | +218% | 0 | 0 | — |
case-21 | pass→pass | 10,722 | 17,062 | +59% | 1 | 1 | 0% | 2,015 | 7,103 | +253% | 0 | 0 | — |
case-22 | fail→pass | 21,535 | 20,921 | -3% | 1 | 1 | 0% | 2,840 | 8,322 | +193% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +64 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.