Keep Calm and Start with a Function
Programming comes with layers of abstraction. Primitives, functions, classes, each one building on the last.
The problem is, most of us reach for the higher layers too early. We write classes before we need them, inject dependencies we don't yet require, and design for a future that never arrives.
This post is a rule of thumb for starting new projects, with Python as our lens.
The idea is simple: start at the lowest layer of abstraction that solves your problem, and only move up when the code tells you to.
The core argument: complexity should be introduced, not assumed. Every layer of abstraction you add is a layer someone has to understand. This is cognitive overhead, the mental effort required to work with your code. The less of it you impose, the faster you and your team move.
Starting Simple
If something can be a primitive, don't make it a function.
If something can be a function, don't make it a method on a class.
This sounds obvious, but it's remarkable how often we ignore it.
Consider something as basic as configuration:
# This is enough.MAX_RETRIES = 3TIMEOUT_SECONDS = 30BASE_URL = "https://api.example.com"# This is not needed.class Config:_instance = Nonedef __new__(cls):if cls._instance is None:cls._instance = super().__new__(cls)return cls._instancedef __init__(self):self.max_retries = 3self.timeout_seconds = 30self.base_url = "https://api.example.com"
A singleton to hold three values that never change. You've added object lifecycle, instantiation logic, and a pattern that exists to solve a problem modules already solve for free, since a Python module is a singleton. The module-level constants are importable, readable, and simple.
The same applies to logic. If you have a piece of business logic that takes an input and returns an output, it's a function:
# Just a function.def calculate_discount(price: float, tier: str) -> float:multipliers = {"bronze": 0.05, "silver": 0.10, "gold": 0.15}return price * multipliers.get(tier, 0)# Not everything needs a class.class DiscountCalculator:def __init__(self, tier: str):self.tier = tierself.multipliers = {"bronze": 0.05, "silver": 0.10, "gold": 0.15}def calculate(self, price: float) -> float:return price * self.multipliers.get(self.tier, 0)
The class version binds the tier to an instance, which means you now manage object lifecycle for something that is, at its core, a stateless computation. The function version is easier to test, easier to compose, and easier to understand. This is not about classes being bad, it's about reaching for them before you need them. This is premature abstraction, abstracting for extensibility before you know what needs extending.
Structure Over Injection
Dependency injection is often presented as a best practice. And in some contexts it is.
But more often than not, it's a sign that the code is already more complex than it needs to be. If you need to inject dependencies into a class just to test it, maybe the problem is the class, not the test.
Lets start with the simplest structure for a project. Lets start with three folders: models, services, and helpers.
This way we will encounter no circular dependenices, and keep it easily understandable.
Models, Your data structures. These are the things your application talks about. In Python, these are your dataclasses, Pydantic models, ORM models. Classes make sense here because models have identity, they represent something.
# models/order.pyfrom dataclasses import dataclassfrom datetime import datetime@dataclassclass Order:id: strcustomer_id: strtotal: floatcreated_at: datetimestatus: str = "pending"
Services, Your business logic. These are functions, not classes. A service takes models in, calls other services or helpers if needed, and returns results. Services are where the actual work happens. They are project-specific and represent your domain logic.
# services/order_service.pyfrom models.order import Orderfrom services.tax_service import calculate_taxdef create_order(customer_id: str, items: list[dict]) -> Order:subtotal = sum(item["price"] * item["qty"] for item in items)tax = calculate_tax(subtotal, region="EU")return Order(id=generate_id(),customer_id=customer_id,total=subtotal + tax,created_at=datetime.now(),)def cancel_order(order: Order) -> Order:if order.status == "shipped":raise ValueError("Cannot cancel a shipped order")return Order(**{**vars(order), "status": "cancelled"})
# services/tax_service.pyTAX_RATES = {"EU": 0.21, "US": 0.08, "UK": 0.20}def calculate_tax(amount: float, region: str) -> float:return amount * TAX_RATES.get(region, 0)
Helpers, Pure, project-agnostic utility functions. These are the functions you could copy into any project and they would still work. They don't know about your models, your database, or your domain. They take inputs and return outputs with no side effects.
# helpers/formatting.pydef format_currency(amount: float, currency: str = "EUR") -> str:symbols = {"EUR": "€", "USD": "quot;, "GBP": "£"}return f"{symbols.get(currency, currency)} {amount:,.2f}"
With this structure, your business logic lives in plain functions that are trivially testable. No mocking frameworks, no dependency injection containers, no abstract base classes. You testcalculate_taxby calling it with a number and checking the result. You testcreate_orderby passing in data and inspecting what comes back.
The need for dependency injection often disappears when your code is structured as functions with clear inputs and outputs. You don't need to inject aTaxCalculatorinto anOrderServiceclass, you just import and call a function.
Consumers
Now that your business logic is clean, testable, and living in service functions, you need to expose it.
This is where consumers come in: APIs, queue subscribers, scheduled jobs, CLI commands, webhooks, triggers. They all fall in the same category.
A consumer should be a thin layer. It handles the protocol, HTTP, AMQP, cron, and delegates to a few service calls at most. It deserializes the input, calls the service, and serializes the output. That's it.
# api/routes/orders.pyfrom fastapi import APIRouterfrom services.order_service import create_order, cancel_orderfrom services.notification_service import notify_customerrouter = APIRouter()@router.post("/orders")def post_order(payload: OrderRequest):order = create_order(payload.customer_id, payload.items)notify_customer(order.customer_id, f"Order {order.id} confirmed")return {"order_id": order.id, "total": order.total}@router.post("/orders/{order_id}/cancel")def post_cancel_order(order_id: str):order = get_order(order_id)cancelled = cancel_order(order)return {"status": cancelled.status}
# workers/order_events.py# A queue subscriber, same pattern, different protocol.def handle_order_created(message: dict):order_id = message["order_id"]order = get_order(order_id)generate_invoice(order)send_confirmation_email(order)
The consumer doesn't contain logic. It's glue. If your API handler is longer than ~15 lines, it's probably doing too much, extract the logic into a service function. This keeps your consumers interchangeable. The same business logic that powers your REST API can power a CLI tool, a queue worker, or a scheduled job, because the logic doesn't live in the consumer.
Keep It Simple
This is worth being explicit about.
If it doesn't need a wrapper, don't write one. If a library already does what you need, use it directly.
# You don't need this.class DatabaseWrapper:def __init__(self, connection):self.connection = connectiondef execute(self, query, params=None):return self.connection.execute(query, params)# Your ORM or driver already does this.# Just use it directly.db.execute("SELECT * FROM orders WHERE id = ?", [order_id])
# You don't need this either.class Logger:def __init__(self, name):self.logger = logging.getLogger(name)def info(self, msg):self.logger.info(msg)# Python's logging module is already designed for this.import logginglogger = logging.getLogger(__name__)logger.info("Order created")
Every wrapper you write is a wrapper someone has to learn, maintain, and debug. Before writing one, ask: what does this give me that the underlying tool doesn't already provide? If the answer is "a nicer interface", that's often not worth the cognitive overhead of another layer. Wrappers make sense when you need to isolate a third-party dependency or add cross-cutting behavior. But wrapping something for the sake of wrapping it is just adding code that needs to be maintained.
Where It Went Wrong
The "everything is a class" philosophy didn't come from nowhere. It came from Java and C#.
In Java, you literally cannot write a function outside of a class. Even public static void main lives on a class. This was a deliberate design choice, but it became a constraint that shaped how an entire generation of developers thought about code.
C# followed the same model, and for years, enterprise software meant class hierarchies, factory patterns, abstract base classes, and interface-driven design. The code worked, but the cognitive overhead was enormous. You'd open a project and find IOrderServiceFactory, AbstractOrderServiceFactoryImpl, and a OrderServiceFactoryProvider, all to do what a function call could do.
PHP had its own chapter in this story. Early PHP was function-based, you'd include files and call functions globally. As projects grew, name collisions became a real problem. Two libraries defining a function with the same name would break everything.Namespaces were introduced in PHP 5.3 to solve this, and classes became the de facto way to organize code, not because OOP was the best model for the problem, but because classes gave you scoping that the language didn't otherwise provide.
This is an important distinction: classes in PHP (and Java, and C#) were often adopted for organizational reasons, not because the problems being solved were inherently object-oriented. The class became a namespace, not a model of behavior.
The modern landscape tells a different story.
React moved from class components to function components, and the ecosystem followed. Hooks replaced lifecycle methods. State management moved from class instances to function closures.
Go doesn't have classes at all. It has structs and functions. Methods exist, but they're just functions with a receiver, no inheritance, no constructors, no this keyword in the traditional sense.
Rust has structs and traits, but no class hierarchy. Composition over inheritance isn't a guideline in Rust, it's the only option.
Zig takes it further, no hidden control flow, no operator overloading, no OOP. Just structs, functions, and explicit behavior.
Nim supports objects but favors procedures and functional composition. The community style leans heavily toward plain functions.
The trend is clear: modern languages are moving toward functions and composition, and away from class hierarchies. Not because OOP is useless, but because most problems don't need it.
Where It Started
To understand how we got here, it's worth going back to where object-oriented programming began.
Smalltalk, created by Alan Kay in the 1970s, was the language that gave rise to the term "object-oriented programming." In Smalltalk, everything truly was an object, numbers, strings, booleans, even code blocks. Messages were sent between objects, and the entire system was designed around this metaphor. Ironically, Kay's original vision of OOP was centered on messaging between objects, not classes and inheritance, he has since said he regrets the term "objects" because it caused people to focus on the lesser idea.
It was elegant. It was consistent. And it was designed for a world where the programmer and the runtime were deeply intertwined, you worked inside the Smalltalk environment, building and modifying live objects.
Then came the Gang of Four, Gamma, Helm, Johnson, and Vlissides, who published Design Patterns: Elements of Reusable Object-Oriented Software in 1994. The book cataloged 23 patterns for solving common problems in OOP: Factory, Singleton, Observer, Strategy, and so on.
The book was brilliant for its time. But it had an unintended side effect.
Developers started applying patterns everywhere, even when the problem didn't call for one. The patterns became a checklist rather than a toolkit. "Which pattern should I use?" replaced "What's the simplest solution?"
Many GoF patterns, Strategy, Command, Observer, become easier to implement in modern languages. Trying to implement these in different languages, keeping the simplest possible implementation in mind, reveals a lot about specific language constraints and how they influence design decisions.
In Python, a Strategy can be a function passed as an argument, a Command can be a callable, an Observer can be a list of callbacks. The full patterns encode more than that, but for most use cases, the simpler version is enough.
What's often overlooked is that the GoF book itself advocates "favor composition over inheritance", promoting reusability and flexibility over rigid inheritance trees. The book was ahead of its time, and to this day holds its place as one of the most influential books in software development.
Make It Work. Make It Better. Improve.
So how do you put all of this into practice?
Here's the process I follow, and the one I'd recommend when starting any new feature or project.
Make it work.
Write out the business logic as functions with the fewest dependencies possible. Focus on scaffolding the building blocks of your feature. Don't worry about the final shape, nobody knows what the finished project looks like when they start. Discovery happens as you build, and keeping things flexible is what lets you move fast.
# First pass, just make it work.def process_signup(email: str, name: str) -> dict:if not email or "@" not in email:raise ValueError("Invalid email")user = {"email": email, "name": name, "status": "pending"}# TODO: save to database# TODO: send welcome emailreturn user
Make it better.
After it works, reflect on the building blocks and refactor. Prioritize pure functions, functions with no side effects that always return the same output for the same input. Clean up repeated logic. Move things into the right folders. Split services that are doing too much.
# Second pass, clean it up.# helpers/validation.pydef is_valid_email(email: str) -> bool:return bool(email) and "@" in email# services/signup_service.pyfrom helpers.validation import is_valid_emailfrom services.user_service import create_userfrom services.email_service import send_welcome_emaildef process_signup(email: str, name: str) -> User:if not is_valid_email(email):raise ValueError("Invalid email")user = create_user(email=email, name=name)send_welcome_email(user)return user
Improve.
Reflect on the final solution. Add TODOs where you know improvements are needed but they're not blocking. Open issues and tasks for follow-up work. Add comments where the logic isn't self-evident. Write a brief README if the module is complex enough to warrant one. And close the work properly, don't leave it in a half-finished state.
# Third pass, leave it better than you found it.# services/signup_service.pyfrom helpers.validation import is_valid_emailfrom services.user_service import create_userfrom services.email_service import send_welcome_email# TODO: Add rate limiting per IP, see issue #142# TODO: Support OAuth signups, planned for Q3def process_signup(email: str, name: str) -> User:"""Create a new user account and send a welcome email.Raises ValueError if the email is invalid."""if not is_valid_email(email):raise ValueError("Invalid email")user = create_user(email=email, name=name)send_welcome_email(user)return user
This process works because it respects how we actually build software. We don't know everything upfront. Requirements change. Scope shifts. By starting with the simplest building blocks, functions with clear inputs and outputs, you give yourself room to adapt. Refactoring a function is cheap. Refactoring a class hierarchy is not.
Make it work first. Organize it second. Polish it third. And at every step, resist the urge to add complexity you don't need yet.
No Golden Rule
Much like life, no one has ever figured it out.
There is no golden rule for every situation, no one-size-fits-all solution. Just to shed some light to the issue, here are a few patterns that are commonly found out in the wild.
UI / Presentation Patterns
Model–View–Controller (MVC), Model–View–ViewModel (MVVM), Model–View–Presenter (MVP), Model–View–Intent (MVI), Presentation–Abstraction–Control (PAC), Hierarchical MVC (HMVC), Model–View–Adapter (MVA), ...
Core Architecture / Layering
Layered Architecture (N-tier / 3-tier), Hexagonal Architecture (Ports and Adapters), Clean Architecture, Onion Architecture, ...
Modularity & Codebase Organization
Modular Programming, Component-Based Architecture, Package by Feature (Feature-Oriented Programming), Package by Layer, ...
Domain & Responsibility Separation
Domain-Driven Design (DDD), Entity–Control–Boundary (ECB), ...
Modern / Practical Code Organization Styles
Vertical Slice Architecture, Screaming Architecture, ...
There are many, many more approaches one could take, and we haven't even touched on microservices, monorepos, and cloud architecture.
The truth is, most of the projects we work on start as POCs. Some make it to MVPs. Some make it to proper products. Out of the ones who made it that far, only a small portion will have enough users to encounter scaling issues. Only a portion of these will have codebases that could benefit from using a more complicated structure.
When that day comes, refactoring a software project that was built in a simple manner, that is well documented, that relies on pure functions instead of complex class hierarchies, that focused on containing and avoiding complexity and prioritizes ease of understanding, will be easy.
It is easy to go from simple to complex, and the countless hours saved along the way will make a difference.
Requirements change, our understanding evolves, and so does the code.
Keep Calm
The best code I've worked on wasn't clever. It wasn't wrapped in layers of abstraction. It was simple, obvious, and easy to change.
So please, keep calm, and start with a function.