DEV Community

Rex
Rex

Posted on • Edited on

Part 2 – Constructing Boundaries (Not Just Using Classes)

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), }; 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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}`, }); } } 
Enter fullscreen mode Exit fullscreen mode

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}`, }); }, }; } 
Enter fullscreen mode Exit fullscreen mode

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>; } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

Go – Package-Level Function

package logger import "fmt" func Log(msg string) { fmt.Println(msg) } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

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} } 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)