Functional Principles Still Matter
There’s a wide range of opinions when it comes to functional principles. Out of the many points people debate, I tend to value the following: Declarativeness Immutability Composition Purity However, humans perceive the real world through side effects and imperative steps. Describing real-world problems purely through functional abstractions can be challenging — or at least requires a significant shift in perspective. Building a world full of functional declarativeness often demands multiple layers of indirection, which may not always be intuitive or practical. Consider some common realities: Configuration pulled from the environment APIs that fail User input that mutates internal state Business logic that depends on time, randomness, or context Despite these constraints, certain problems are better described — and understood — when a few functional principles are applied. So rather than force the world to fit a functional model, I’ve landed on a middle ground. I primarily strive for predictability and composability in my systems, and I’ll use whatever principles — functional or otherwise — that help me get there. And functional principles do help me get there. Purity and Composition As we briefly touched on earlier, it's nearly impossible — and not particularly practical — to achieve 100% purity in most functions. But we can still define boundaries such that the behaviors feel pure, even if the environment around them is not. Let’s start with a simple validator: function validate( schemaService: SchemaService, schemaId: string, value: unknown, ): void { // schemaService invokes side effects, so the function is not pure } This function isn’t pure — it depends on schemaService, which might fetch or mutate data behind the scenes. But just like in mathematics, we can often reason about complex systems by introducing constraints. If we constrain the behavior of schemaService — say, through interface boundaries, mocks, or scoped inputs — then validate() becomes more predictable. That’s often good enough. In testing, we already do this implicitly: we mock schemaService to make the function behave in a controlled way. But now consider composability: Use in Another Function function validateSchemaAndDoMore(metadata: Map) { validate( schemaService, // where should it come from? metadata["schemaId"], metadata["value"], ); } When using validate() elsewhere, we need to provide schemaService. In practice, that leaves us with a few choices: Pass dependencies down through injection chains Import them directly as singletons (tight coupling) Pass a container or context to retrieve dependencies Extend the Function to Include Notification Here’s two ways to compose validate() with a notification system: Option 1: Fully Injected function validateAndNotify( notificationService: NotificationService, schemaService: SchemaService, schemaId: string, value: unknown, ) { try { validate(schemaService, schemaId, value); notificationService.ok(); } catch (err: unknown) { notificationService.err(err); } } Option 2: Fully Imported import schemaService from './schema-service'; import notificationService from './notification-service'; function validateAndNotify( schemaId: string, value: unknown, ) { try { validate(schemaService, schemaId, value); notificationService.ok(); } catch (err: unknown) { notificationService.err(err); } } Each has trade-offs: One pushes dependencies through the call chain (verbose, but explicit) The other hides them behind module imports (convenient, but tightly coupled) This is where objects or classes come into play — not because we want to model the world with objects, but because they help structure the environment around behaviors. interface Schema { validate(value: unknown): void; } function KnownSchemaValidator(worldAroundMe: any) { const schemaId = "knownSchemaId"; const schemaService = worldAroundMe.schemaService; return { validate(value: unknown) { // validation logic here } }; } function CoreBusinessValidator(worldAroundMe: any) { const validator = KnownSchemaValidator(worldAroundMe); const notificationService = worldAroundMe.notificationService; return { validate(value: unknown) { // core business validation + notification } }; } By injecting “the world around me,” we isolate external dependencies while preserving pure-ish behavior contracts. In other words: we separate what we control (function parameters) from what we manage (injected services), creating the illusion of purity within well-defined constraints. Of course, if a function can be truly pure — that’s ideal. But wrapping it in a factory or an object to inject external context isn’t about over-engineering for every possible future. It’s a small, intentional step that makes extension easier later. And if my assumptions turn out

