Skip to content

OOP and SOLID in NestJS

Encapsulation

Encapsulation protects behavior and invariants, not merely fields.

typescript
class Order {
  private status: 'draft' | 'confirmed' = 'draft';

  confirm() {
    if (this.status !== 'draft') {
      throw new OrderAlreadyConfirmed();
    }
    this.status = 'confirmed';
  }
}

A public setStatus(value: string) exposes the invariant and is weaker even if the field remains private.

Use immutable objects for values and messages when practical. Entities can be mutable inside a controlled transaction, but mutations should be named business operations.

Abstraction

An abstraction should hide a decision likely to change or a boundary worth testing. Good abstractions have a small vocabulary grounded in the consumer's need.

typescript
interface Clock {
  now(): Date;
}

Avoid a wrapper that reproduces an entire third-party SDK method for method. It adds a layer but hides no meaningful decision.

Composition over inheritance

Prefer collaborating policies:

typescript
@Injectable()
class ShippingQuoteService {
  constructor(
    @Inject(SHIPPING_POLICY)
    private readonly policy: ShippingPolicy,
  ) {}
}

Inheritance is appropriate when subtypes have a genuine is-a relationship, share a stable contract, and remain substitutable. Framework base classes or templates can be valid; feature services rarely need a generic base CRUD class.

Warning signs in inheritance:

  • subclasses override methods to throw "unsupported";
  • callers inspect subtype names;
  • protected mutable state coordinates behavior;
  • base changes break unrelated features;
  • decorators/metadata are assumed to inherit in ways the framework does not guarantee.

Polymorphism

Use polymorphism for real variation:

typescript
interface PricingPolicy {
  calculate(order: PricedOrder): Money;
}

class StandardPricing implements PricingPolicy { /* ... */ }
class PartnerPricing implements PricingPolicy { /* ... */ }

Keep the selection at the composition root or a focused factory. Avoid selecting a strategy from unvalidated user input.

Single Responsibility Principle

Define responsibility as a cohesive policy or reason to change, not one method.

A PlaceOrder use case may load an order, enforce a policy, authorize payment, save, and publish an event. Its single responsibility is coordinating order placement. Persistence mapping and payment SDK calls belong elsewhere because they change for different reasons.

Split when:

  • unrelated actors request changes;
  • dependencies cluster into separate groups;
  • tests require unrelated setup;
  • state or lifecycle differs;
  • part of the behavior has a clearer domain owner.

Open/Closed Principle

First implement the direct case. When repeated changes reveal a stable operation with varying behavior, extract the variation.

typescript
const TAX_POLICIES = new Map<Region, TaxPolicy>([
  ['eu', euTaxPolicy],
  ['us', usTaxPolicy],
]);

Do not replace a small stable conditional with a class hierarchy solely to avoid modifying a file.

Liskov Substitution Principle

A subtype/implementation must preserve the contract:

  • accepted inputs and preconditions;
  • returned meaning and postconditions;
  • error categories;
  • side effects and idempotency;
  • ordering and consistency expectations.

If CachedOrderRepository.save() silently drops writes when the cache is unavailable, it is not substitutable for OrderRepository even if the TypeScript signatures match.

Interface Segregation Principle

Design ports from consumer use:

typescript
interface FindOrder {
  byId(id: OrderId): Promise<Order | null>;
}

interface SaveOrder {
  save(order: Order): Promise<void>;
}

Separate read/write ports when consumers and semantics differ. Do not fragment an interface if every consumer always needs the same cohesive operations.

Dependency Inversion Principle

The application defines what it needs; infrastructure implements it.

text
PlaceOrder -> PaymentGateway <- StripePaymentGateway

Nest module wiring points from the outer adapter to the application-owned token. Domain/application source code never imports Stripe.

OOP checklist

  • [ ] Business mutations are named and preserve invariants.
  • [ ] Public surface is smaller than internal representation.
  • [ ] Composition is preferred unless substitution is proven.
  • [ ] Abstractions correspond to real variation or boundaries.
  • [ ] Implementations honor behavioral contracts, not only types.
  • [ ] Constructor dependencies reveal collaboration clearly.
  • [ ] Nest/ORM/vendor concerns remain at appropriate edges.

Canonical source: skills/nestjs-oop-design-patterns/references/oop-solid.md. This page is generated during the documentation build.

Open-source guidance for deliberate NestJS engineering.