The Gang of Four's "Design Patterns" book is more than thirty years old, but you no longer need to read it as a ready-made catalog of recipes — half the patterns have long since dissolved into the language and frameworks. So why learn them? Because NestJS, TypeORM, and every HTTP framework speak exactly this vocabulary. ExpressAdapter, CompositePropagator, PassportStrategy — these aren't random names, they're GoF patterns embedded in class names. Without knowing the pattern, it's hard to understand why a class is built the way it is and not some other way.
Below are all 23 patterns in their classic groups. For each one: the gist in a single sentence, where it already lives in off-the-shelf tools, and whether you need to write it yourself.
Creational Patterns
This group answers one question: how do you create objects the right way?
Singleton
Gist: one instance for the whole application.
If you create an object everywhere it's needed, you end up with several unrelated copies with different state. Singleton solves this: the object exists as a single instance, and everyone talks to the same one.
In NestJS, a singleton is implemented through the container — each @Injectable() provider is created as a single instance by default. This is better than the classic GoF variant with static getInstance(): a container-managed singleton is injected through the constructor and is easily swapped out in tests.
The key consequence: one instance serves all requests in parallel, so singleton providers must not carry mutable state — otherwise one request's state leaks into another.
@Injectable()
export class PricingService {
// one instance for the entire application context
}
it('calculates price', () => {
const service = new PricingService(); // in a test we create it via new — simple
});
Prototype
Gist: a new instance on every request.
The opposite of Singleton: sometimes you need a fresh object for each operation, not a shared one. In NestJS this is Scope.TRANSIENT — each dependency resolution yields a new object.
The trap: if you inject a transient provider into a singleton provider the usual way, the injection happens once, when the singleton is created — and the "freshness" is lost. The solution is ModuleRef.resolve():
@Injectable()
export class ReportController {
constructor(private readonly moduleRef: ModuleRef) {}
async create(request: ReportRequest): Promise<ReportDto> {
const builder = await this.moduleRef.resolve(ReportBuilder); // a new instance every time
return builder.with(request).build();
}
}
Factory Method
Gist: object creation is delegated to a method that hides the concrete class.
Instead of writing new ConcreteClass(...) everywhere, the calling code works with an interface, and the factory method decides which implementation to create.
In NestJS, every useFactory provider is a factory method: the calling code knows the token and the interface, the factory decides which implementation to configure:
export const CLOCK = Symbol('Clock');
@Module({
providers: [
{
provide: CLOCK,
useFactory: (config: ConfigService): Clock =>
config.get('CLOCK_FIXED')
? Clock.fixed(new Date('2026-01-15T10:00:00Z'))
: Clock.system(),
inject: [ConfigService],
},
],
exports: [CLOCK],
})
export class ClockModule {}
In application code, static factory methods are a good way to create objects with validation:
export class Order {
static create(customerId: CustomerId, lines: OrderLine[]): Order {
if (lines.length === 0) {
throw new Error('An order must contain at least one line');
}
return new Order(OrderId.generate(), customerId, lines, 'CREATED');
}
}
Abstract Factory
Gist: a factory that creates a family of related objects.
Where Factory Method creates a single object, Abstract Factory creates a whole group of objects that must "fit together."
In NestJS, the role of the abstract factory is played by the DI container and ModuleRef: they hand out ready objects by token, hiding the concrete implementation. Which implementation ends up behind the NOTIFICATION_PORT token — an SMTP adapter or a push one — is decided by the module configuration, not by the calling code.
In application code, Abstract Factory is rarely written by hand: environment-based configuration (useFactory + ConfigService) covers the task more simply.
Builder
Gist: step-by-step assembly of a complex object with readable code.
A constructor with eight parameters is a source of bugs: it's easy to mix up the order, and it's unclear what does what. Builder gives named methods for each field.
In the TypeScript ecosystem, Builder is used in DocumentBuilder from @nestjs/swagger and in the query builders of TypeORM and Knex. That said, most of Builder's job in TypeScript is done by an object literal with named fields:
interface OrderSearchQuery {
customerId?: CustomerId;
status?: OrderStatus;
page: number;
size: number;
}
const query: OrderSearchQuery = {
status: 'PAID',
page: 0,
size: 20,
};
// the classic builder remains where the assembly is multi-step
const openApiConfig = new DocumentBuilder()
.setTitle('Orders API')
.setVersion('1.0')
.build();
Structural Patterns
This group answers the question: how do you build the connections between objects the right way?
Adapter
Gist: converts one interface into another that the client expects.
Imagine two plugs of different shapes: your code expects one interface, but an external library offers another. Adapter is the connector between them.
NestJS doesn't know which HTTP server sits underneath it — Express or Fastify. It works with a single HttpAdapter (ExpressAdapter, FastifyAdapter), which brings any server to a common contract.
In application code, Adapter is the foundation for working with external dependencies: the adapter translates a domain interface into the language of a specific SDK:
@Injectable()
export class S3DocumentStorageAdapter implements DocumentStoragePort {
constructor(private readonly s3: S3Client) {}
async store(document: Document): Promise<DocumentRef> {
const key = document.id.value;
await this.s3.send(new PutObjectCommand({
Bucket: 'documents',
Key: key,
Body: document.content,
}));
return new DocumentRef(key);
}
}
Bridge
Gist: the abstraction and the implementation evolve independently.
The classic example: the Readable stream in Node.js — one abstraction "data stream," independent implementations for a file, a socket, an HTTP response. The code that reads the stream doesn't change when the source changes.
In application code, Bridge in its pure form is almost never seen — it's replaced by the "interface + dependency injection" combination.
Composite
Gist: a group of objects is used the same way as a single object.
You need to send a notification over email and SMS at the same time, but the calling code shouldn't know the details. Composite lets you "wrap" several objects into one that implements the same interface.
It's recognized by the Composite prefix: for example, CompositePropagator in OpenTelemetry for Node — several propagators look like one.
@Injectable()
export class CompositeNotificationAdapter implements NotificationPort {
constructor(private readonly channels: NotificationPort[]) {}
async orderCancelled(order: Order): Promise<void> {
await Promise.all(this.channels.map(channel => channel.orderCancelled(order)));
}
}
Decorator
Gist: an object is wrapped in a wrapper with the same interface that adds behavior.
You need to add caching to a repository, but you can't (or don't want to) change its class. Decorator creates a wrapper with the same interface that intercepts calls and adds the desired behavior.
In NestJS, this role is played by interceptors — a wrapper around the handler call with the same contract. Don't confuse it with TypeScript decorators (@Injectable(), @Get()): syntactically those are metadata annotations, while the GoF Decorator is about a wrapper object.
@Injectable()
export class CachingProductRepository implements ProductRepository {
constructor(
private readonly delegate: TypeOrmProductRepository,
private readonly cache: Cache,
) {}
async findById(id: ProductId): Promise<Product | null> {
return this.cache.getOrLoad(id.value, () => this.delegate.findById(id));
}
}
Facade
Gist: a simple interface over a complex subsystem.
Working with a database driver directly requires: take a connection from the pool, prepare the query, execute it, parse the rows, return the connection to the pool. TypeORM's Repository hides all this complexity behind a single call.
In NestJS, the facades are HttpService, TypeORM's Repository, ClientProxy for microservices. In application code, a facade over an external SDK is a normal form of adapter: a single method can hide three third-party API calls, retries, and error translation.
@Injectable()
export class PaymentGatewayAdapter implements PaymentPort {
constructor(private readonly sdkClient: PaymentSdkClient) {}
async charge(order: Order, method: PaymentMethod): Promise<PaymentResult> {
const response = await this.sdkClient.submit({
amount: order.total.amount,
currency: order.total.currency.code,
method: method.token,
});
return PaymentResult.of(response.transactionId, response.status);
}
}
Flyweight
Gist: shared immutable objects instead of thousands of identical copies.
If you create one object per word in a text, memory runs out fast. Flyweight shares objects with identical content — one instance per value.
In JavaScript, this is string interning and the shared hidden classes in V8. In frameworks — internal caches of decorator metadata (reflect-metadata).
In application code, Flyweight is almost never written by hand. Its idea is carried by immutable value objects and constants: Currency.RUB is one for the whole application precisely because it's immutable.
Proxy
Gist: a stand-in object controls access to the real object.
The proxy intercepts calls and does something before or after: opens a transaction, checks permissions, caches the result.
In JavaScript, the stand-in is built into the language — the Proxy object intercepts property and method access. It powers lazy relations in TypeORM and auto-mocks in testing libraries. In NestJS, the same "do something before and after the call" role is played by guards and interceptors, while the @Transactional() decorator from typeorm-transactional wraps a method in a transaction.
Hence the classic trap: interceptors fire only on calls that go through a controller — a direct call to a service method from another service bypasses them (Spring has the same proxy mechanics — covered in detail in the article on AOP).
@Injectable()
export class TransferService {
@Transactional() // the decorator wraps the method in a transaction
async transfer(from: AccountId, to: AccountId, amount: Money): Promise<void> {
// the calling code gets the wrapper, not the original method directly
}
}
Behavioral Patterns
This group answers the question: how do you organize the interaction between objects?
Chain of Responsibility
Gist: a request travels down a chain of handlers until someone handles it.
An HTTP request needs to be checked first for authentication, then for CSRF, then for authorization, and each step can stop processing. Instead of one huge method — a chain of independent handlers.
In Express and NestJS, this is the middleware chain — the reference implementation of the pattern. The same mechanics apply to NestJS guards, interceptors, and exception filters.
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
traceContext.run({ traceId: resolveTraceId(req) }, () => {
next(); // pass it further down the chain
});
}
}
Command
Gist: an operation is packaged into an object — it can be passed around, deferred, queued.
Normally a method call is instant and anonymous. Command turns an operation into an object with data — it can be passed to another thread, deferred, logged, undone.
In Node, this is job objects going into a BullMQ queue. In NestJS — @Cron tasks from @nestjs/schedule and queue processors.
In application code, Command is the structural foundation for separating "what to do" from "how to do it": one object carries the operation's data, another executes it.
export interface CancelOrderCommand {
readonly orderId: OrderId;
readonly reason: CancelReason;
}
@Injectable()
export class CancelOrderHandler {
constructor(private readonly orderRepository: OrderRepository) {}
@Transactional()
async handle(command: CancelOrderCommand): Promise<void> {
const order = await this.orderRepository.findByIdOrFail(command.orderId);
order.cancel(command.reason);
await this.orderRepository.save(order);
}
}
Interpreter
Gist: a language with a grammar and an interpreter for expressions written in it.
In Node, this is the cron expressions in @Cron('0 0 * * *') from @nestjs/schedule and query languages like JSONPath or JMESPath.
In your own code, creating mini-languages isn't worth it: string expressions aren't checked by the compiler, they break during refactoring, and they complicate debugging.
Iterator
Gist: sequential access to elements without exposing the internal structure.
The pattern has long since dissolved into the language: the Symbol.iterator protocol, for...of, generators, async iterators for streams. TypeORM adds skip/take for paged fetches.
You never have to implement Iterator by hand. The one close case is returning immutable views from an aggregate's collections.
Mediator
Gist: objects communicate through a mediator, without knowing about each other.
If one service calls another directly, they become tightly coupled: changing one breaks the other. Mediator removes the direct dependency — objects publish events, and whoever wants to subscribes.
In NestJS, this is EventEmitter2 from @nestjs/event-emitter: a provider publishes an event, listeners react, and nobody is directly coupled to anyone. The NestJS router is also a mediator between the transport and the handlers.
@Injectable()
export class CancelOrderHandler {
constructor(private readonly events: EventEmitter2) {}
async handle(command: CancelOrderCommand): Promise<void> {
// ... cancellation logic ...
this.events.emit('order.cancelled', new OrderCancelled(command.orderId));
}
}
@Injectable()
export class RefundListener {
@OnEvent('order.cancelled')
async on(event: OrderCancelled): Promise<void> {
// a different module, unaware of the cancellation handler
}
}
Memento
Gist: a snapshot of an object's state for a later rollback.
A savepoint in transactions is a pure Memento: SAVEPOINT records a point, ROLLBACK TO SAVEPOINT rolls back to it.
In application code it's almost never needed: rolling back state is the job of a database transaction, and change history is a separate audit table or event sourcing.
Observer
Gist: subscribers get notified when the publisher's state changes.
You need to send an email when an order is cancelled. You could call emailService.send(...) right inside the business logic — but then the business logic knows about the email service. Observer separates them: the business logic publishes an event, the email service subscribes.
In NestJS, this is @OnEvent from @nestjs/event-emitter. An important nuance: if a listener with external effects (an email, an SMS) fires before the transaction commits, the notification may go out for a rolled-back transaction. The right way is to publish the event after the commit (or through an outbox).
@Injectable()
export class OrderNotificationListener {
@OnEvent('order.cancelled')
async on(event: OrderCancelled): Promise<void> {
await this.notificationPort.orderCancelled(event.orderId);
}
}
State
Gist: an object's behavior changes as its internal state changes.
An order in the CREATED status can be cancelled. An order in the DELIVERED status can't. The transition logic can be split into separate state classes, but for most tasks, checks inside the object's own methods are enough:
export class Order {
cancel(reason: CancelReason): void {
if (this.status !== 'PAID' && this.status !== 'CREATED') {
throw new IllegalOrderStateError(this.id, this.status, 'cancel');
}
this.status = 'CANCELLED';
this.registerEvent(new OrderCancelled(this.id, reason));
}
}
The classic State with a separate class per state is justified for a very large state machine. For an ordinary object with a few statuses, a union type of statuses plus transition checks is enough.
Strategy
Gist: a family of algorithms behind a common interface, chosen depending on the situation.
Discounts for different customer categories: you can write a switch with conditions, or you can declare a DiscountPolicy interface and create one implementation per category. Adding a new category means a new class, not editing a switch.
In NestJS, Strategy is everywhere: PassportStrategy (picks the authentication method), cache-manager stores, winston logger transports.
export interface DiscountPolicy {
supports(order: Order): boolean;
apply(order: Order): Money;
}
@Injectable()
export class DiscountService {
constructor(private readonly policies: DiscountPolicy[]) {}
calculateDiscount(order: Order): Money {
return this.policies
.filter(p => p.supports(order))
.map(p => p.apply(order))
.reduce((acc, discount) => acc.add(discount), Money.ZERO);
}
}
For a single-method strategy, the functional form is natural in TypeScript: type DiscountFn = (order: Order) => Money — implementations are passed as plain functions, no classes needed.
Template Method
Gist: the skeleton of an algorithm in a base class, the changeable steps in subclasses.
An authentication strategy must always extract and verify credentials the same way. The PassportStrategy(Strategy) base class takes care of this, leaving the subclass only the meaningful part:
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy) {
async validate(payload: JwtPayload): Promise<AuthUser> {
// we write only the meaningful part, the base class provides the skeleton
return { userId: payload.sub, roles: payload.roles };
}
}
The modern variant is to pass the step as a function rather than creating a subclass. dataSource.transaction(...) in TypeORM does exactly this: the skeleton (open transaction → execute → commit/rollback) is constant, and the variable step is passed as a callback.
Visitor
Gist: a new operation over a structure of objects without changing their classes.
There's a hierarchy of PaymentMethod types: Card, Sbp, Cash. You need to compute the fee differently for each type without adding a fee() method to every class. Visitor adds the operation from the outside.
In modern languages, Visitor has been displaced by pattern matching:
// TypeScript: discriminated union + exhaustive switch instead of Visitor
type PaymentMethod = Card | Sbp | Cash;
function fee(method: PaymentMethod): number {
switch (method.kind) {
case 'card': return method.amount * 0.02;
case 'sbp': return 0;
case 'cash': return 50;
}
}
All 23 Patterns: A Quick Summary
| Pattern | Where it shows up in off-the-shelf tools | Do you need it in your own code |
|---|---|---|
| Singleton | Default provider scope in NestJS | Don't write by hand — it's the container's job |
| Prototype | Scope.TRANSIENT, ModuleRef.resolve() | Rarely; a local variable is usually enough |
| Factory Method | useFactory providers | Yes — static factory methods to create objects with validation |
| Abstract Factory | NestJS DI container, ModuleRef | Not needed — useFactory + ConfigService assembles the configuration |
| Builder | DocumentBuilder in @nestjs/swagger, TypeORM/Knex query builders | Yes — but an object literal with named fields usually suffices |
| Adapter | ExpressAdapter, FastifyAdapter | Yes — adapters to external dependencies |
| Bridge | Readable streams in Node.js | Almost never — "interface + DI" covers it |
| Composite | CompositePropagator in OpenTelemetry | Yes — when you need several recipients behind one interface |
| Decorator | Interceptors in NestJS, stream wrappers | Yes — wrappers over repositories; check built-in mechanisms first |
| Facade | Repository in TypeORM, HttpService, ClientProxy | Yes — an adapter-facade over someone else's SDK |
| Flyweight | String interning, hidden classes in V8 | Almost never — the idea is carried by immutable value objects |
| Proxy | Proxy in JS, TypeORM lazy relations, @Transactional() | Don't write — use the built-in mechanisms |
| Chain of Responsibility | Middleware in Express/NestJS, guards and filters | Rarely — the framework's ready-made chains are enough |
| Command | BullMQ jobs, @Cron from @nestjs/schedule | Yes — separating "what" from "how" in operation handlers |
| Interpreter | Cron expressions in @nestjs/schedule, JSONPath | Don't invent your own expression languages |
| Iterator | Symbol.iterator, for...of, generators | Dissolved into the language |
| Mediator | EventEmitter2, the NestJS router | Yes — events instead of direct calls between modules |
| Memento | SAVEPOINT in transactions | Almost never — the database transaction does the rollback |
| Observer | @OnEvent in @nestjs/event-emitter | Yes — domain events, constantly |
| State | XState for complex cases | Yes, in a lightweight form — a union type of statuses + transition checks |
| Strategy | PassportStrategy, winston transports | Yes — instead of sprawling switch statements |
| Template Method | PassportStrategy(Strategy) + validate() | The callback variant is preferable to inheritance |
| Visitor | AST traversal in the TypeScript compiler | Displaced by discriminated unions + exhaustive switch |
Of the 23 patterns, you'll regularly write seven or eight in application code: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method, and State. Another handful you use ready-made every day without noticing: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. The rest are vocabulary for reading other people's code.
In Short
- GoF patterns aren't recipes to copy, they're a vocabulary: this is exactly what frameworks speak in their class names.
- Proxy is built into JavaScript: the
Proxyobject, TypeORM lazy relations, and@Transactional()all work through it. - Strategy is the main tool against sprawling
switchstatements. - Observer is the standard way to separate side effects (an email, a metric) from the business logic.
- Adapter is the foundation for working with external dependencies: the domain knows the interface, the adapter knows the concrete SDK.
- Singleton isn't written by hand with
static getInstance()— that's the DI container's job. - Decorator adds behavior without inheritance — but first check whether the framework already has an interceptor for it.
- Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.
What to Read Next
- SOLID by Example — the principles these patterns exist for.
- GRASP by Example — which class to give responsibility to before choosing a pattern.
- Spring AOP — how Proxy, Spring's number-one pattern, is built.
- DI/IoC, bean scopes — Singleton and Prototype as container scopes.
- Spring Events — Observer and Mediator in action.