You have your requirements documented, your system designed, and your API contracts agreed upon. Now it is time to build. But implementation is not just “start coding.” It is a disciplined process that separates professional engineers from hobbyists. In this post, we walk through how to turn a design into working software, using our running example: building a task management app (like a simplified Trello).
Before writing a single line of business logic, establish your foundation. A clean project setup saves you hundreds of hours over the life of a project.
Set up four things immediately:
.gitignore for your language. Make your first commit the empty project skeleton.A clean project structure for our task management app looks like this:
task-manager/ ├── src/ │ ├── main/java/com/taskmanager/ │ │ ├── domain/ # Entities and value objects │ │ ├── service/ # Business logic │ │ ├── controller/ # REST endpoints │ │ ├── repository/ # Data access │ │ └── config/ # App configuration │ └── test/java/com/taskmanager/ ├── docker-compose.yml ├── Dockerfile ├── .gitignore └── README.md
This structure tells any developer exactly where to find things. Domain logic is isolated. Controllers are thin. Configuration is centralized. This is not accidental — it is intentional design.
The most important rule of implementation: build your core business logic first, independent of any framework. Your domain layer should work without Spring Boot, without Flask, without a database. It is pure logic.
Why? Because business rules change slowly. Frameworks change fast. If your business logic is tangled with your framework, every upgrade becomes a rewrite.
Here is the TaskService for our task management app in Java:
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Task createTask(String title, String description, Long assigneeId) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Task title cannot be empty");
}
Task task = new Task();
task.setTitle(title.trim());
task.setDescription(description);
task.setAssigneeId(assigneeId);
task.setStatus(TaskStatus.TODO);
task.setCreatedAt(LocalDateTime.now());
return taskRepository.save(task);
}
public Task getTask(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found: " + id));
}
public Task updateStatus(Long id, TaskStatus newStatus) {
Task task = getTask(id);
validateStatusTransition(task.getStatus(), newStatus);
task.setStatus(newStatus);
task.setUpdatedAt(LocalDateTime.now());
return taskRepository.save(task);
}
private void validateStatusTransition(TaskStatus current, TaskStatus next) {
if (current == TaskStatus.DONE && next == TaskStatus.TODO) {
throw new InvalidStatusTransitionException(
"Cannot move a completed task back to TODO"
);
}
}
}
And the same logic in Python using Flask:
from datetime import datetime
from enum import Enum
class TaskStatus(Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
DONE = "DONE"
class TaskService:
def __init__(self, task_repository):
self.task_repository = task_repository
def create_task(self, title, description=None, assignee_id=None):
if not title or not title.strip():
raise ValueError("Task title cannot be empty")
task = {
"title": title.strip(),
"description": description,
"assignee_id": assignee_id,
"status": TaskStatus.TODO.value,
"created_at": datetime.utcnow().isoformat()
}
return self.task_repository.save(task)
def get_task(self, task_id):
task = self.task_repository.find_by_id(task_id)
if not task:
raise LookupError(f"Task not found: {task_id}")
return task
def update_status(self, task_id, new_status):
task = self.get_task(task_id)
self._validate_status_transition(task["status"], new_status)
task["status"] = new_status
task["updated_at"] = datetime.utcnow().isoformat()
return self.task_repository.save(task)
def _validate_status_transition(self, current, new_status):
if current == TaskStatus.DONE.value and new_status == TaskStatus.TODO.value:
raise ValueError("Cannot move a completed task back to TODO")
Notice what both implementations share: input validation, clear error handling, and a status transition rule that enforces business logic. The framework is irrelevant. The logic is identical. That is the sign of a well-designed domain layer.
With your domain layer solid, the API layer becomes a thin wrapper. Controllers should do three things: accept the request, call the service, and return the response. Nothing more.
Here is a Spring Boot controller for the task endpoints we designed in the previous post:
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody CreateTaskRequest request) {
Task task = taskService.createTask(
request.getTitle(),
request.getDescription(),
request.getAssigneeId()
);
return ResponseEntity.status(HttpStatus.CREATED).body(task);
}
@GetMapping("/{id}")
public ResponseEntity<Task> getTask(@PathVariable Long id) {
return ResponseEntity.ok(taskService.getTask(id));
}
@PatchMapping("/{id}/status")
public ResponseEntity<Task> updateStatus(
@PathVariable Long id,
@RequestBody UpdateStatusRequest request) {
Task task = taskService.updateStatus(id, request.getStatus());
return ResponseEntity.ok(task);
}
}
The controller is deliberately thin. There is no business logic here. No validation beyond what Spring handles automatically. Every decision lives in TaskService where it can be tested independently.
Code without version control discipline is a liability. Every professional team follows a branching strategy. Here is a practical workflow that scales from two developers to two hundred:
# Create a feature branch from main git checkout main git pull origin main git checkout -b feature/task-status-update # Make small, focused commits as you work git add src/main/java/com/taskmanager/service/TaskService.java git commit -m "Add status transition validation to TaskService" git add src/test/java/com/taskmanager/service/TaskServiceTest.java git commit -m "Add tests for status transition rules" # Push and open a pull request git push origin feature/task-status-update # After code review and CI passes, merge via pull request # Delete the feature branch after merge git branch -d feature/task-status-update
The key rules:
Writing code that works is the minimum bar. Writing code that others can read, maintain, and extend is the real job. Follow these practices consistently:
Use meaningful names. validateStatusTransition is clear. check is not. A good name eliminates the need for a comment.
Keep functions small. Each function should do one thing. If you need to scroll to read a function, it is too long. The TaskService above has four methods, each under fifteen lines. That is intentional.
Follow SOLID principles. Single Responsibility means your TaskService handles task logic and nothing else. It does not send emails, generate reports, or manage users. Each of those gets its own service.
Handle errors explicitly. Notice that TaskService throws specific exceptions: TaskNotFoundException, InvalidStatusTransitionException. Never swallow exceptions silently. Never return null when you mean “not found.”
Write tests alongside your code. Not after. Not “when you have time.” Every method you write should have a corresponding test before you move to the next method. Tests are not optional — they are part of the implementation.
There is a well-known principle in software engineering: “Make it work, make it right, make it fast” — in that order.
Make it work means get a functioning solution that passes your tests. Do not worry about elegance or performance. Just make it correct.
Make it right means refactor for clarity. Clean up names, extract methods, remove duplication. This is where SOLID principles and clean code practices come in.
Make it fast means optimize, but only where you have measured a bottleneck. Premature optimization is the root of countless engineering disasters. Profile first, then optimize the hotspot.
Most engineers skip step two and jump straight to step three. They end up with fast code that no one can read or maintain. Resist the urge. Readable code that performs adequately will always beat clever code that no one can debug at two in the morning.
Implementation is where engineering discipline matters most. Set up your project cleanly. Build the domain layer first. Keep your controllers thin. Use version control like a professional. Write code that your future self will thank you for. Follow the mantra: make it work, make it right, make it fast. In that order, always.
March 19, 2020In the previous post, we turned our task management app idea into wireframes. Now it is time to turn those wireframes into something real. This is where you define how the system works, not just how it looks.
Wireframes show structure — where elements go on the page. Design answers everything else: visual identity, interaction patterns, error states, and the system architecture underneath.
For our task app, the wireframes showed boards, columns, and draggable cards. Now we answer the hard questions: How does the frontend talk to the backend? What does the data look like? What happens when two users drag the same card?
Design runs on two parallel tracks:
Both must stay in sync. A beautiful drag-and-drop UI backed by an API that cannot handle reordering is a design failure.
Before writing code, define the high-level architecture. Our task app uses a classic client-server pattern with a REST API layer:
Frontend (React SPA) --HTTPS/JSON--> API Gateway (Nginx) --HTTP--> Backend (REST API) --SQL--> Database (PostgreSQL)
| Component | Responsibility | Technology |
|---|---|---|
| Frontend | UI rendering, user interactions, local state | React, TypeScript |
| API Gateway | Routing, rate limiting, SSL, authentication | Nginx or AWS ALB |
| Backend | Business logic, validation, authorization | Spring Boot or Flask |
| Database | Persistent storage, data integrity | PostgreSQL |
The API contract is the agreement between frontend and backend. Design it before implementation so both teams can work in parallel.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/boards | List all boards for the authenticated user |
| POST | /api/v1/boards | Create a new board |
| GET | /api/v1/boards/{id}/tasks | List all tasks on a board |
| POST | /api/v1/tasks | Create a new task |
| PUT | /api/v1/tasks/{id} | Update a task |
| DELETE | /api/v1/tasks/{id} | Delete a task |
| GET | /api/v1/users/me | Get current user profile |
Here is the Spring Boot controller contract defining these endpoints:
// TaskController.java — API contract for task management
@RestController
@RequestMapping("/api/v1")
public class TaskController {
public record TaskRequest(
String title, String description,
String status, Long columnId, Long assigneeId
) {}
public record TaskResponse(
Long id, String title, String description,
String status, String columnName, String assigneeName,
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
@GetMapping("/boards/{boardId}/tasks")
public ResponseEntity<List<TaskResponse>> getTasksByBoard(
@PathVariable Long boardId) {
return ResponseEntity.ok(taskService.findByBoard(boardId));
}
@PostMapping("/tasks")
public ResponseEntity<TaskResponse> createTask(
@RequestBody @Valid TaskRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(taskService.create(request));
}
@PutMapping("/tasks/{id}")
public ResponseEntity<TaskResponse> updateTask(
@PathVariable Long id,
@RequestBody @Valid TaskRequest request) {
return ResponseEntity.ok(taskService.update(id, request));
}
@DeleteMapping("/tasks/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.delete(id);
return ResponseEntity.noContent().build();
}
}
The same contract in Flask with Python dataclasses:
# task_controller.py — API contract for task management
from dataclasses import dataclass, asdict
from datetime import datetime
from flask import Flask, request, jsonify
app = Flask(__name__)
@dataclass
class TaskRequest:
title: str
description: str
status: str
column_id: int
assignee_id: int
@dataclass
class TaskResponse:
id: int
title: str
description: str
status: str
column_name: str
assignee_name: str
created_at: datetime
updated_at: datetime
@app.route("/api/v1/boards/<int:board_id>/tasks", methods=["GET"])
def get_tasks_by_board(board_id):
tasks = task_service.find_by_board(board_id)
return jsonify([asdict(t) for t in tasks]), 200
@app.route("/api/v1/tasks", methods=["POST"])
def create_task():
data = TaskRequest(**request.get_json())
return jsonify(asdict(task_service.create(data))), 201
@app.route("/api/v1/tasks/<int:task_id>", methods=["PUT"])
def update_task(task_id):
data = TaskRequest(**request.get_json())
return jsonify(asdict(task_service.update(task_id, data))), 200
@app.route("/api/v1/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
task_service.delete(task_id)
return "", 204
Both implementations define the same contract. The DTOs enforce data shape. The HTTP methods and status codes follow REST conventions. This is the specification both teams work against.
The database schema is the foundation everything rests on. Here is the schema for our task app:
| Table | Column | Type | Constraint |
|---|---|---|---|
| users | id, email, display_name, created_at | BIGINT, VARCHAR, VARCHAR, TIMESTAMP | PK, UNIQUE NOT NULL, NOT NULL, DEFAULT NOW() |
| boards | id, name, owner_id, created_at | BIGINT, VARCHAR, BIGINT, TIMESTAMP | PK, NOT NULL, FK -> users(id), DEFAULT NOW() |
| columns | id, name, board_id, position | BIGINT, VARCHAR, BIGINT, INTEGER | PK, NOT NULL, FK -> boards(id), NOT NULL |
| tasks | id, title, description, column_id, assignee_id, position, created_at, updated_at | BIGINT, VARCHAR, TEXT, BIGINT, BIGINT, INTEGER, TIMESTAMP, TIMESTAMP | PK, NOT NULL, NULLABLE, FK -> columns(id), FK -> users(id), NOT NULL, DEFAULT NOW(), DEFAULT NOW() |
Key relationships: A user owns many boards. A board has many columns. A column has many tasks. A task belongs to one column and one assignee. The position field enables drag-and-drop reordering — move a card and you update the position integers.
The UI/UX design phase turns wireframes into pixel-perfect mockups. Build a design system — a shared vocabulary of reusable components:
Tools like Figma and Adobe XD let designers and developers collaborate on the same file, inspect spacing, and export assets.
Critical principle: design for states, not just screens. Every component needs empty, loading, error, and populated state designs.
A prototype is a clickable, interactive mockup that stakeholders can experience without production code. Click “Create Task” and a modal opens. Drag a card between columns. Navigate between boards.
Prototyping serves two purposes:
Figma’s prototyping mode lets you define click targets, transitions, and flows directly on design frames. Remember: a prototype is not production code. It is a communication tool.
“Design is not how it looks, it’s how it works.” — Steve Jobs
Every hour spent on design saves five hours of rework during implementation. Focus on user experience over aesthetics. A plain-looking app with clear navigation and predictable behavior beats a stunning app that confuses users. Get the API contracts right. Get the data model right. The colors are the easy part.
In the next post, we start building. That is where implementation begins.
This is post #6 in the How It Works series. We are building a task management app (simplified Trello) as our running example. In the previous post on Planning, we defined what we are building and why. Now it is time to figure out what it looks like before writing a single line of code.
A wireframe is a low-fidelity visual blueprint of your application. It defines the layout and user flow of each screen — no colors, no fonts, no pixel-perfect polish. Think of it as the architectural blueprint you review before the construction crew shows up.
A wireframe answers three questions:
Wireframes are intentionally ugly. If you are picking shades of blue, you have wandered into design. That comes later.
Many developers skip wireframing because they think it is a designer’s job. That is a mistake. Wireframes prevent the most expensive pattern in software: building, then redesigning. Without a shared visual plan, three things go wrong:
Wireframing is cheap. Refactoring is not.
| Type | Fidelity | Tools | When to Use |
|---|---|---|---|
| Low-fidelity | Boxes, lines, labels | Pen & paper, whiteboard | Early brainstorming, first 30 minutes of a project |
| Mid-fidelity | Structured layouts with placeholder text | Balsamiq, Figma wireframe mode | Sprint planning, stakeholder reviews |
| High-fidelity | Near-final look with realistic content | Figma, Sketch, Adobe XD | Final design approval before implementation |
Start low, go higher. Begin with pen and paper. If the idea survives that conversation, move to a mid-fidelity tool. High-fidelity wireframes are worth it only when you need stakeholder sign-off before committing to code.
Let us wireframe the four core screens of our simplified Trello clone.
A centered card on a blank background. The card contains a logo placeholder at the top, an email input field, a password input field, a “Log In” button spanning the full width of the card, and a “Sign Up” link below it. No sidebar, no navigation bar. The user has one job: authenticate.
The main screen after login. A top navbar spans full width — app name on the left, user avatar dropdown on the right. Below it, three vertical columns represent statuses: To Do, In Progress, and Done. Each column contains stacked task cards (title + assignee avatar) with an “+ Add Task” button at the bottom. Classic Kanban layout.
Clicking a task card opens a modal overlay. Editable title at top, description text area below, and a right sidebar showing metadata: assignee, due date, priority, status dropdown. Comments section at the bottom. The dashboard stays dimmed behind the modal so the user retains spatial context.
Standard settings layout. Left sidebar lists setting categories: Profile, Notifications, Team Members, Integrations. The right content area changes based on the selected category. The Profile section shows avatar upload, name, and email fields. The Notifications section shows toggle switches. Keep it simple — settings pages rarely need creative layouts.
Every screen in your wireframe maps to a route, a controller method, and a template. When the wireframe is done, your routing structure writes itself.
Here is the route structure for our task app in Spring Boot (Java):
@Controller
public class TaskAppController {
@GetMapping("/login")
public String loginPage() {
// Wireframe: Login Page - centered card, email/password fields
return "login";
}
@GetMapping("/dashboard")
public String dashboard(Model model) {
// Wireframe: Board View - three columns (To Do, In Progress, Done)
List<Task> tasks = taskService.getAllTasksForUser();
model.addAttribute("tasks", tasks);
return "dashboard";
}
@GetMapping("/tasks/{id}")
public String taskDetail(@PathVariable Long id, Model model) {
// Wireframe: Task Detail Modal - title, description, metadata sidebar
Task task = taskService.getTaskById(id);
model.addAttribute("task", task);
return "task-detail";
}
@GetMapping("/settings")
public String settings() {
// Wireframe: Settings Page - sidebar categories, right content area
return "settings";
}
@GetMapping("/settings/{section}")
public String settingsSection(@PathVariable String section, Model model) {
// Wireframe: Settings subsections - profile, notifications, team, integrations
model.addAttribute("section", section);
return "settings";
}
}
And the same structure in Flask (Python):
from flask import Flask, render_template, redirect, url_for
from services import task_service
app = Flask(__name__)
@app.route("/login")
def login_page():
"""Wireframe: Login Page - centered card, email/password fields"""
return render_template("login.html")
@app.route("/dashboard")
def dashboard():
"""Wireframe: Board View - three columns (To Do, In Progress, Done)"""
tasks = task_service.get_all_tasks_for_user()
return render_template("dashboard.html", tasks=tasks)
@app.route("/tasks/<int:task_id>")
def task_detail(task_id):
"""Wireframe: Task Detail Modal - title, description, metadata sidebar"""
task = task_service.get_task_by_id(task_id)
return render_template("task_detail.html", task=task)
@app.route("/settings")
@app.route("/settings/<section>")
def settings(section="profile"):
"""Wireframe: Settings Page - sidebar categories, right content area"""
return render_template("settings.html", section=section)
if __name__ == "__main__":
app.run(debug=True)
Every route maps to a wireframe screen. Every template name matches a wireframe label. No guessing about what pages the app needs — the wireframe already defined them. This is why wireframing matters for developers: it turns vague requirements into a concrete routing table before you write any business logic.
The tool matters far less than the habit. A napkin sketch beats no wireframe every time.
Show wireframes to real users early. Not after polishing. Not after development starts. Show the rough sketch and watch what happens. Where they squint, you have a clarity problem. Where they ask “how do I get back,” you have a navigation problem. Where they ignore a section, you might not need it.
Their confusion is your UX debt — cheaper to fix now by erasing a box than later by refactoring components and rewriting endpoints.
Also: wireframe the unhappy paths. What does the screen look like with zero tasks? What happens when the API fails? If you only wireframe the golden path, you will discover edge cases in production.
With our wireframes defined and our route structure mapped, the next step in the series is Designing and Prototyping — where we take these skeletal layouts and add the visual layer: color, typography, spacing, and interaction design. The wireframe tells us what goes where. The design tells us how it looks and feels.
Post #5 in the How It Works series. We are building a task management app (simplified Trello) as our running example. Now it is time to plan the work.
“Weeks of coding can save you hours of planning.” Every senior developer has lived through a project where the team jumped straight into code, only to realize weeks later they built the wrong thing, in the wrong order, with components that do not integrate.
Poor planning leads to:
Planning forces the team to think through work before coding, surface risks early, and agree on priorities.
The most valuable planning activity is decomposition — breaking a large project into pieces small enough to understand, estimate, and assign.
| Level | Definition | Example |
|---|---|---|
| Epic | Major capability | User Management |
| Story | User-facing feature | User can register with email |
| Task | Developer work unit | Create User JPA entity |
Our task app epics:
| Epic | Key Stories |
|---|---|
| User Management | Registration, login, password reset |
| Task CRUD | Create, edit, delete, view, assign tasks |
| Board & Column Management | Create board, add columns, drag-and-drop |
| Notifications | Email on assignment, in-app alerts, daily digest |
Each epic is independent enough for a different developer. Decomposition creates parallelism.
The goal is not precision — it is shared understanding of relative size.
Task CRUD estimates:
| Story | Points | Time Range | Notes |
|---|---|---|---|
| Create task | 3 | 1-2 days | Form + API endpoint |
| Edit task | 3 | 1-2 days | Reuse form component |
| Delete task | 2 | 0.5-1 day | Soft delete + confirmation |
| View task details | 2 | 1-2 days | Detail page + activity log |
| Assign task | 5 | 2-4 days | User search, permissions, notification |
If a story estimates above 8 points, break it down further. Large estimates hide uncertainty.
Not everything ships in v1. The MoSCoW method forces explicit decisions:
| Priority | Meaning | Our Features |
|---|---|---|
| Must Have | Product does not work without it | Registration, login, create/view tasks, basic board |
| Should Have | Important but usable without it | Edit, delete, assign tasks, drag-and-drop |
| Could Have | Nice if time allows | Email notifications, task comments |
| Won’t Have | Out of scope for v1 | Daily digest, Slack integration, mobile app |
The “Won’t Have” category is the most important. Saying no explicitly prevents scope creep.
Plan in sprints — 1-2 week windows where the team commits to specific work. Only pull in what the team can finish. Teams operate at 60-70% capacity after meetings, reviews, and bugs.
Sample Sprint 1 (2-week sprint, 2 developers, ~30 points capacity):
| Story | Points | Assignee | Definition of Done |
|---|---|---|---|
| User registration | 5 | Dev A | Register, confirmation email, stored in DB |
| User login/logout | 5 | Dev A | JWT auth, session management |
| Create task | 3 | Dev B | API + UI form, task persisted |
| View task list | 3 | Dev B | Paginated list with status and assignee |
| Board with default columns | 5 | Dev B | To Do, In Progress, Done columns |
| DB schema + scaffolding | 5 | Dev A | Entities, migrations, CI pipeline |
Total: 26 points. Four-point buffer for the unexpected work that will appear.
Product planning is what to build. Technical planning is how. Agree on database schema, API contracts, and architecture decisions before writing application code.
Here is the planned Task entity in Java using JPA:
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private TaskPriority priority;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
The same schema in Python using SQLAlchemy:
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime
from sqlalchemy.orm import relationship, DeclarativeBase
from datetime import datetime
import enum
class Base(DeclarativeBase):
pass
class TaskStatus(enum.Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
DONE = "DONE"
class TaskPriority(enum.Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
CRITICAL = "CRITICAL"
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), nullable=False, default=TaskStatus.TODO)
priority = Column(Enum(TaskPriority), nullable=False, default=TaskPriority.MEDIUM)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow,
onupdate=datetime.utcnow)
assignee = relationship("User", back_populates="tasks")
Both define the same contract: ID, title, description, status, priority, assignee foreign key, and timestamps. Writing models during planning forces alignment between frontend and backend before any application code exists.
Eisenhower said it: “Plans are useless, but planning is everything.”
Your sprint plan will not survive contact with reality. A critical bug derails day three. A dependency team misses their deadline. A 3-point story turns into an 8. That is normal.
The value is the shared understanding built during the process. Decomposing together surfaces hidden dependencies. Estimating together lets the person who built something similar warn about edge cases. Prioritizing together aligns product and engineering on what “done” means.
The plan is a starting point. The process of planning is the actual product. Revisit every sprint. Update. Adjust scope. A plan that never changes is a plan nobody is looking at.
This is Part 4 of the How Software Engineering Works series. We are building a task management app (think simplified Trello) from scratch.
You have identified the problem. You have brainstormed solutions. Now comes the step most developers skip entirely: validation. Before you write a single line of production code, you need proof that your solution is worth building.
Building the wrong thing is the most expensive mistake in software engineering. It is not a bug you can patch in a sprint. It is weeks or months of wasted effort, burned budget, and team morale damage. Studies consistently show that 70-80% of features in enterprise software are rarely or never used.
Validation is cheap. Building is expensive. A week of validation can save you six months of building something nobody wants. As a senior developer, I treat validation as a non-negotiable phase, not an optional nice-to-have.
Before you validate the solution, validate the problem itself. Do real users actually experience the pain you think they do?
For our task management app, the question is: Do small teams really struggle with task tracking?
Here is how you find out:
If nobody confirms the problem exists, stop. You just saved yourself months.
The problem is confirmed. Now test whether your specific approach solves it. You do not need code for this.
For our task management app, we might create a simple mockup showing boards, columns, and drag-and-drop cards. If test users say “this looks just like Trello,” we know we need stronger differentiation.
Can your team actually build this? Before committing, build a proof of concept (POC) for the hardest technical piece. Do not build the whole app. Build the one thing you are least sure about.
For our app, the riskiest part might be real-time collaboration. Can we sync task updates across multiple users instantly? Let us write a quick POC to find out.
Here is a minimal Java prototype, an in-memory task store to validate the core concept quickly:
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
// Throwaway POC - NOT production code
public class TaskStorePOC {
private final Map> tasks = new ConcurrentHashMap<>();
public String createTask(String title, String assignee) {
String id = UUID.randomUUID().toString().substring(0, 8);
Map task = new HashMap<>();
task.put("id", id);
task.put("title", title);
task.put("assignee", assignee);
task.put("status", "TODO");
task.put("created", System.currentTimeMillis());
tasks.put(id, task);
return id;
}
public Map getTask(String id) {
return tasks.get(id);
}
public void moveTask(String id, String newStatus) {
Map task = tasks.get(id);
if (task != null) {
task.put("status", newStatus);
}
}
public List
And the same concept in Python, a quick throwaway prototype using a dictionary:
import uuid
from datetime import datetime
# Throwaway POC - NOT production code
tasks = {}
def create_task(title, assignee):
task_id = str(uuid.uuid4())[:8]
tasks[task_id] = {
"id": task_id,
"title": title,
"assignee": assignee,
"status": "TODO",
"created": datetime.now().isoformat()
}
return task_id
def move_task(task_id, new_status):
if task_id in tasks:
tasks[task_id]["status"] = new_status
def get_tasks_by_status(status):
return [t for t in tasks.values() if t["status"] == status]
# Quick validation
id1 = create_task("Design database schema", "Alice")
id2 = create_task("Set up CI/CD pipeline", "Bob")
move_task(id1, "IN_PROGRESS")
print("TODO:", get_tasks_by_status("TODO"))
print("IN_PROGRESS:", get_tasks_by_status("IN_PROGRESS"))
Notice these prototypes use in-memory storage. No database, no API layer, no authentication. That is intentional. This code exists to answer one question: does the core data model work? If it does, we move forward. If it does not, we pivot before investing in infrastructure.
Technical feasibility is only half the equation. Is there a market for this?
Once problem, solution, technical feasibility, and market are validated, build a throwaway prototype. This is not production code. This is a tool to test your remaining assumptions with real users.
Key rules for prototypes:
The goal is learning speed, not code quality. If your prototype validates the remaining assumptions, you now have confidence to invest in building the real thing.
Fall in love with the problem, not the solution.
The most dangerous trap in engineering is emotional attachment to your solution. I have seen senior engineers spend months defending an architecture that users do not want, simply because they already built it. Validation exists to protect you from this. If your research shows users need something different from what you proposed, that is not failure. That is the process working exactly as it should. Be willing to pivot. The best engineers I have worked with treat every assumption as a hypothesis to be tested, not a truth to be defended.
Next up: Part 5 – Planning, where we turn our validated solution into an actionable roadmap.