Object-oriented programming is a programming paradigm that groups data and functions into a single object, thereby increasing cohesion and reducing coupling.

When learning OOP, the most important thing is a shift in thinking. Martin Fowler says that the best way to achieve this shift in thinking is to work in a well-structured OOP environment for a while. However, there aren’t many developers who truly understand OOP, making it difficult to find such an environment.

In this post, we will progressively improve code from a procedural style to an object-oriented style to explore the essence of OOP. Hopefully, this will help you make that crucial mental shift.

1. Introduction to Procedural Code

Let’s start by writing a function that reads a document from a buffer and prints it character by character:

/* Reading from the buffer */
function main() {
  const buffer = "Hello, World!";

  printDocument(buffer);
}

function printDocument(buffer: string) {
  for (let i = 0; i < buffer.length; i++) {
    const char = buffer.charAt(i);

    console.log(char);
  }
}

1.1. Adding functionality to procedural code

Now, let’s add a feature to read a document from a file and print it character by character.

function main() {
  /* Reading from the buffer */
  const buffer = "Hello, World!";
  printDocument(buffer);

  /* Reading from a file */
  const file = new File("test.txt");
  printDocument(file);
  file.close();
}

function printDocument(doc: string | File) {
  /* Reading from the buffer */
  if (typeof doc === "string") {
    for (let i = 0; i < doc.length; i++) {
      const char = doc.charAt(i);

      console.log(char);
    }
  } else if (doc instanceof File) {
    /* Reading from a file */
    let char = doc.getChar();

    while (char != EOF) {
      console.log(char);

      char = doc.getChar();
    }
  }
}

This is a typical example of procedural code. One characteristic of procedural code is the frequent use of if statements.

2. Problems with Procedural Code

What if we need to add a feature to read from a REST API? We would have to modify both the main() function and the printDocument() function.

function main() {
  /* Reading from the buffer */
  const buffer = "Hello, World!";
  printDocument(buffer);

  /* Reading from a file */
  const file = new File("test.txt");
  printDocument(file);
  file.close();

  /* Making a REST API request */
  const request = new HttpRequest("https://test.com/api");
  printDocument(request);
}

function printDocument(doc: string | File | HttpRequest) {
  /* Reading from the buffer */
  if (typeof doc === "string") {
    for (let i = 0; i < doc.length; i++) {
      const char = doc.charAt(i);

      console.log(char);
    }
  } else if (doc instanceof File) {
    /* Reading from a file */
    let char = doc.getChar();

    while (char != EOF) {
      console.log(char);

      char = doc.getChar();
    }
  } else if (doc instanceof HttpRequest) {
    /* REST API request */
    const body = doc.body();

    for (let i = 0; i < body.length; i++) {
      const char = body.charAt(i);

      console.log(char);
    }
  }
}

If all we have to change is the single printDocument() function, that might not be a big deal. But what if there are other functions that also need to handle these inputs? The number of functions to update grows accordingly.

function main() {
    const buffer = "Hello, World!"
    printDocument(buffer)
    updateDocument(buffer, "new contents")
    clearDocument(buffer)

    const file = new File("test.txt")
    printDocument(file)
    updateDocument(file, "new contents")
    // There's a rule that you have to close the file before clearing it.
    file.close()
    clearDocument(file)
}

function updateDocument(doc: string | File, contents: string) {
    if(typeof doc === 'string') {
        doc = contents
    }
    else if(doc instanceof File){
        doc.write(contents)
    }
    else if(doc instanceof HttpRequest){
        ...
    }
}

function clearDocument(doc: string | File) {
    if(typeof doc === 'string') {
        doc = ""
    }
    else if(doc instanceof File){
        doc.delete()
    }
    else if(doc instanceof HttpRequest){
        ...
    }
}

function printDocument(doc: string | File) {
    if(typeof doc === 'string') {
        for (let i = 0; i < doc.length; i++) {
            const char = doc.charAt(i)

            console.log(char)
        }
    }
    else if(doc instanceof File){
        let char = doc.getChar()

        while (char != EOF) {
            console.log(char)

            char = doc.getChar()
        }
    }
    else if(doc instanceof HttpRequest){
        ...
    }
}

In each function, we’re checking the type via if-else. Imagine if you need to change HttpRequest to HttpRequest2. In a real project, the code would be far longer and more complex. Finding and updating all affected functions wouldn’t be easy.

Even from a simple viewpoint, if you have no if statements, you can just read the code as is, but if there’s an if, you have to keep each condition in your head while reading. In other words, if statements increase complexity and make development harder.

This is a part that can be hard to convey if someone hasn’t personally struggled with code full of if-else. But we can’t just paste extremely complex code here, either.

