The debate over classes vs functions is one of the internet's favorite distractions. But in practice, the real question isn’t “OOP vs FP” — it’s “How do we define and construct clear boundaries around behavior and dependencies?”

This isn’t a debate about keywords — it’s a conversation about intentional construction.


🧱 Constructed Boundaries > Singleton Modules

I often see patterns where modules default-export ambient objects:

// logger.ts
export const logger = {
  log: (msg: string) => console.log(msg),
};

As I’ve worked in larger-scale codebases, I tend to avoid this approach. Instead, I prefer to define a constructor — either a function or class — as a way to defer construction and inject dependencies.

This style allows clearer boundaries around configuration and collaborators, leading to more testable and maintainable systems.


🔁 Composable Construction

Let’s start with a simple, practical example of dependency injection — a logger that receives configuration as a parameter, not from a global module or static value.

import { z } from "zod";

export const LoggerConfigSchema = z.object({
  prefix: z.string(),
  level: z.number().optional(),
});

export type LoggerConfig = z.infer<typeof LoggerConfigSchema>;


export const LogLevelDebug = 0;
export const LogLevelInfo = 1;
export const LogLevelWarn = 2;
export const LogLevelError = 3;

Now, here are two ways to define a logger that consumes this config:

Both of the following examples define an explicit, testable, dependency-injected logger — one with a class, one with a function.

Class-Based Logger

class ConsoleLogger {
  constructor(private config: LoggerConfig) {}

