This is post #2 in our How It Works series. Our running example is building a task management app — a simplified Trello for small dev teams. In this post, we cover the step that separates experienced engineers from everyone else: understanding the problem before writing a single line of code.
The number one mistake developers make is jumping straight to code. Someone mentions an idea and within an hour there is a half-built prototype with zero clarity on what the software actually needs to do.
Before you open your editor, answer two questions:
If you cannot answer both clearly, you are not ready to build. Every hour spent understanding the problem saves ten hours of rework later.
Requirements come from the people who will use your software. Proven techniques:
Ask “What are you trying to accomplish?” not “What feature do you want?” For our task app, interviews revealed a consistent theme: nobody knew who was working on what.
Distill your findings into a single, clear problem statement — specific, measurable, and free of solution language.
Bad: “We need a Kanban board.”
Good: “Small development teams lose track of who is working on what, leading to duplicated effort and missed deadlines.”
The bad version assumes a solution. The good version describes the pain. Write it down and put it somewhere visible — every decision should trace back to it.
Every project operates within boundaries. Map them early:
Technical: Budget, timeline, team size, tech stack knowledge, infrastructure limits.
Business: Compliance requirements (GDPR, SOC 2), required integrations (Slack, GitHub), expected user base size.
For our app: 2 developers, 6-week timeline, GitHub integration required, targeting teams of 5–20 people.
User stories translate the problem into buildable units: “As a [role], I want [feature], so that [benefit].”
Modeling stories as structured data ensures every story is complete and traceable:
public class UserStory {
private String role;
private String action;
private String benefit;
private List<String> acceptanceCriteria;
public UserStory(String role, String action, String benefit) {
this.role = role;
this.action = action;
this.benefit = benefit;
this.acceptanceCriteria = new ArrayList<>();
}
public void addCriteria(String criteria) {
this.acceptanceCriteria.add(criteria);
}
public String format() {
return String.format(
"As a %s, I want to %s, so that %s.",
role, action, benefit
);
}
public static void main(String[] args) {
UserStory story = new UserStory(
"team lead",
"create tasks and assign them to team members",
"everyone knows who is responsible for what"
);
story.addCriteria("Lead can create a task with title and description");
story.addCriteria("Lead can assign a task to any team member");
story.addCriteria("Assigned member receives a notification");
System.out.println(story.format());
}
}
User stories tell you what to build. Acceptance criteria tell you when it is done — testable conditions that leave no room for ambiguity.
For “As a developer, I want to update my task status,” the criteria might be:
The best way to ensure criteria are testable is to write them as executable tests:
import pytest
from enum import Enum
from datetime import datetime
class TaskStatus(Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
IN_REVIEW = "IN_REVIEW"
DONE = "DONE"
class Task:
def __init__(self, title, assignee):
self.title = title
self.assignee = assignee
self.status = TaskStatus.TODO
self.history = []
def update_status(self, new_status, changed_by):
if changed_by != self.assignee and changed_by.role != "lead":
raise PermissionError("Only assignee or lead can update")
self.history.append({
"from": self.status, "to": new_status,
"by": changed_by.name, "at": datetime.now()
})
self.status = new_status
class User:
def __init__(self, name, role="developer"):
self.name = name
self.role = role
def test_task_starts_as_todo():
task = Task("Build login page", User("Alice"))
assert task.status == TaskStatus.TODO
def test_assignee_can_update_status():
alice = User("Alice")
task = Task("Build login page", alice)
task.update_status(TaskStatus.IN_PROGRESS, alice)
assert task.status == TaskStatus.IN_PROGRESS
def test_lead_can_update_any_task():
alice = User("Alice")
lead = User("Bob", role="lead")
task = Task("Build login page", alice)
task.update_status(TaskStatus.IN_REVIEW, lead)
assert task.status == TaskStatus.IN_REVIEW
def test_non_assignee_cannot_update():
task = Task("Build login page", User("Alice"))
with pytest.raises(PermissionError):
task.update_status(TaskStatus.DONE, User("Charlie"))
def test_status_change_records_history():
alice = User("Alice")
task = Task("Build login page", alice)
task.update_status(TaskStatus.IN_PROGRESS, alice)
assert len(task.history) == 1
assert task.history[0]["from"] == TaskStatus.TODO
assert task.history[0]["to"] == TaskStatus.IN_PROGRESS
Each test maps directly to an acceptance criterion. When all tests pass, the story is done. No debate, no ambiguity.
The projects that succeed spend more time in this phase than you expect. The projects that fail skip it entirely. Resist the urge to start coding immediately. When you feel the pull to “just start building,” write a problem statement instead. If you cannot write one that is clear and specific, you do not understand the problem yet — and your code will not solve it.
In the next post, we move into Finding the Solution — where we explore possible approaches, evaluate trade-offs, and choose the right path forward.