Capstone: A Real Java Application

Designing the Architecture

15 min Lesson 2 of 13

Designing the Architecture

Every non-trivial Java application eventually reaches a point where dumping everything into a single class — or even a flat collection of classes — becomes painful. Code changes ripple unexpectedly, tests become impossible to write in isolation, and onboarding a new teammate means reading the entire codebase before touching anything. A deliberate architecture prevents all of this.

In this lesson you will design the layered architecture for the capstone application, learn why each layer exists, understand what belongs in each one, and organise everything into a package structure that enforces those boundaries by convention and, optionally, by tooling.

The Classic Three-Layer Model

Most business applications are well-served by three horizontal layers stacked on top of each other:

  1. Presentation / Interface layer — receives input and delivers output. In a CLI app this is argument parsing and console output. In a REST service this is HTTP controllers and JSON serialisation.
  2. Business / Domain layer — contains the rules that make the application valuable. This layer knows nothing about how data arrives or where it goes.
  3. Data / Infrastructure layer — persists and retrieves data. In our capstone this will be JDBC or an in-memory store; in a production system it could be JPA, an external API, a message broker, and so on.
The dependency rule: dependencies flow inward only. The presentation layer depends on the business layer; the business layer depends on abstractions (interfaces) that the data layer implements. The business layer never imports a JDBC class. If you find yourself tempted to do that, you have discovered a layering violation.

Translating Layers to Java Packages

Packages are Java's primary mechanism for expressing architectural intent. A well-chosen package layout makes the architecture visible to every reader of the codebase, and most static analysis tools (ArchUnit, Checkstyle, SpotBugs) can enforce package-level rules in CI.

For the capstone — a small task-management CLI — a practical layout looks like this:

com.example.taskmanager ├── Main.java // entry point only — delegates immediately │ ├── ui/ // Presentation layer │ ├── ConsoleApp.java // top-level interaction loop │ ├── CommandParser.java // parses raw String[] args or stdin │ └── Formatter.java // formats domain objects for display │ ├── service/ // Business layer │ ├── TaskService.java // orchestrates use-cases │ └── ValidationService.java // pure domain rules, no I/O │ ├── repository/ // Data layer abstraction (interfaces live here) │ ├── TaskRepository.java // interface │ └── InMemoryTaskRepository.java // implementation │ ├── model/ // Shared domain model — owned by no single layer │ ├── Task.java │ ├── Priority.java │ └── Status.java │ └── exception/ // Application-specific exceptions ├── TaskNotFoundException.java └── ValidationException.java
Put interfaces next to their consumers, not their implementations. TaskRepository lives in repository/ because it is the contract; InMemoryTaskRepository (or later a JdbcTaskRepository) is an implementation detail that satisfies that contract. Swapping implementations requires zero changes in the service layer.

Why Separate the Model Package?

The model package holds plain Java objects — records or classes with no framework annotations, no SQL, no HTTP — that represent the concepts the application reasons about. All layers reference model objects freely, so placing the model inside any single layer would create an artificial dependency. It stands alone.

// model/Task.java — a Java 17 record; immutable, zero dependencies package com.example.taskmanager.model; import java.time.LocalDate; import java.util.UUID; public record Task( UUID id, String title, String description, Priority priority, Status status, LocalDate dueDate ) { // Compact constructor for validation public Task { if (title == null || title.isBlank()) { throw new IllegalArgumentException("Task title must not be blank"); } } }

The Repository Interface — Abstracting Persistence

The business layer never calls JDBC or touches a file directly. Instead it talks to a repository interface defined in terms of domain objects. This is the Repository pattern from Domain-Driven Design, and it is one of the highest-leverage patterns in enterprise Java.

// repository/TaskRepository.java package com.example.taskmanager.repository; import com.example.taskmanager.model.Task; import com.example.taskmanager.model.Status; import java.util.List; import java.util.Optional; import java.util.UUID; public interface TaskRepository { Task save(Task task); Optional<Task> findById(UUID id); List<Task> findAll(); List<Task> findByStatus(Status status); void delete(UUID id); }

The implementation — in-memory for now — lives in the same package but is a detail:

// repository/InMemoryTaskRepository.java package com.example.taskmanager.repository; import com.example.taskmanager.model.Task; import com.example.taskmanager.model.Status; import java.util.*; public class InMemoryTaskRepository implements TaskRepository { private final Map<UUID, Task> store = new LinkedHashMap<>(); @Override public Task save(Task task) { store.put(task.id(), task); return task; } @Override public Optional<Task> findById(UUID id) { return Optional.ofNullable(store.get(id)); } @Override public List<Task> findAll() { return List.copyOf(store.values()); } @Override public List<Task> findByStatus(Status status) { return store.values().stream() .filter(t -> t.status() == status) .toList(); } @Override public void delete(UUID id) { store.remove(id); } }

The Service Layer — Where Business Logic Lives

The service layer receives a TaskRepository via its constructor (constructor injection — no framework required). It orchestrates domain rules and delegates persistence. It does not format output and it does not care about HTTP or CLI specifics.

// service/TaskService.java package com.example.taskmanager.service; import com.example.taskmanager.exception.TaskNotFoundException; import com.example.taskmanager.model.*; import com.example.taskmanager.repository.TaskRepository; import java.time.LocalDate; import java.util.List; import java.util.UUID; public class TaskService { private final TaskRepository repository; public TaskService(TaskRepository repository) { this.repository = repository; } public Task createTask(String title, String description, Priority priority, LocalDate dueDate) { Task task = new Task( UUID.randomUUID(), title, description, priority, Status.OPEN, dueDate ); return repository.save(task); } public Task completeTask(UUID id) { Task existing = repository.findById(id) .orElseThrow(() -> new TaskNotFoundException(id)); Task completed = new Task( existing.id(), existing.title(), existing.description(), existing.priority(), Status.DONE, existing.dueDate() ); return repository.save(completed); } public List<Task> listAllTasks() { return repository.findAll(); } }
Do not let convenience pull logic downward. A common mistake is writing query logic — filtering, sorting, computing aggregates — inside the repository because "the database does it efficiently." Sometimes that is right, but when it is not data-specific work, keep it in the service. A rule like "overdue tasks are those where dueDate is before today and status is not DONE" is a business rule; the repository should expose findAll() and the service applies the filter with a stream.

Trade-offs and Alternatives

The three-layer model is a pragmatic starting point, not a dogma. As you design, be aware of:

  • Hexagonal Architecture (Ports & Adapters): Makes the inward-dependency rule explicit by naming the boundary — the port (interface) — and the adapter (implementation). Scales better for multiple I/O channels (CLI + REST + event-driven).
  • Vertical slicing: Instead of horizontal layers, group by feature (task/, user/). Each feature owns its own service, repository, and model. Reduces coupling between features but can duplicate infrastructure code.
  • Anemic vs. rich domain model: Our Task record is mostly data; the service holds logic. A richer model would embed state-transition methods (task.complete()) directly in the domain object. Both are valid — the record approach is simpler; the rich model scales better as rules grow.

Summary

A layered architecture separates what the user sees (UI), what the application knows (service), and where data lives (repository). Java packages make this structure visible and tooling-enforceable. The dependency rule — always point inward toward the domain — is the single most important constraint to uphold. With this skeleton in place, the next lesson will flesh out the domain model in detail.