I Built a Python App Using Only Functions — No Classes Allowed

No classes. No self. Just pure functions.

I Built a Python App Using Only Functions — No Classes Allowed
Photo by Gloria Babić on Unsplash

Forget OOP for a second. Can Python still sing with just functions?

I Built a Python App Using Only Functions — No Classes Allowed

Python is known for its flexibility. You can go full OOP with classes, methods, and inheritance. Or you can lean into functional programming. Most of us mix the two without thinking much about it.

But I wanted to push the limits.
What if I built a complete Python application — no classes, no objects — just pure functions?

Could I manage complexity, structure data cleanly, and keep the code maintainable?

I decided to find out.

Why Even Try This?

Before I dive into how I did it, here’s why I even bothered:

  • Challenge Conventional Thinking: OOP is often the default, especially in Python tutorials. I wanted to see the “other side.”
  • Simplify: Functions are simpler to understand and test individually. No hidden state. No deep hierarchies.
  • Functional Curiosity: Languages like JavaScript, Haskell, and Clojure promote functional purity. Python can too — to an extent.

The App I Built: A Minimalist Task Manager

To test this, I built a CLI-based task manager app with the following features:

  • Add a task
  • Mark task as complete
  • View tasks (filtered by status)
  • Delete tasks
  • Save/load tasks to a JSON file

Nothing huge — but enough logic and data flow to challenge a function-only design.

The Rules I Set

To stay true to the experiment, I followed these rules:

  • No class definitions at all
  • No use of self, __init__, or class-based design patterns
  • Use only functions, dicts, and basic data structures
  • The code must be readable and modular

Designing Without Classes: How I Structured It

Without classes, how do you organize things?
Answer: with modules and functions.

I broke the code into small functional units, grouped logically into modules:

1. task_store.py

Handles reading/writing to the JSON file.

import json 
 
def load_tasks(filename): 
    try: 
        with open(filename, 'r') as f: 
            return json.load(f) 
    except FileNotFoundError: 
        return [] 
 
def save_tasks(filename, tasks): 
    with open(filename, 'w') as f: 
        json.dump(tasks, f, indent=2)

2. task_logic.py

Handles the core operations on tasks.

from uuid import uuid4 
 
def create_task(description): 
    return { 
        'id': str(uuid4()), 
        'description': description, 
        'completed': False 
    } 
 
def mark_complete(tasks, task_id): 
    return [ 
        {**t, 'completed': True} if t['id'] == task_id else t 
        for t in tasks 
    ] 
 
def delete_task(tasks, task_id): 
    return [t for t in tasks if t['id'] != task_id]

3. cli.py

Takes user input and calls the right functions.

def print_tasks(tasks): 
    for t in tasks: 
        status = "✅" if t['completed'] else "❌" 
        print(f"{t['id']} - {t['description']} [{status}]")

Notice: No classes, no methods — just pure functions that return new data or perform side effects.

The Good: Why This Worked Surprisingly Well

1. Testability Went Through the Roof

Each function was a small, predictable unit. Easy to write tests for.

def test_create_task(): 
    task = create_task("Write Medium article") 
    assert task['description'] == "Write Medium article" 
    assert not task['completed']

No mocking needed. No object state. Just input → output.

2. Zero Side Effects by Default

Most functions didn’t mutate the original list of tasks. Instead, they returned a modified copy. This immutability mindset made debugging much easier.

3. Readability and Reusability

When everything is just a function, it’s easy to read and reuse pieces. You don’t need to instantiate anything or wonder about the internal state.

The Not-So-Good: Where It Got Messy

1. Shared State Is Awkward Without OOP

In a class, you’d hold self.tasks and update it in-place. With just functions, you have to pass tasks around everywhere. It got verbose:

tasks = load_tasks('tasks.json') 
tasks = mark_complete(tasks, some_id) 
save_tasks('tasks.json', tasks)

Not the end of the world, but it added clutter.

2. Namespace Collisions Can Happen

Without classes to scope methods, your module-level function names can start to overlap:

# Is this `create_task` or `create_user`?

You need good naming discipline — or more modularization.

3. No Built-in Encapsulation

In OOP, you can hide internal state or behavior. Here, everything is exposed. Anyone can modify the task dict however they like.

You can enforce some structure with TypedDict or dataclass, but that violates the "no classes" rule. So I stuck with dicts and discipline.

Would I Do This Again?

Absolutely — for certain use cases.

When Functions Shine:

  • Small utilities or CLI tools
  • Data transformations (ETL jobs)
  • Scripting, automation
  • Stateless microservices

When OOP is Better:

  • Complex stateful systems
  • GUIs or games with many interacting entities
  • Frameworks or extensible APIs

This experiment reminded me that you don’t need OOP to write clean, structured Python.
It also showed me the importance of why we choose a paradigm — not just going with defaults.


Final Thoughts: Don’t Marry a Paradigm

This wasn’t about proving that functions are “better.” It was about rethinking what’s necessary and useful.

Python is powerful because it supports multiple paradigms.
Try limiting yourself to one — even for a weekend — and see what you learn.

I came out with cleaner code, better testing habits, and a renewed respect for simplicity.


If you’re stuck in OOP-land, take a break. Build something with just functions. It’ll change the way you think.

Photo by Simone Hutsch on Unsplash