There’s a wide range of opinions when it comes to functional principles. Out of the many points people debate, I tend to value the following:
- Declarativeness
- Immutability
- Composition
- Purity
However, humans perceive the real world through side effects and imperative steps. Describing real-world problems purely through functional abstractions can be challenging — or at least requires a significant shift in perspective.
Building a world full of functional declarativeness often demands multiple layers of indirection, which may not always be intuitive or practical.
Consider some common realities:
- Configuration pulled from the environment
- APIs that fail
- User input that mutates internal state
- Business logic that depends on time, randomness, or context
Despite these constraints, certain problems are better described — and understood — when a few functional principles are applied.
So rather than force the world to fit a functional model, I’ve landed on a middle ground.
I primarily strive for predictability and composability in my systems, and I’ll use whatever principles — functional or otherwise — that help me get there.
And functional principles do help me get there.
Purity and Composition
As we briefly touched on earlier, it's nearly impossible — and not particularly practical — to achieve 100% purity in most functions. But we can still define boundaries such that the behaviors feel pure, even if the environment around them is not.
Let’s start with a simple validator:
function validate(
schemaService: SchemaService,
schemaId: string,
value: unknown,
): void {
// schemaService invokes side effects, so the function is not pure
}
This function isn’t pure — it depends on schemaService, which might fetch or mutate data behind the scenes. But just like in mathematics, we can often reason about complex systems by introducing constraints.
If we constrain the behavior of schemaService — say, through interface boundaries, mocks, or scoped inputs — then validate() becomes more predictable. That’s often good enough.
In testing, we already do this implicitly: we mock schemaService to make the function behave in a controlled way.
But now consider composability:
Use in Another Function
function validateSchemaAndDoMore(metadata: Map<string, string>) {
validate(
schemaService, // where should it come from?
metadata["schemaId"],
metadata["value"],
);
}
When using validate() elsewhere, we need to provide schemaService. In practice, that leaves us with a few choices:
- Pass dependencies down through injection chains
- Import them directly as singletons (tight coupling)
- Pass a container or context to retrieve dependencies
Extend the Function to Include Notification
Here’s two ways to compose validate() with a notification system:
Option 1: Fully Injected
function validateAndNotify(
notificationService: NotificationService,
schemaService: SchemaService,
schemaId: string,
value: unknown,
) {
try {
validate(schemaService, schemaId, value);
notificationService.ok();
} catch (err: unknown) {
notificationService.err(err);
}
}
Option 2: Fully Imported
import schemaService from './schema-service';
import notificationService from './notification-service';
function validateAndNotify(
schemaId: string,
value: unknown,
) {
try {
validate(schemaService, schemaId, value);
notificationService.ok();
} catch (err: unknown) {
notificationService.err(err);
}
}
Each has trade-offs:
- One pushes dependencies through the call chain (verbose, but explicit)
- The other hides them behind module imports (convenient, but tightly coupled)
This is where objects or classes come into play — not because we want to model the world with objects, but because they help structure the environment around behaviors.
interface Schema {
validate(value: unknown): void;
}
function KnownSchemaValidator(worldAroundMe: any) {
const schemaId = "knownSchemaId";
const schemaService = worldAroundMe.schemaService;
return {
validate(value: unknown) {
// validation logic here
}
};
}
function CoreBusinessValidator(worldAroundMe: any) {
const validator = KnownSchemaValidator(worldAroundMe);
const notificationService = worldAroundMe.notificationService;
return {
validate(value: unknown) {
// core business validation + notification
}
};
}
By injecting “the world around me,” we isolate external dependencies while preserving pure-ish behavior contracts.
In other words: we separate what we control (function parameters) from what we manage (injected services), creating the illusion of purity within well-defined constraints.
Of course, if a function can be truly pure — that’s ideal. But wrapping it in a factory or an object to inject external context isn’t about over-engineering for every possible future.
It’s a small, intentional step that makes extension easier later.
And if my assumptions turn out wrong, I can revisit the boundary and rethink the contract — no sunk cost, just an adaptation.
As long as we maintain good boundaries and clear contracts, we can reason about and test these functions as if they were pure — even when they’re not.
I’ve found far more success in structuring for purity than in chasing purity for its own sake.
This mindset applies across the board — whether I’m shaping business rules, transforming data, or designing system behaviors.
Immutability
Immutability is a great tool to use to improve predictability.
If a value is declared as immutable and never changes, great — that’s easy to reason about.
But if a value is mutable, and that’s clearly declared and understood — that’s fine too.
The real problem isn’t mutation — it’s unexpected mutation.
Some languages help you with this through the type system (like TypeScript, Rust, or Elm).
Others, like JavaScript or Python, leave it to convention and discipline.
Either way, the goal is the same:
Declare your intent.
Stick to that intent.
The intent itself is a contract.
That’s usually good enough.
Build Behavior from Small Pieces
Functional thinking isn’t just about functions — it’s about how we structure systems.
I’ve found the most success in systems that are composed from focused units — each owning a clear behavior or decision. This works better than trying to centralize everything into one orchestrator or “God class.”
Some patterns I lean on:
- Keep orchestration shallow — let domain logic live in reusable units
- Group behavior by cohesion, not inheritance
- Compose systems by layering contracts, not hard-coded call chains
It’s about making change easier — and more localized — when the system grows.
Functional Principles Aren’t the Opposite of OOP
Functional and object-oriented principles can (and often should) coexist.
- Use classes to own lifecycle and boundaries
- Use functions to express clear, testable logic
- Use DI to wire them together without magic
In the end, all of this is in service of one thing: clarity.
Conclusion
Functional programming offers some great tools — but the value comes from how we apply them, not whether we follow the paradigm perfectly.
- Purity gives us control over behavior.
- Immutability gives us clarity over data.
- Composition gives us structure and scalability.
Code can be structured in a way that achieves all of the above — even if, underneath, it’s not written in a purely functional style.
With clear boundaries and well-defined contracts, systems become easier to understand, test, and extend.
And whether I’m working in a class-heavy codebase or a function-first one, those principles still apply.