The Registry Pattern
I keep reaching for the same pattern lately. Different projects, different domains, but the same shape: a lookup table of handlers, keyed by some discriminator, all implementing the same interface. It's called the registry pattern, and once you see it, you see it everywhere.
This isn't a new pattern. It's been around for decades. But I've found it especially useful in two contexts that are increasingly common: systems with multiple notification channels, and AI features that need to handle diverse tool calls. Both are cases where you have a single entry point but many possible behaviors.
The Shape of the Problem
Consider a notification service. You need to send messages through email, SMS, push notifications, maybe Slack. The business logic doesn't care which channel is used. It just knows "notify this user about this event."
The naive approach is a switch statement:
const sendNotification = async (userId: string, message: string, channel: string) => {
switch (channel) {
case "email":
await sendEmail(userId, message);
break;
case "sms":
await sendSMS(userId, message);
break;
case "push":
await sendPush(userId, message);
break;
default:
throw new Error(`Unknown channel: ${channel}`);
}
};This works until it doesn't. Add a new channel and you're modifying the core notification logic. The switch grows. The file becomes a dumping ground for transport-specific code. Testing requires mocking everything because it's all tangled together.
The registry pattern inverts this. Instead of the notification service knowing about every transport, transports register themselves with a central lookup.
The Pattern
A registry is fundamentally simple: a map from keys to handlers, where all handlers implement the same interface.
interface ITransport {
send(userId: string, message: string): Promise<Result<void, TransportError>>;
}
type TransportRegistry = Map<string, ITransport>;That's it. The complexity comes from how you use it.
class NotificationService {
constructor(private readonly transports: TransportRegistry) {}
notify = async (userId: string, message: string, channel: string) => {
const transport = this.transports.get(channel);
if (!transport) {
return err({ type: "UnknownChannel", message: `No transport for ${channel}` });
}
return transport.send(userId, message);
};
}The registry holds references to handlers. Consumers look up implementations by key without knowing which concrete handler they'll get.
The notification service doesn't import EmailTransport. It doesn't know about Twilio or Firebase. It just asks the registry "give me the thing that handles this channel" and calls the standard interface.
Adding a new channel means implementing ITransport and registering it. The notification service never changes.
Not Service Locator
This is where people get confused. The registry pattern is not the service locator pattern. They look similar, both involve looking things up, but they solve different problems.
Service locator is a dependency injection anti-pattern. It's a global container that holds all your application's dependencies. Components reach into it to get whatever they need:
// Service Locator (don't do this)
class UserController {
createUser = async (req: Request) => {
const userService = ServiceLocator.get<IUserService>("userService");
const logger = ServiceLocator.get<ILogger>("logger");
// ...
};
}Service Locator: a global lookup for dependencies. Everything reaches into the same box for different things.
The problems are well documented. Dependencies are hidden. Testing requires global mocks. The locator becomes a god object that everything depends on. It's the anti-pattern that dependency injection frameworks exist to prevent.
The registry pattern is narrower. It's not replacing your DI. It's solving a specific problem: routing to one of many implementations of the same interface based on runtime data.
| Aspect | Registry Pattern | Service Locator |
|---|---|---|
| Purpose | Route to one of many implementations of the same interface | Resolve dependencies of different types |
| Scope | Single concern (transports, tools, handlers) | Entire application's dependencies |
| Type Safety | Strong - all handlers share the same interface | Weak - returns arbitrary types |
| Dependencies | Injected into consumers | Consumers reach into global state |
| Testability | Easy - inject mock registry | Hard - global state must be mocked |
The key differences:
-
Scope: A registry handles one family of handlers (transports, tools, parsers). Service locator handles all dependencies.
-
Interface uniformity: Registry handlers all implement the same interface. Service locator returns arbitrary types.
-
Injection: The registry itself is injected as a dependency. Service locator is typically global.
-
Runtime vs compile-time: Registry lookups happen at runtime based on data. DI wiring happens at startup.
You still use constructor injection. You still define interfaces. The registry is just another dependency that gets injected.
// Composition root - still normal DI
const emailTransport = new EmailTransport(smtpClient);
const smsTransport = new SMSTransport(twilioClient);
const pushTransport = new PushTransport(firebaseClient);
const transports = new Map<string, ITransport>([
["email", emailTransport],
["sms", smsTransport],
["push", pushTransport],
]);
const notificationService = new NotificationService(transports);Where This Shines: AI Tool Execution
The pattern really clicks when you're building AI features with tool use. The LLM produces structured output: "call the search_files tool with these arguments." Your code needs to route that to the right handler.
interface ITool {
name: string;
execute(args: unknown): Promise<Result<ToolResult, ToolError>>;
}
type ToolRegistry = Map<string, ITool>;
class AIExecutor {
constructor(private readonly tools: ToolRegistry) {}
executeTool = async (toolCall: ToolCall) => {
const tool = this.tools.get(toolCall.name);
if (!tool) {
return err({ type: "UnknownTool", message: `No handler for ${toolCall.name}` });
}
return tool.execute(toolCall.arguments);
};
}AI tool execution: the LLM produces tool calls, the registry routes them to the correct handler.
This is the same shape as the notification service. Different domain, same pattern. The AI executor doesn't know about file systems or test runners or database queries. It just looks up the tool and calls it.
Adding a new tool is trivial. Implement ITool, register it, done. The executor doesn't change. The LLM's tool definitions don't change (well, they do, but that's a separate concern). The routing logic is completely decoupled from the tool implementations.
I've used this exact pattern for:
- Different entity types an AI can create (components, workflows, connections)
- Different actions an AI can take on a canvas (add, move, delete, connect)
- Different parsers for file types in a code analysis tool
- Different validators for form field types
Any time you have "do X based on type Y," consider a registry.
The Interface Contract
Here's the catch: all handlers must satisfy the same interface.
This is both the pattern's strength and its limitation. The uniform interface means the consumer doesn't need to know which handler it's calling. But it also means every handler must fit into that shape.
interface ITransport {
send(userId: string, message: string): Promise<Result<void, TransportError>>;
}What if email needs attachments? What if SMS has character limits? What if push notifications need device tokens?
You have options:
Option 1: Widen the interface
interface ITransport {
send(
userId: string,
message: string,
options?: TransportOptions
): Promise<Result<void, TransportError>>;
}
type TransportOptions = {
attachments?: Attachment[];
priority?: "high" | "normal" | "low";
// ... other transport-specific options
};This works but the options object becomes a grab-bag. Handlers ignore options that don't apply to them. Type safety erodes.
Option 2: Move complexity to the message
type Notification = {
userId: string;
title: string;
body: string;
data?: Record<string, unknown>;
};
interface ITransport {
send(notification: Notification): Promise<Result<void, TransportError>>;
}Each transport extracts what it needs from the notification object. The email transport looks for data.attachments. The push transport looks for data.action. The contract is simpler but less explicit.
Option 3: Pre-process before the registry
const notify = async (event: NotificationEvent) => {
const notification = NotificationBuilder.fromEvent(event);
const channels = NotificationRouter.getChannels(event.user, event.type);
for (const channel of channels) {
const transport = this.transports.get(channel);
await transport?.send(notification);
}
};The registry stays simple. Complexity moves to the builder and router, which can have richer logic about what each transport needs.
None of these are perfect. The interface is a constraint you choose to live with. The benefit of uniform dispatch has to outweigh the cost of fitting everything into one shape.
Registration Strategies
There are a few ways to populate the registry.
Manual registration at the composition root:
const registry = new Map<string, ITransport>();
registry.set("email", new EmailTransport(smtpClient));
registry.set("sms", new SMSTransport(twilioClient));Simple, explicit, boring. My preference for most cases.
Self-registration where handlers register themselves:
// EmailTransport.ts
export class EmailTransport implements ITransport {
static readonly key = "email";
// ...
}
// registry.ts
import { EmailTransport } from "./EmailTransport";
import { SMSTransport } from "./SMSTransport";
export const createTransportRegistry = (deps: TransportDeps) => {
const registry = new Map<string, ITransport>();
registry.set(EmailTransport.key, new EmailTransport(deps.smtp));
registry.set(SMSTransport.key, new SMSTransport(deps.twilio));
return registry;
};Still explicit, but the key lives with the implementation. Harder to have mismatches.
Decorator-based registration (common in frameworks):
@Transport("email")
export class EmailTransport implements ITransport {
// ...
}Magic. The decorator registers the class somewhere global. Framework-specific. I avoid this, it hides what's happening.
Plugin-based registration for true extensibility:
interface ITransportPlugin {
key: string;
create(deps: TransportDeps): ITransport;
}
const loadPlugins = async (pluginPaths: string[]) => {
const registry = new Map<string, ITransport>();
for (const path of pluginPaths) {
const plugin: ITransportPlugin = await import(path);
registry.set(plugin.key, plugin.create(deps));
}
return registry;
};This is for when you actually need runtime extensibility. New transports can be added without recompiling. Most applications don't need this.
The Downsides
It's not all upside.
Indirection: There's now a layer between the caller and the implementation. Stack traces are longer. Debugging requires understanding the registry lookup. For simple cases, a switch statement is more obvious.
Interface rigidity: I mentioned this, but it's worth repeating. Every handler must fit the same shape. If your handlers are genuinely different, forcing them into one interface creates awkward abstractions.
No framework support: Unlike dependency injection, there's no standard library for registries. No automatic wiring. No lifecycle management. You build it yourself.
Type erosion: The registry key is often a string. TypeScript can help with union types, but runtime lookups can still fail:
type TransportKey = "email" | "sms" | "push";
const registry = new Map<TransportKey, ITransport>();
// This is fine
registry.get("email");
// This compiles but returns undefined
registry.get("email" as TransportKey); // if "email" isn't actually registeredYou can add runtime checks, but it's another thing to get wrong.
Testing complexity: While the pattern makes testing easier (inject a mock registry), you now need to test the registry wiring itself. Did every handler get registered? Are the keys correct?
describe("TransportRegistry", () => {
it("should have all expected transports", () => {
const registry = createTransportRegistry(mockDeps);
expect(registry.has("email")).toBe(true);
expect(registry.has("sms")).toBe(true);
expect(registry.has("push")).toBe(true);
});
});When to Use It
The registry pattern works well when:
- You have multiple implementations of the same interface
- The choice of implementation depends on runtime data
- New implementations are added over time
- The calling code shouldn't know about specific implementations
It's overkill when:
- You have two or three implementations that rarely change
- The choice is known at compile time
- Implementations differ significantly in their interface needs
For the notification service example: if you only ever send email and that's unlikely to change, just inject IEmailService directly. The abstraction isn't worth it.
But if you're building a platform where customers configure their notification channels, or an AI system where tools are added regularly, the registry pattern pays for itself.
Conclusion
The registry pattern is a lookup table with a shared interface. That's the whole thing. Keys map to handlers, handlers all look the same, callers don't know which handler they get.
It's not service locator. Service locator is a global dependency container. The registry is a single-purpose routing mechanism that gets injected like any other dependency.
The pattern shines when you have multiplexed behavior: one entry point, many possible handlers, runtime selection. Notification transports. AI tool execution. File parsers. Event handlers. Plugin systems.
The cost is interface rigidity. Every handler must fit the same shape. If that constraint works for your domain, the pattern gives you extensibility without modifying core logic. If it doesn't, you'll fight the abstraction.
Like most patterns, it's a trade-off. Use it when the trade-off is worth it.