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 ofnew
orprototype
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.