4 Principles of Object-Oriented Programming

Last updated Apr 16th, 2022
The four principles of object-oriented programming (abstraction, inheritance, encapsulation, and polymorphism) are features that - if used properly - can help us write more testable, flexible, and maintainable code.

Even though I studied object-oriented programming in university, I still struggled with it once I got out into the real world and tried using it to develop software.

I think this has to do with the fact that I was unaware that object design is actually comprised of three parts:

  • analysis: where we learn a domain, discover functional and non-functional requirements, and turn them into use cases
  • design: where we utilize a design method like Responsibility-Driven Design to convert requirements into roles, responsibilities, and collaborations and
  • programming: where we map designs into tested, flexible, maintainable code

Object design

Object design is comprised of three parts: Analysis, Design, and Programming

Only learning the programming aspect, I found myself confused about what good design looked like, how to turn requirements into objects, and when and why to use concepts like abstraction, encapsulation, inheritance, and polymorphism. Yes, I could somewhat remember what these concepts were, but knowing when and why to use them was unclear to me.

Principles of Object Oriented Programming

The four main principles of object-oriented programming (abstraction, inheritance, encapsulation, and polymorphism). The core principle is abstraction. Without it, the others couldn't exist.

In this post, I want to revisit these four main ideas — these principles of object-oriented programming — discuss why they’re beneficial and explain them with simple, relatable, and practical explanations.


Are you really aware of how your TV turns on when you press the ON button on the remote? Do you, as a user, need to know the specific sequence of 0’s and 1’s that your remote control emits to signal to the television’s receiver that it should turn on? Or is pressing the ON button good enough?

Are you really aware of how your car starts when you turn your key in the ignition? Do you, as a driver, need to know about the ignition switch, how the voltage from the battery hits the ignition coil, how an engine spark gets directed to the spark plugs, and how that ignites the fuel to make the car run? Or is turning the key and hearing a ding-ding enough?

Abstraction OO

Abstraction makes technology easier to use. Most of us know that pressing the ON button on the remote will turn on a TV. And that's good enough for us. Imagine that you needed to know the low-level electronic details in order to turn your TV on to watch your favourite HBO show. The learning curve would be tremendous. Very few people would watch TV if that were the case.

It should be clear that we maneuver the world using abstractions — conceptual models that give us a good enough understanding of inventions that are much more complex behind the scenes. And in object-oriented programs, the inventions that we aim to simplify and make easy to use are objects.

Abstraction is a key principle of design. Since object-oriented programs can get quite large, it’s just not humanly possible to expect others to be aware of the inner-workings of each and every class or component within them. Therefore, we strive to abstract objects in a way that makes them intention-revealing, simple to use, and simple to understand.

Consider the abstraction of a washing machine.