  #shouldLog(level: LogLevel): boolean {
    if (this.config.level === undefined) {
      return LogLevelInfo >= level;
    }
    return this.config.level >= level;
  }

  #format(level: string, message: string): string {
    return `[${this.config.prefix}] ${level.toUpperCase()}: ${message}`;
  }

  log(msg: string) {
    if (this.#shouldLog(LogLevelInfo)) {
      console.log(this.#format('log', msg));
    }
  }

  error(msg: string) {
    if (this.#shouldLog(LogLevelError)) {
      console.error(this.#format('error', msg));
    }
  }

  withContext(ctx: string) {
    return new ConsoleLogger({
      ...this.config,
      prefix: `${this.config.prefix}:${ctx}`,
    });
  }
}

Factory-Based Logger

function createLogger(config: LoggerConfig) {
  function shouldLog(level: LogLevel): boolean {
    return (config.level ?? LogLevelInfo) >= level;
  }

  function format(level: string, msg: string): string {
    return `[${config.prefix}] ${level.toUpperCase()}: ${msg}`;
  }

  return {
    log(msg: string) {
      if (shouldLog(LogLevelInfo)) {
        console.log(format('log', msg));
      }
    },
    error(msg: string) {
      if (shouldLog(LogLevelError)) {
        console.error(format('error', msg));
      }
    },
    withContext(ctx: string) {
      return createLogger({
        ...config,
        prefix: `${config.prefix}:${ctx}`,
      });
    },
  };
}

Why It Matters

Both are great — because both:

  • Are constructed explicitly
  • Accept config and collaborators
  • Expose a clean, testable public API
  • Allow the consumer to decide whether the instance should be singleton, transient, or context-bound
  • Enable environment-specific wiring of dependencies rather than hardwiring them through static linkage

This flexibility is especially valuable in testing and modular architectures. And despite what it might look like at a glance — setting up these patterns doesn’t take much longer than writing the static version.

In fact, many engineers agree with the idea of composition and clean separation — yet we often spend more time debating whether it’s too much boilerplate than it would take to actually implement it. Setting up patterns like these — a simple constructor, an injectable utility, a boundary-aware service — typically takes no more than 5–10 minutes each, and even less as you get more fluent with the pattern. This isn't extra ceremony; it's optionality you can afford. It's a small investment that pays off in flexibility, clarity, and ease of change — especially as your system grows.

🧩 And while only one of them uses the class keyword, both are conceptually defining a class. The presence of new or prototype isn’t what matters. What matters is the boundary — and whether you construct it cleanly.

Personally, I prefer using class for most of my production code. I find it helps clearly separate dependencies, internal state, and external behavior. It also allows me to group private helpers in a natural way, and IDEs tend to provide better support — from navigation to inline documentation — when using classes.

Now let’s revisit the idea of configuration itself being injected — not globally loaded.

// config.ts
export class ConfigService {
  constructor(private readonly record: Record<string, any>) {}

  private getAnyValue(keys: string[]): any | undefined {
    let cur: any | undefined = this.record;
    const keySize = keys.length;
    for (let idx = 0; idx < keySize; idx++) {
      if (Array.isArray(cur)) {
        if (idx < keySize - 1) {
          return;
        }
      }

      if (typeof cur === "object") {
        const key = keys[idx] as string;
        cur = cur[key];
      }
    }
    return cur;
  }

  get<T>(key: string): T {
    if (!key) throw Error("empty key is not allowed");
    return this.getAnyValue(key.split(".")) as T;
  }

  getSlice<T>(key: string): T[] {
    if (!key) throw Error("empty key is not allowed");
    return this.getAnyValue(key.split(".")) as T[];
  }
}

// config-loader.jsonc.ts
export async function loadJsoncConfig(): Promise<Record<string, any>> {
  const configPath = path.join(process.cwd(), "config.jsonc");
  // This could also be injected. We're leaving it hardcoded for this example to keep the focus on
  // demonstrating how different parts can be composed and swapped later.
  const configContent = await fs.readFile(configPath, "utf-8");
  return JSONC.parse(configContent) as Record<string, any>;
}

Finally, here’s how we wire that config service into our logger:

async function main() {
  const rawConfig = await loadJsoncConfig();
  const configService = new ConfigService(rawConfig);
  // This separation makes it easy to swap different file formats or config loading mechanisms —
  // whether it's JSON, env vars, remote endpoints, or CLI flags.
  const rawLoggerConfig = configService.get("logger");
  const loggerConfig = LoggerConfigSchema.parse(rawLoggerConfig);

  const logger = new ConsoleLogger(loggerConfig);


  // continue application setup...
  // e.g., pass logger into your server, router, or DI container
}

This highlights the pattern: you can defer construction, inject dependencies, and compose behavior cleanly — without relying on global state or static linkage.

This isn’t just a pattern for enterprise-scale systems. Even in small prototypes, defining boundaries early makes it easier to stub things, swap implementations, or integrate with evolving environments. The upfront cost is low — and the downstream flexibility is real.

Here’s a quick comparison of the two approaches:

Pattern Characteristics Pros Cons
Singleton / Ambient Module Shared instance via global import Simple, fast for small projects Hard to test, inflexible
Constructed Component Built via constructor or factory, passed explicitly Composable, testable, modular — even in prototypes Slightly more setup upfront (usually 5–10 mins max, or instance if you have a good vibe😉)

☕ Java & Go Comparison: You're Already Doing This

Before we dive into the structured, interface-based versions, it’s worth noting that Java and Go also have ambient-style patterns — the equivalent of a default-exported singleton in JavaScript:

Java – Static Logger

public class StaticLogger {
    public static void log(String msg) {
        System.out.println(msg);
    }
}

Go – Package-Level Function

package logger

import "fmt"

func Log(msg string) {
    fmt.Println(msg)
}

These work for small programs, but they tend to leak dependencies and make configuration or testing harder — much like ambient modules in JavaScript.

If you're writing Java, you're already familiar with this pattern:

public interface Logger {
    void log(String msg);
    Logger withContext(String ctx);
}

public class ConsoleLogger implements Logger {
    private final String prefix;

    public ConsoleLogger(String prefix) {
        this.prefix = prefix;
    }

    public void log(String msg) {
        System.out.println("[" + prefix + "] " + msg);
    }

    public Logger withContext(String ctx) {
        return new ConsoleLogger(prefix + ":" + ctx);
    }
}

In Go, you'd write something very similar:

type Logger interface {
    Log(msg string)
    WithContext(ctx string) Logger
}

type ConsoleLogger struct {
    Prefix string
}

func (l *ConsoleLogger) Log(msg string) {
    fmt.Printf("[%s] %s\n", l.Prefix, msg)
}

func (l *ConsoleLogger) WithContext(ctx string) Logger {
    return ConsoleLogger{Prefix: l.Prefix + ":" + ctx}
}

In both cases, you're constructing with dependencies, and explicitly wiring in behavior. You avoid ambient state and expose a small surface area for consumers.

You never default-export a global Logger instance in Java or Go. You construct, inject, and isolate. That’s the same idea we’re advocating here — just with different syntax.


🚪 A Word on Object-Oriented Discipline

As complexity grows, classes can offer some ergonomic benefits:

  • Grouping behavior and helpers using private or # methods
  • Better IDE discoverability and navigation
  • Easier organization of lifecycle-bound internal state

However, it’s important not to over-apply OOP conventions. I generally avoid subclassing and prefer composition over inheritance — not because inheritance is inherently wrong, but because it often introduces tight coupling and fragile hierarchies. When behavior needs to be shared, I favor helpers, delegates, or injected collaborators.

Likewise, I avoid protected methods. I find it cleaner to stick with public and private, keeping the object interface clear and the internals encapsulated.

The takeaway here isn’t that classes win — it’s that clarity wins. Whether you’re using class syntax or a factory function, the important part is that you’re being deliberate about boundaries and dependencies.

And once again, the key isn't the class keyword — it's the pattern of construction.


🧭 Conclusion: Construct, Don’t Just Declare

Use a class. Use a factory. Use a struct in Go or a POJO in Java.

The real question is:

Are you constructing your boundaries, or leaking them via ambient state?

That’s what makes your codebase adaptable — not the presence of class, but the presence of intention. (Or struct, if you're using Go. Or table, if you're writing Lua 😉)

Start small. Inject later. Boundaries give you leverage.

This isn’t about trying to predict every possible future feature — it’s about shaping your code so that the behavior you define is easily composable, and composed behaviors are much easier to reason about. There’s a meaningful difference between designing for flexibility and overengineering for speculation.