From Everything Is an Object to Function Composition: The Programming Paradigms I've Used
Introduction: What Are We Actually Arguing About?
Why I Want to Talk About This
As everyone knows, every good programmer thinks their own code is poetry and everyone else's is a disaster. Arguments about paradigms are really arguments about whose poetry scans better. I only started thinking seriously about programming paradigms after leaving my Java comfort zone. Until then, the textbook trio of encapsulation, inheritance, and polymorphism had mostly been concepts to memorize.
When I first wrote Java, I easily confused "enough abstraction" with "good code." A feature with concrete classes but no interfaces always felt incomplete. A conditional without a Strategy or Factory seemed insufficiently official. Controllers called Services, Services called Managers, and Managers called Repositories, while data traveled back and forth between DTOs, BOs, Entities, and VOs. There were plenty of files, neatly arranged layers, and even a peculiar sense of big-project security whenever I opened the IDE.
After writing more code and gradually picking up TypeScript and Rust, I realized that some abstractions really did isolate change. Others simply spread a ten-line idea across ten files.
The code hadn't become easier to change. I just had to jump to definitions more often.
That experience became my starting point for thinking about paradigms. To me, they offer different ways to organize code when dealing with state, change, computation, and side effects.
To borrow a line from The Rust Course: every choice is a tradeoff. Most so-called best practices simply make the most common tradeoffs on your behalf. In an actual project, we still have to decide where we're willing to put the complexity.
A Language Usually Spans Multiple Paradigms
A programming paradigm is a fundamental way of describing and organizing computation.
Different paradigms emphasize different questions:
Imperative programming asks, "What step runs next?"
Object-oriented programming asks, "Which object should be responsible for this?"
Functional programming asks, "What transformations turn the data into a result?"
Declarative programming asks, "What do I want?"
Event-driven programming asks, "Who should respond when something changes?"
Languages rarely belong to just one paradigm. Haskell leans strongly toward functional programming, Smalltalk treats almost everything as an object, and C is a natural fit for procedural programming. TypeScript, Rust, Python, and Scala accommodate a wider range of styles.
Rather than rushing to label a language, I'd first ask: given the capabilities it offers, what is a suitable way to solve this particular problem?
I'll start with the most direct approach, procedural programming, then focus on OOP, which I know best and most want to complain about, and functional programming, which I personally prefer.
Common Programming Paradigms
Procedural: Make the Steps Clear
Procedural programming is usually considered a way to organize imperative programming: change program state through a sequence of instructions, then package those instructions into functions or procedures.
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
}Its strengths are directness and control. Execution order and resource usage are usually clear. Scripts, systems programming, performance hotspots, and tasks that already have well-defined steps are good fits.
The downside is that shared mutable state becomes harder to reason about as the program grows. C, Pascal, and Fortran are classic examples, and Go also often uses a clear procedural style.
OOP: Useful, but You Really Don't Need to Abstract Everything
What OOP Was Trying to Solve
At the heart of object-oriented programming (OOP), objects encapsulate their own state and behavior, and systems emerge from collaboration between objects.
Textbooks usually list encapsulation, inheritance, and polymorphism. In practice, I think encapsulation and polymorphism provide the lasting value. These days I treat inheritance as a tool to use carefully, rather than a ritual that OOP requires.
A well-designed object should protect a boundary. Outside code tells it what to do instead of reaching in and changing its internal state. Callers depend on stable capabilities, while implementation details stay behind the boundary.
In engineering practice, though, this can easily slide from "use objects to establish boundaries" into "create an object for everything first." OOP ceremony can feel like putting on a suit and tie for work every morning, even when your job is hauling bricks.
Java-Style Bloat: Turning Simple Features into More Files
Blaming Java for everything would be unfair. Modern Java has lambdas, streams, record, sealed classes, and switch expressions, making it much more flexible than early Java. But its long-established enterprise culture does make it easy to turn patterns into rituals. It's like assigning every function a personal butler whose only job is to open the door. I've put up with this for far too long.
Suppose the requirement is simply "give members a 10% discount on orders." In some projects, it quickly grows into this:
discount/
├── DiscountStrategy.java
├── AbstractDiscountStrategy.java
├── VipDiscountStrategy.java
├── NormalDiscountStrategy.java
├── DiscountStrategyFactory.java
├── DiscountStrategyContext.java
└── DiscountStrategyType.javaThe Service then asks the Factory for a Strategy, puts it into a Context, and finally performs a calculation that is essentially just price * 0.9.
Of course this structure is "extensible." But will the requirements actually grow in the direction we imagined? If they don't, that extensibility is just a complexity tax paid in advance.
In TypeScript, the same thing can start with a function type:
type Discount = (price: number) => number;
const vipDiscount: Discount = (price) => price * 0.9;
const noDiscount: Discount = (price) => price;
const calculatePrice = (price: number, discount: Discount) => discount(price);If discounts eventually need external dependencies, caching, state, or a complex lifecycle, there's still time to turn them into objects. Abstraction should follow change, not run ahead of the requirements and wait for them.
Another classic is the nesting doll of data objects:
OrderRequestDTO
-> OrderCreateBO
-> OrderEntity
-> OrderDetailVO
-> OrderResponseDTODifferent models have valid reasons to exist: API inputs, domain objects, and database structures shouldn't remain permanently coupled. But if four types have identical fields and every conversion mechanically calls setId, setName, and setStatus, the change hasn't been isolated. The reader has.
My test for this kind of code is simple: does this layer actually have its own constraints or reasons to change? If not, another layer is usually just another layer.
Where OOP Gets Awkward
Polymorphism for Its Own Sake
Design patterns originally compensated for limits in a language's expressiveness, but some people treat them as the language itself. Behavior that is naturally a single transformation gets wrapped in a one-method interface, several implementation classes, and a factory. Early Java lacked convenient first-class functions, so there were historical reasons for this. In a language with function values, a function is often a smaller, more direct strategy.
An object makes sense when an implementation needs resources, state, or several related behaviors. If all it has is apply(input), my first question is: why can't it just be a function?
Inheritance Hides Control Flow
AbstractBaseHandler, BaseServiceImpl, and TemplateProcessor may appear to reuse code while scattering the actual execution order across four or five levels of parent classes.
A method's behavior may come from parent hooks, subclass overrides, framework proxies, and annotation-driven aspects. There may be only one line at the call site, but understanding it means climbing the inheritance tree. The higher you climb, the more it feels like archaeology.
The real difficulty is the strong implicit contract between parent and child classes. A seemingly safe change to a parent can quietly change every subclass. If composition can express the relationship, inheritance usually isn't my first choice.
Mutable Objects Create Temporal Coupling
Consider this API:
Order order = new Order();
order.setItems(items);
order.setCoupon(coupon);
order.calculatePrice();
order.confirm();Must calculatePrice() run before confirm()? Can setCoupon() run after confirm()? Is an empty order just created with new even a valid state?
When correctness depends on the order of method calls, the object may encapsulate data without really encapsulating its constraints. A large collection of setters also lets it pass through many half-valid states during its lifetime.
"Every computing problem is ultimately a state-machine problem. The remaining question is whether you've drawn the state machine."
—The famous (just kidding) programmer Ech0xff
A better design might require all necessary data at construction and use explicit methods for state transitions. Or it might use immutable data and pure functions that return new states. The choice matters less than making illegal states difficult to create.
Abstraction Makes Things Easier to Find and Harder to Read
Interfaces are useful for isolating truly unstable boundaries, such as databases, payment providers, and third-party services. But when every class has an interface with only one implementation, readers who find UserService must jump to IUserService just to confirm it adds no information. Its sole purpose may be to make you Ctrl+Click a few more times.
Such interfaces satisfy dependency inversion in form without enabling meaningful substitution. Needing replaceable dependencies in tests doesn't mean every internal class must have an interface in advance. Often, only the actual system boundaries deserve abstraction.
Where OOP Really Works Well
Complaints aside, OOP still has strengths. Some concepts are naturally expressed as objects.
Entities with Identity and a Lifecycle
An order isn't just data to edit at will. It has a unique identity, states such as pending payment, paid, and shipped, and constraints on transitions between them.
type OrderStatus = "pending" | "paid" | "shipped";
class Order {
#status: OrderStatus = "pending";
constructor(readonly id: string) {}
pay() {
if (this.#status !== "pending") {
throw new Error("只有待支付订单可以付款");
}
this.#status = "paid";
}
ship() {
if (this.#status !== "paid") {
throw new Error("只有已支付订单可以发货");
}
this.#status = "shipped";
}
}The value of this class lies in protecting order state and ensuring that every change uses a valid entry point. This encapsulation has a clear business meaning.
System Boundaries That Need Substitution
Databases, object storage, payment gateways, and message queues may have several implementations and often need substitutes in tests. A small, stable interface is a good way to describe their capabilities:
interface PaymentGateway {
charge(orderId: string, amount: number): Promise<string>;
}Production can inject a real payment provider, while tests inject an in-memory implementation. The caller doesn't care about network protocols or SDK details, only about charging a payment and returning a transaction ID.
Components That Naturally Have Internal State
Editor documents, network connections, caches, game entities, and UI components have clear identities and lifecycles. Keeping related behavior and state in one object is often easier to maintain than having a dozen functions manipulate the same raw data.
So I draw a clear boundary for OOP: objects should represent real boundaries, not serve as expensive boxes around functions.
Functional: I Prefer to See Code as Data Transformations
Why I Like Functional Programming
Functional programming (FP) favors composing programs from functions, reducing mutable state, and placing side effects at clear boundaries.
It is often associated with these ideas:
Functions are first-class values that can be passed as arguments and returned as results.
A pure function's result depends only on its inputs.
Data should be immutable where possible; an update produces a new value.
Expressions and composition describe computation, reducing repeated changes to intermediate state.
Types enumerate possible outcomes so exceptional cases don't disappear into control flow.
My preference for FP has nothing to do with code length or whether map and filter look sophisticated. What draws me to it is the certainty it offers locally.
When I see a pure function, I only need to consider its inputs and outputs. I don't have to guess whether it changes a global variable, updates a singleton, or depends on another method having run first. The system still has side effects involving databases, networks, and time, but pushing them to the boundaries keeps the most error-prone business calculations simple.
I Went Through a Functional Purist Phase Too
When I first discovered FP, I briefly thought OOP was worthless. I was almost obsessively determined to refactor everything into functional code. Seeing a class made my fingers itch, and I wanted to delete the last new in the project. You could call me "the programmer who respects OOP most in the world" (my admittedly unscientific theory is that every functional programmer was once an OOP victim who later became a traitor).
Chains of map, filter, and reduce looked like beautiful pipelines, while currying and composition broke rules into fine-grained pieces. I found this code exquisitely elegant. Classes, inheritance, and object state all seemed like baggage from a bygone era.
For a while, I would even take code like this:
class SocketClient {
constructor(private readonly socket: WebSocket) {}
send(message: string) {
this.socket.send(message);
}
close() {
this.socket.close();
}
}And deliberately refactor it into a factory function:
const createSocketClient = (socket: WebSocket) => ({
send: (message: string) => socket.send(message),
close: () => socket.close(),
});The moment I turned that class into a factory function, I felt I'd nailed OOP to the cross. Looking back, I had merely found another way to encapsulate a WebSocket. Both versions do exactly the same thing: wrap a mutable WebSocket and its related behaviors. Both send and close have side effects. The second version only replaces a private field with a closure; the underlying model hasn't changed.
That experience gradually loosened my fixation on syntax. FP really helped me extract core logic such as price calculations, validation, and state transitions from the outside environment, keeping it pure and composable. Connections, caches, and sessions already have lifecycles; leaving them as objects is often simpler.
TypeScript: Functions Are Lightweight Abstractions
The earlier discount strategy is one example. In TypeScript, functions can naturally be passed around and composed as rules, without first creating a set of strategy classes.
type Discount = (price: number) => number;
const threshold =
(minimum: number, discount: number): Discount =>
(price) =>
price >= minimum ? price - discount : price;
const percentage =
(rate: number): Discount =>
(price) =>
price * (1 - rate);
const applyDiscounts = (price: number, discounts: readonly Discount[]) =>
discounts.reduce((current, discount) => discount(current), price);
const finalPrice = applyDiscounts(200, [threshold(100, 20), percentage(0.1)]);Both threshold(100, 20) and percentage(0.1) create new rules. The rules have no hidden state; they simply describe a transformation from one price to another. Adding a discount only requires a function that matches the Discount type.
This solves the same problem as the Strategy Pattern, but functions are already strategies natively supported by the language. There's no need to simulate them with classes.
This style also feels natural for collection processing:
type Item = Readonly<{
price: number;
quantity: number;
}>;
type Order = Readonly<{
status: "pending" | "paid" | "cancelled";
items: readonly Item[];
}>;
const paidRevenue = (orders: readonly Order[]) =>
orders
.filter((order) => order.status === "paid")
.flatMap((order) => order.items)
.map((item) => item.price * item.quantity)
.reduce((total, lineTotal) => total + lineTotal, 0);Reading from top to bottom gives you the business meaning: select paid orders, flatten their items, calculate each line's amount, and add everything up. There are no loop indices or accumulators modified across multiple branches.
Chaining has limits too. filter, flatMap, and map create intermediate arrays. For millions of records or a performance hotspot, one loop may be more suitable. FP can make code easier to reason about, but performance still needs case-by-case analysis.
TypeScript: Union Types Put the Possibilities in Plain Sight
A functional programmer's happiest moment: finishing the code, passing the type checker, and realizing there's no need to run it at all.
Another approach I really like is representing business outcomes with discriminated unions:
type PaymentResult =
| { type: "approved"; transactionId: string }
| { type: "declined"; reason: string }
| { type: "retryable"; retryAfter: number };
const assertNever = (value: never): never => {
throw new Error(`未处理的支付结果:${JSON.stringify(value)}`);
};
const resultMessage = (result: PaymentResult): string => {
switch (result.type) {
case "approved":
return `支付成功:${result.transactionId}`;
case "declined":
return `支付失败:${result.reason}`;
case "retryable":
return `${result.retryAfter} 秒后重试`;
default:
return assertNever(result);
}
};Unlike a PaymentResultDTO with mostly nullable fields, the union explicitly describes three mutually exclusive states. An approved result always has a transactionId; a declined result always has a reason.
If a fourth outcome is added later, assertNever(result) causes a type error wherever a new branch is missing. Types don't just label data; they help check business completeness.
Rust: Iterators Turn Loops into Composable Computation
Rust is a multiparadigm language, but immutability by default, iterators, Option, Result, enums, and pattern matching all fit functional thinking well.
The revenue calculation above can be written in Rust like this:
struct Item {
price: u64,
quantity: u32,
}
struct Order {
paid: bool,
items: Vec<Item>,
}
fn paid_revenue(orders: &[Order]) -> u64 {
orders
.iter()
.filter(|order| order.paid)
.flat_map(|order| order.items.iter())
.map(|item| item.price * u64::from(item.quantity))
.sum()
}It looks much like the TypeScript version, but Rust iterators are lazy. filter, flat_map, and map only compose the computation steps. They run when sum() consumes the iterator, without creating an intermediate array for every step.
This is exactly the kind of abstraction I like: declarative data flow at the top, with the compiler optimizing it into something close to a handwritten loop. Readability and performance don't have to be opposites.
Rust: Result and ? Make Errors Composable Too
Java's checked exceptions try to force callers to handle errors, which is a reasonable aim. In practice, though, this often becomes layers of throws, catching and rewrapping exceptions, or throwing a broader exception just to make the compiler happy.
Rust puts potentially failing computations into an ordinary return value, Result<T, E>:
fn load_config(path: &Path) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path)?;
let config = toml::from_str(&content)?;
validate(config)
}The meaning of ? is straightforward: extract a successful value and continue, or return the error to the caller. The error path isn't hidden as an abrupt jump; it remains in the function signature and can be composed with methods such as map, and_then, and map_err.
Enums and pattern matching also make state modeling comfortable:
enum PaymentResult {
Approved { transaction_id: String },
Declined { reason: String },
Retryable { retry_after: u64 },
}
fn result_message(result: PaymentResult) -> String {
match result {
PaymentResult::Approved { transaction_id } => {
format!("支付成功:{transaction_id}")
}
PaymentResult::Declined { reason } => {
format!("支付失败:{reason}")
}
PaymentResult::Retryable { retry_after } => {
format!("{retry_after} 秒后重试")
}
}
}Rust checks whether a match covers every branch. Add a state to an enum, and the compiler points out every place that needs updating. That's much more reassuring than reaching some obscure corner at runtime and discovering that the default branch returns null.
Functional Programming Has Costs Too
After all that praise, I should also discuss its problems.
Higher-order functions, closures, recursion, algebraic data types, and more advanced abstractions all have a learning cost.
An obsession with point-free style can make code so short that you can no longer tell where the arguments went.
Real programs inevitably have side effects. Databases and networks don't vanish because we like pure functions.
Frequently copying large data structures to preserve immutability can have a real performance cost in some situations.
Wrapping ordinary business logic in complex Functors or Monad Transformers can become another form of abstraction for its own sake. (Monads are useful, but there seem to be more articles explaining them than people using them.)
The functional community can worship abstraction too. It just replaces Factory and Abstract with Category and Monad.
What I really like are plain functional principles: prefer immutability and pure functions, make data flow clear, and let types express the possibilities. Deeper theoretical abstractions can wait until a problem actually needs them.
Other Paradigms
I haven't used the following paradigms as deeply as FP and OOP, so I'll keep these introductions brief. Their categories sometimes overlap, and a system usually exhibits several of these traits at once.
Declarative
Declarative programming describes what you want and leaves the details of how to do it to the underlying system.
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE status = 'paid'
GROUP BY customer_id;SQL doesn't ask developers to iterate over data, maintain groups, and accumulate amounts manually. The database chooses an execution plan. HTML, CSS, and regular expressions also have clear declarative qualities.
This is a good fit for queries, rules, and UI descriptions. It offers compact expression and room for optimization underneath. In exchange, we give up some control and may have to understand complex execution mechanisms when the abstraction leaks.
Event-Driven and Reactive
Event-driven programming organizes systems around publishing and handling events. Reactive programming goes further, treating data that changes over time as streams.
These approaches fit browser interactions, WebSockets, message queues, real-time data, and asynchronous services. JavaScript/Node.js is naturally event-driven, RxJS provides stream composition, Elm emphasizes reactive UIs, and Erlang/Elixir excel at concurrent systems built from processes and messages.
The main costs come from time: events can arrive out of order, be duplicated, go missing, or pile up. The complete control flow is also distributed across consumers. Once business logic depends on events, idempotency, backpressure, retries, and tracing need serious attention.
Logic Programming
Logic programming describes problems through facts, rules, and queries, leaving the runtime to derive results. Prolog, Datalog, and Mercury are common examples.
It suits expert systems, permission inference, constraint solving, knowledge graphs, and some static analysis scenarios. For problems rich in relationships and rules, it can closely express the domain. Search performance, debugging methods, and team familiarity also limit its use in general business applications.
A Quick Comparison
Paradigm | Main Question | Suitable Uses |
|---|---|---|
Imperative / Procedural | What happens next? | Scripts, algorithms, low-level control, performance hotspots |
Object-Oriented | Who is responsible? | Entities with identity and lifecycles, replaceable boundaries |
Functional | How is data transformed? | Business calculations, data processing, concurrency-friendly core logic |
Declarative | What result do I want? | Data queries, UI and rule descriptions |
Event-Driven / Reactive | How do changes propagate? | UIs, messaging, real-time and asynchronous systems |
Logic | Which facts and rules hold? | Inference, constraints, rule systems |
In Real Projects, I Mix Them
Real projects rarely use just one paradigm. An ordinary backend service can use all of these at once:
Declarative SQL to query data.
Objects to encapsulate database connections, payment gateways, and entities with lifecycles.
Pure functions to calculate prices, permissions, and states.
Imperative code to orchestrate the steps of a request.
Events to notify auditing, messaging, and downstream systems.
My favorite structure these days is Functional Core, Imperative Shell: keep core calculations as pure as possible, and let the outer layer coordinate side effects.
async function checkout(
orderId: string,
repository: OrderRepository,
payment: PaymentGateway,
publish: (event: OrderPaid) => Promise<void>,
) {
const order = await repository.find(orderId);
// 函数式核心:输入订单和规则,得到价格,不访问外部环境。
const amount = calculateTotal(order, discountRules);
// 命令式外壳:明确安排数据库、网络和事件的先后顺序。
const transactionId = await payment.charge(order.id, amount);
const paidOrder = markAsPaid(order, transactionId);
await repository.save(paidOrder);
await publish({ type: "order.paid", orderId, transactionId });
return paidOrder;
}Here, OrderRepository and PaymentGateway are external boundaries well suited to OOP interfaces. calculateTotal and markAsPaid are pure functions that are easy to test. checkout openly performs imperative orchestration.
Network requests don't need a whole suite of unfamiliar abstractions just to look functional, and every calculation doesn't need to live in a Service class just to look object-oriented. Different parts carry different kinds of complexity, so they can naturally use different paradigms.
Conclusion: Paradigms Should Help Us Reduce Complexity
Moving from Java to TypeScript and Rust, my focus gradually shifted from camps to problems. I no longer adopt a style simply because it looks professional.
OOP is good at encapsulating state with identity and a lifecycle, and at isolating system boundaries that really need substitution. Meaningless interfaces, factories, inheritance, and layers only make simple problems more expensive.
FP helps me see data flow more clearly and makes business calculations easier to test, compose, and reason about. But it can also lead to excessive abstraction, and it cannot eliminate real-world side effects.
Imperative, declarative, event-driven, and logic programming each have their place too. In mature code, each part should use an expression that is simple enough and suitable enough. There's no need to chase paradigm purity.
If a function can express the problem clearly, don't create a class yet.
If an object really protects state and boundaries, don't dismantle it for functional purity either.
Ultimately, paradigms are tools. Code must accommodate change. Our technical identities matter less than whether the code can survive until the next version.