// Options for the wash cycle
type WashOptions = {
  dryLevel: 'low' | 'medium' | 'high'
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false

// The abstraction
class WashingMachine {
  // Private instance variables

  public startCycle (options: WashOptions): void {
	  // Parse the options
    // Get access to the physical layer
    // Convert options into commands
    // Lots of low-level code
    // And so on...
  // More methods


Assuming that the only public method right now is the startCycle method, what else does the client need to know? Does it need to know how startCycle works? Does it need to know about the private instance variables within the class? Does it need to know about any other private methods?

Abstraction OO

No, absolutely not. Just like the power-button on the TV remote, it’s simplified for public use. For someone to use this abstraction, all they need to know is the existence of the startCycle method and how call it. That’s it. All other details are abstracted away within the class.

// Obtain access to the abstraction
import { WashingMachine } from '../washer'
// Create an instance
const washer = new WashingMachine();

// Usage
  dryLevel: 'medium',
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false

By keeping what’s not necessary for the client to know abstracted away, we reduce confusion and make it easy for other developers to know how to use our code and extend it.

Abstraction is the root principle of design

Abstraction has many uses — for both the designer and the client. There is much more that can be said on abstraction and abstraction tools like interfaces, abstract classes and types, such as the fact that they give us:

  • The ability to design using contracts vs concretions.
  • The ability to separate the what from the how.
  • The ability to focus on the declarative separately from the imperative.
  • The ability to focus on the high-level ideas separately from the low-level details.

But for now, I’ll say that it’s the root principle of design and a central point of focus for many other important topics like design patterns, principles, responsibility-driven design and more.

Also see

Abstraction is the macro-level principle. Here are a few micro-level topics which are directly related.

  • Maintain a Single Layer of Abstraction at a Time: Keep your methods cohesive and readable by maintaining a single layer of abstraction at a time. Understand what responsibilities for doing and knowing your class has and stick to it.
  • Minimal Interface: Boil the public API (of a class, component, or service) down to the smallest set of operations possible that lets people do what they need to do. May not be the most human-friendly, but operations can be combined in ways that let clients accomplish their goals.
  • Humane Interface: Find out what people want to do and then design the interface in a way that represents it in the most human-centered language possible.
  • Thin Interfaces, thick implementations: Stanford Computer Science Professor John Ousterhout's idea is design thin interfaces with thick implementations. This idea further reinforces that abstraction is about making interfaces easy to understand and use while hiding away complex details.


Wouldn’t it be nice if we could reuse code or extend it for more specific use cases? That’s what inheritance is about.

To go about code reuse, in TDD, we have this technique called the Rule of Three which specifies that upon seeing duplication three times, we should refactor it into an abstraction.

The classic example I use to demonstrate is one where we rely on several different API endpoints from a front-end application but we duplicate the fetching logic across each API adapter (think /users, /forum, /billing, etc).

Instead, what we could do is refactor the data-fetching logic into a common place. The abstract class is a good tool for this use case.

// Base API
export abstract class API {
  protected baseUrl: string;
  private axiosInstance: AxiosInstance;
  constructor (baseUrl: string) {
    this.baseUrl = baseUrl
    this.axiosInstance = axios.create({})
  private enableInterceptors () {    // Here's where you can define common refetching logic  }
  // Common "get" logic
  protected get<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{    return this.axiosInstance.get<T>(`${this.baseUrl}/${url}`, {      headers: requestConfig?.headers ? requestConfig.headers : {},      params: requestConfig.params ? requestConfig.params : null,    });  }
  // Common "post" logic
  protected post<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{    return this.axiosInstance.post<T>(`${this.baseUrl}/${url}`, {      headers: requestConfig?.headers ? requestConfig.headers : {},      params: requestConfig.params ? requestConfig.params : null,    });  }}

And then, for each API adapter we need, we can merely subclass the base API abstract class to get access to the common functionality (public or protected methods and properties).

// Users API
export class UsersAPI extends API {
  constructor () {
  // High-level functionality (extending it)
  async getUsers (): Promise<Users> {
    let response = await this.get('/'); // Use the common logic from the base class    return response.data.users as Users

// Forum API
export class ForumAPI extends API {
  constructor () {
  // High-level functionality (extending it)
  async getPosts (): Promise<Posts> {
    let response = await this.get('/'); // Here, as well    return response.data.posts as Posts;

// Billing API
export class BillingAPI extends API {
  constructor () {
  // High-level functionality (extending it)
  async getPayments (): Promise<Payments> {
    let response = await this.get('/'); // And here ;)    return response.data.payments as Payments;

Inheritance relies on the principle of abstraction; with it, we gain the ability to abstract away (duplicated) low-level details to the base class (API) so that the subclasses can focus on the (unique) high-level details (UsersAPI, ForumAPI, BillingAPI).

Abstraction OO

A good reason to refactor using inheritance is to clean up duplication and make generic functionality easier to use for more specific objects.

It’s about reuse, not hierarchies

When most developers learn about inheritance, they’re presented with trivial Animal-Dog or Person-Staff-Teacher examples which map nicely to the real world but manifest real problems with the complex hierarchies they create.

It is very important to note that concepts which exist in the real world often do not translate into useful software objects. For example, it’s probably not important to model the hierarchy of a car’s speed, dimensions, upgrades, parts, and window tint within the context of an online oil change booking form. However, in the design of a multiplayer racing game, they may very well be useful and necessary.

Why do we invent abstractions anyway? What's the point? To help us express what we need to express to meet the requirements, and to do so in a maintainable way — that’s it. As I've written about in solidbook.io, the goal of software design is to write code that:

  1. Meet the needs of the customer and
  2. Is cost-effectively changed by developers

Inheritance used properly helps #2. Therefore, inheritance is a tool we use upon finding opportunities for reuse, not to express real-world hierarchical similarities.

"At the heart of object-oriented software development there is a violation of real-world physics. We have a license to reinvent the world, because modeling the real world in our machinery is not our goal."

— Rebecca Wirfs-Brock via Object Design [2002]

Composition & delegation

If inheritance is really just about reuse (and not about world-building), then that means we can skip the hierarchy entirely and use the techniques of composition and delegation to implement inheritance.

That means we could also refactor the duplication of the HTTP logic like this.

class UsersAPI {
  // Compose using dependency injection
  constructor (http: API) {
    this.http = http;
    this.baseURL = 'https://example.com/users'
  getUsers () {
    return this.http.get(this.baseURL)


Therefore, if we encounter duplication, we have a lot of refactoring techniques:

  • Inheritance (Extract superclass): When you have two or more classes with common methods or fields, create a superclass and inherit the shared behavior.
  • Composition (Extract class): When one class does work for two or more classes, extract the related methods to its own class and compose your reliant classes with the new one.

Rule of thumb — only inherit from contracts, not concretions: If you’re going to use inheritance the classic OO way, I generally advise that you only inherit or extend from contracts (interfaces, abstract classes, types) and not from concretions (classes). There’s a design method which explains why this rule makes sense (see Responsibility-Driven Design), but as a quick rule of thumb, you’ll know if you’re using inheritance poorly if you find yourself needing to subclass a concrete class.


Objects contain state, make decisions based on that state, and relay messages to other objects asking them for help to fetch or compute things, like a network of routers or a web of atoms.

Because objects make decisions (and enact business logic) based on their state, it’s important that each object manage its own state and protect against mutations from outside classes. Failure to do so can lead to strange behavior, bugs, and an anemic domain model.

Encapsulation is the technique of making state private. Encapsulation means that the class which owns the state decides the degree to which state can be accessed and changed. This is done through the use of public methods.

Let’s continue to demonstrate with the washing machine example. Depending on the washing machine’s current state, some behaviours are valid while others are invalid.

washing machine

For example, if the machine is currently ON , it is valid to call turnOff() to turn the machine OFF. If the machine is in the WASH state, it’s OK to call pause() to put the machine into the PAUSED state. But if the machine is OFF (or ON for that matter) it would not be valid to call PAUSE.

Why? Because a washing machine can’t go from being OFF to PAUSED. It doesn’t make sense.

Encapsulation is the mechanism that governs these rules (also known as class invariants) for us.

type WashState = 'OFF' | 'ON' | 'WASH' | 'PAUSED' | 'DONE';
type Cycle = undefined | 'COOL' | 'WARM';

type MachineState = {
  washState: WashState;
  currentCycle: Cycle;

class WashingMachine {
  // Encapsulation of state is achieved here by making
  // state private and inaccessible directly.
  private state: MachineState;
  // Access is provided via public methods
  public getWashState () : WashState {
    return this.state.washState;
  public getCurrentCycle (): Cycle {
    return this.state.currentCycle;

  // State is changed only through the use of public
  // mutator methods
  public pause (): void {
    // Note this important business logic which is encapsulated
    // to the correct place. The related data and behavior live
    // together.

    // We ONLY do the pause logic if the current state is
    // 'WASH'
    if (this.state.washState === 'WASH') {
      // Stop washing
      // Cue pause sound
      // Set new state
      this.state.washState = 'PAUSED'


Given this, client usage may look as follows:

  dryLevel: 'medium',
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false

console.log(washer.getWashState()); // 'WASH'
console.log(washer.getWashState()); // 'PAUSED'

Encapsulation combines together the data and related behavior which — like abstraction — simplifies usage, but also acts as a single place for us to put relevant logic. With this technique, we invent single sources of truth and obtain appropriate places to locate logic once and for all.


Polymorphism is about “taking on many shapes”. It's about designing your use cases and algorithms in such a way that we always get the same high-level behavior, but we allow for dynamic low-level behavior at runtime.

I still think the best real-world examples I can provide are either:

Regardless, let's demonstrate with an example that will let me introduce you to the idea of roles from Responsibility-Driven Design (the actual way to do OO correctly as far as I know). Think about the role of a clerk at a grocery store. What’s a clerk supposed to do? Answer questions, scan products, bag your items, and prompt you for payment, right? So you could say those are the responsibilities of a clerk.

Clerk Role Responsibilities Collaborations CRC card

A clerk CRC card that defines the role of a clerk, the related responsibilities and the collaborations it has.

Now let’s say that there are 4 different employees that work at this grocery store. Each one can take on the role of a clerk. There’s James who is generally pretty snappy, Mariah who can be somewhat brooding, Max who is always on time (and the friendliest person you’ve ever met), and Mark who is actually the manager but will sometimes fill in when they’re short staffed.

It’s clear that each of the employees does their job slightly differently, but they still perform the responsibilities that a clerk should perform. That is, they are concrete classes which implement the contract of a Clerk.

Let's represent the contract using an interface.

interface Clerk {
  answerQuestion (question: string): Answer;
  scanProduct (product: Product): void;
  bagProduct (product: Product): void;
  promptForPayment (customer: Customer): Promise<PaymentResult>

Then, we could realize the concretions by implementing the Clerk interface, handling things with slighly different behavior if we wish.

class ClerkJames implements Clerk {

  answerQuestion (question: string): Answer {
    ... // Implement uniquely to James

  scanProduct (product: Product): void {
    ... // Implement uniquely to James

  bagProduct (product: Product): void {
    ... // Implement uniquely to James

  promptForPayment (customer: Customer): Promise<PaymentResult> {
    ... // Implement uniquely to James


class ClerkMariah implements Clerk {

  answerQuestion (question: string): Answer {
    ... // Implement uniquely to Mariah

  scanProduct (product: Product): void {
    ... // Implement uniquely to Mariah

  bagProduct (product: Product): void {
    ... // Implement uniquely to Mariah

  promptForPayment (customer: Customer): Promise<PaymentResult> {
    ... // Implement uniquely to Mariah


// Implement the others

And then finally, in the code that relies on a Clerk, we'd provide a Clerk and a Customer to check out.

function checkoutCustomer (clerk: Clerk, customer: Customer): Promise<PaymentResult> {
  for (let question of customer.getQuestions()) {

  for (let product of customer.getProductsInCart()) {

  await clerk.promptForPayment(customer);

Note that this function relies on a Clerk, not a ClerkJames or a ClerkMariah (or so on). Therefore, it doesn’t matter who shows up to work to fill the role. It could be James, Mariah, Max, or Mark — doesn’t matter. All that matters is that some object with the role of clerk is supplied and that fully implements the requirements of the contract for a Clerk:

let customer: Customer = { ... };

let clerkJames: ClerkJames = new ClerkJames();
let clerkMariah: ClerkMariah = new ClerkMariah();

checkoutCustomer(clerkJames, customer); // Valid
checkoutCustomer(clerkMariah, customer); // Also valid since it's a `Clerk`

Do you see how this works? By relying on a contract instead of a concretion, we gain the ability to sub in for different possible implementations.

This is what polymorphism is about. Dynamic runtime behavior. Substitutability.

Clerk polymorphism

What I want for you to take away from this example is that the idea of relying on roles/contracts can be very effective. Within the context of a clean/hexagonal/ports and adapters architecture, the most common reasons you'll do this is to:

  • Keep core code separate from infrastructure code using dependency inversion
  • Keep application layer use cases pure so that they can be unit tested. Swap out an infrastructure dependency (StripeAPI, PaypalAPI) with a test double (MockPaymentAPI) so that we don't make real calls to external services or infrastructure during testing.

Also see

React components are an example of polymorphism: I've mentioned it before, but in React, the Component class is an example of polymorphism. By extending the component class and implementing the render() method, we create dynamic runtime behavior (where the dynamic behavior is how our components get rendered). Read more here.

Rule of thumb — switch statements (conditionals) can be refactored to polymorphism: Most of the time, if we’re dealing with three or more conditionals in a particular block (and we expect to add more conditions), it could be a good idea to refactor to polymorphism. Extract the generic/common behavior to a contract (abstract class or interface) and put the specific behavior in subclasses. Then program against the contract and allow dynamic runtime binding to work its magic.


Abstraction, encapsulation, inheritance, and polymorphism are four of the main principles of object-oriented programming.

  • Abstraction lets us selectively focus on the high-level and abstract way the low-level details.
  • Inheritance is about code reuse, not hierarchies.
  • Encapsulation keeps state private so that we can better enforce business rules, protect model invariants, and develop a single source of truth for related data and logic.
  • Polymorphism provides the ability for us to design for dynamic runtime behavior, easy extensibility, and substitutability.


Liked this? Sing it loud and proud 👨‍🎤.


Be the first to leave a comment


Stay in touch!

About the author

Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚡

Khalil is a software developer, writer, and musician. He frequently publishes articles about Domain-Driven Design, software design and Advanced TypeScript & Node.js best practices for large-scale applications.

View more in Object-Oriented Programming

You may also enjoy...

A few more related articles

Non-Functional Requirements (with Examples)
Non-functional requirements are quality attributes that describe how the system should be. They judge the system as a whole based ...
How to Test Code Coupled to APIs or Databases
In the real-world, there's more to test than pure functions and React components. We have entire bodies of code that rely on datab...
How to Mock without Providing an Implementation in TypeScript
Having to provide an implementation everytime you create a test double leads to brittle tests. In this post, we learn how to creat...
When to Use Mocks: Use Case Tests
Mocking gets a pretty bad rap. However, if you're building an application using object-oriented programming and you're making use ...

Want to be notified when new content comes out?

Join 10000+ other developers learning about Domain-Driven Design and Enterprise Node.js.

I won't spam ya. 🖖 Unsubscribe anytime.

Get updates