3. How to Improve Procedural Code

3.1. Splitting functions

One way to avoid if statements is to split printDocument() into multiple functions:

function main() {
  const buffer = "Hello, World!";
  printBufferDocument(buffer);

  const file = new File("test.txt");
  printFileDocument(file);
}

function printBufferDocument(doc: string) {
  for (let i = 0; i < doc.length; i++) {
    const char = doc.charAt(i);

    console.log(char);
  }
}

function printFileDocument(doc: File) {
  let char = doc.getChar();

  while (char != EOF) {
    console.log(char);

    char = doc.getChar();
  }
}

At first glance, this seems fine.

However, it’s common for functions to call other functions in a nested way:

function main() {
    const buffer = "Hello, World!"
    printBufferWeeklyReport(buffer)

    const file = new File("test.txt")
    printFileWeeklyReport(file)
}

function printBufferWeeklyReport(doc: string){
    /* Code to generate the report */
    ...

    printBufferDocument(report)
}

function printFileWeeklyReport(doc: File){
    /* Code to generate the report */
    ...

    printFileDocument(report)
}

Sure, the if statements are gone. But in removing the if, you repeat the code to generate the report in each place. In such a scenario, sticking with an if might actually be simpler.

Perhaps you’re thinking you could make that code to generate the report into a function, so that both duplication and if statements vanish. In most real-world projects, though, code to generate the report differs slightly depending on whether you’re dealing with a buffer or a file. It’s rarely that simple to isolate the logic.

3.2. Passing in the execution code

The fundamental reason we can’t remove the if statements in printDocument() is that main() only provides the data needed by printDocument(), not the method (function) for using that data.

So, printDocument() has to figure out, via conditional checks, how to run the correct logic for the given type of data.

What if we pass in the function itself as well?

function main() {
  const buffer = "Hello, World!";
  let position = 0;
  const getCharFromBuffer = (doc: string) => {
    if (position == doc.length) return;

    return doc.charAt(position++);
  };

  printDocument(buffer, getCharFromBuffer);

  const file = new File("test.txt");
  const getCharFromFile = (doc: File) => {
    const char = doc.getChar();

    return char == EOF ? null : char;
  };

  printDocument(file, getCharFromFile);
  file.close();
}

function printDocument(doc: any, getChar: Func) {
  let char = getChar(doc);

  while (char != null) {
    console.log(char);

    char = getChar(doc);
  }
}

Now main() becomes more complex, but printDocument() no longer needs an if statement and won’t need modifying if new formats appear.

The next question is how to tidy up main().

4. Object-Oriented Code

To make main() cleaner, we can use classes:

function main() {
  const bufferDocument = new BufferDocument("Hello, World!");
  printDocument(bufferDocument);

  const fileDocument = new FileDocument("test.txt");
  printDocument(fileDocument);
}

function printDocument(reader: DocumentReadable) {
  let char = reader.getChar();

  while (char != null) {
    console.log(char);
    char = reader.getChar();
  }

  reader.close();
}

interface DocumentReadable {
  getChar(): string | null;
  close(): void;
}

class BufferDocument implements DocumentReadable {
  private position: number;

  constructor(private buffer: string) {
    this.position = 0;
  }

  public getChar(): string | null {
    if (this.position === this.buffer.length) return null;

    return this.buffer.charAt(this.position++);
  }
}

class FileDocument implements DocumentReadable {
  private stream: ReadStream;

  constructor(filename: string) {
    this.stream = new File(filename);
  }

  public getChar(): string | null {
    const char = this.stream.getChar();
    return char !== EOF ? char : null;
  }

  public close(): void {
    this.stream.close();
  }
}

In the OOP version, printDocument() has no if statements to check document types. When you create the object in main(), you define everything it needs. All printDocument() has to do is call the object’s methods.

If you see an if in your code, ask yourself if it’s procedural code. Consider whether you can refactor it to an object-oriented style.

Earlier, we said the procedural code only passes the data needed by printDocument(), but not the function. The object-oriented approach packages the data and the relevant functions together into an object. That’s the biggest and most striking difference.

5. Characteristics of a Good Object

Let’s express the code above in a class diagram:

5.1. Separation of Concerns

The printDocument() function just uses the DocumentReadable interface. BufferDocument and FileDocument each provide their functionality according to the DocumentReadable interface.

So, printDocument(), BufferDocument, and FileDocument only need to know about the DocumentReadable interface. They know nothing about each other specifically. This means that as long as the DocumentReadable interface doesn’t change, there’s no need to modify these functions or classes.

In other words, if the interface remains stable, any changes within a particular component don’t ripple out to everything else. That’s how OOP enables incremental development. By cleanly separating interface and implementation, OOP naturally fosters high cohesion and low coupling.

