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.