Developers accustomed to procedural thinking can often struggle with the concept of encapsulating a feature so they can focus solely on it. This lack of practice may also appear in everyday tasks like dividing and delegating work.

For example, a frontend and backend developer define a REST API and then start building their respective parts. An error arises in the backend that indicates the backend code doesn’t meet the existing API specification. Upon debugging, the backend developer discovers it’s simpler to change the REST API (and therefore the frontend code) than to fix the backend code. The backend developer requests the frontend developer to adapt their code. The frontend developer agrees.

This violates the principle established when the REST API was designed. Moreover, dragging the frontend into the backend’s internal issues creates a tighter coupling.

You might wonder if such a minor change is really a big deal. It may not seem like a huge issue when coding, but it becomes a headache when someone else tries to analyze the code later.

Each party’s area of responsibility must remain strictly defined.

Developers accustomed to procedural thinking may perceive this statement as selfish or cold. In reality, it’s purely a technical approach.

5.2. Access Control

You can think of functions as being interconnected through variables.

Take global variables, for example. We’re often told not to use them because any function that uses the same global variable becomes interconnected. In a project with over 100,000 lines of code, how can you guarantee every function obeys the rules you have in mind? This skyrockets complexity.

If you can’t figure out how far the impact of your code changes might reach, you’re almost guaranteed to create bugs.

In the following diagram, the get(), print(), change(), reset() functions all share the count variable, so they’re tightly connected. In particular, change() and reset() can directly affect the behavior of every other function that depends on count.

By contrast, only the methods inside BufferDocument can use the buffer and position properties. They’re private, so external code is blocked from accessing them.

That’s why you shouldn’t expose properties as public. If external code can modify them, you can’t know how far the impact of a change to those properties might go. It becomes effectively the same as using global variables.

6. Applications of Object-Oriented Programming

The principle of separating concerns in OOP to manage complexity and minimize the impact of changes extends into other domains as well.

6.1. Microservices Architecture

MSA (Microservices Architecture) bears significant structural resemblance to OOP.

At the core of OOP is bundling data and functions in a single object. Likewise, MSA bundles the database and API into a single service, whose internal implementation and database aren’t exposed externally. This prevents internal changes from affecting the outside world and greatly improves maintainability and scalability.

Not understanding OOP thoroughly can make it significantly harder to grasp and properly design an architecture like MSA, which shares these structural parallels.

These days, MSA is quite popular. Many developers seem to focus primarily on how to use MSA components like API gateways, gRPC, or message brokers. But the most fundamental prerequisite is a deep understanding of OOP.

6.2. Agile Methodology

In Agile, designers, developers, and planners work as a close-knit team—a collaboration style that can be likened to OOP’s core principle of high cohesion and low coupling. This approach is crucial for productivity and efficiency not only in software development, but also in organizational structure and teamwork.

Although an Agile team isn’t structurally identical to an OOP “object,” there is a similarity in how the person who produces the data (the planner, creating requirements) and the person who consumes it (the developer, implementing those requirements) are part of the same unit. This somewhat mirrors how, inside an object, data (properties) and functions (methods) are tightly coupled.

Additionally, the iterative development process—one of Agile’s core principles—becomes more feasible with OOP’s separation of concerns. OOP models a system as independent objects interacting with each other, which lets you develop and test each object independently. This dovetails neatly with Agile’s emphasis on small development cycles and rapid feedback. Consequently, OOP is recognized as a major underlying technique for successful Agile software development.

7. Conclusion

We began with the following statement:

Object-oriented programming is a programming paradigm that groups data and functions into a single object, thereby increasing cohesion and reducing coupling.

Indeed, the essence of OOP is grouping data and functions into one object. However, many articles and videos explaining OOP focus on encapsulation, information hiding, polymorphism, and inheritance. Those four basic OOP principles are simply guidelines for how to group data and functions. The most fundamental, indispensable part is grouping data and functions together.

Can there be an object that only has data? Or an object that has only functions? Even if you use class syntax, that doesn’t necessarily make it a true object. An object has meaning only when it includes both state (properties) and behavior (methods).

You might feel you’ve grasped something after reading this post, but it won’t become concrete until you try refactoring your own code into an object-oriented style. Understanding what makes “good code” takes a lot of thought and practice.

Modern development methodologies are grounded in OOP. If you don’t have a deep understanding of OOP, you’ll find it challenging to properly apply TDD, DDD, Agile, MSA, and other modern approaches. That’s one major reason many MSA projects exist but relatively few succeed.

A collection of common patterns used in OOP is known as Design Patterns. We’ll cover that next time.