Capstone: A Real Java Application

Implementing Business Logic

15 min Lesson 5 of 13

Implementing Business Logic

The service layer is where your application earns its keep. Controllers parse input and format output; repositories read and write data; but the service is where the real decisions happen — what constitutes a valid task, what state transitions are legal, what rules govern the system's behaviour. Getting this layer right is the difference between software that is easy to change and software that fights you every time you touch it.

In this lesson you will implement TaskService and a dedicated ValidationService for the task-management capstone, applying professional patterns for rule enforcement, guard clauses, and clear exception semantics.

Two Responsibilities, Two Classes

A common temptation is to write one fat service that does everything. Resist it. Split business logic across two focused classes:

  • ValidationService — stateless, pure. Evaluates field-level and business-rule constraints and throws a typed exception when violated. Has no repository dependency.
  • TaskService — stateful via its repository. Orchestrates use-cases: it validates, mutates state, and persists. It delegates all constraint logic to ValidationService.

This split keeps each class small, gives you a single place to change a rule, and makes both classes trivially testable in isolation.

The ValidationException

Validation failures are not programming errors — they are expected, domain-level outcomes. Model them with a dedicated exception rather than overloading IllegalArgumentException:

// exception/ValidationException.java package com.example.taskmanager.exception; import java.util.List; public class ValidationException extends RuntimeException { private final List<String> errors; public ValidationException(List<String> errors) { super("Validation failed: " + String.join("; ", errors)); this.errors = List.copyOf(errors); } public ValidationException(String error) { this(List.of(error)); } public List<String> getErrors() { return errors; } }

Collecting all errors before throwing (rather than failing on the first one) gives callers a complete picture and enables better UX: the user sees every problem at once.

Implementing ValidationService

Each validation method accumulates errors into a list, then throws if the list is non-empty. This is the collect-and-throw pattern.

// service/ValidationService.java package com.example.taskmanager.service; import com.example.taskmanager.exception.ValidationException; import com.example.taskmanager.model.Priority; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; public class ValidationService { private static final int TITLE_MAX_LENGTH = 120; private static final int DESCRIPTION_MAX_LENGTH = 2000; /** Validates all fields required to create a new Task. */ public void validateCreateTask(String title, String description, Priority priority, LocalDate dueDate) { List<String> errors = new ArrayList<>(); if (title == null || title.isBlank()) { errors.add("Title must not be blank"); } else if (title.length() > TITLE_MAX_LENGTH) { errors.add("Title must not exceed " + TITLE_MAX_LENGTH + " characters"); } if (description != null && description.length() > DESCRIPTION_MAX_LENGTH) { errors.add("Description must not exceed " + DESCRIPTION_MAX_LENGTH + " characters"); } if (priority == null) { errors.add("Priority is required"); } if (dueDate != null && dueDate.isBefore(LocalDate.now())) { errors.add("Due date must not be in the past"); } if (!errors.isEmpty()) { throw new ValidationException(errors); } } /** Business rule: a DONE task may not be reopened through the normal flow. */ public void validateStatusTransition( com.example.taskmanager.model.Status current, com.example.taskmanager.model.Status requested) { if (current == com.example.taskmanager.model.Status.DONE && requested != com.example.taskmanager.model.Status.DONE) { throw new ValidationException( "A completed task cannot be reopened; create a new task instead"); } } }
Keep ValidationService stateless. It takes raw values in, throws or returns — nothing else. No repository, no Clock unless you inject it. Stateless services are safe to share as singletons and are trivial to test: call the method, assert the exception message.

Implementing TaskService

TaskService receives both collaborators via its constructor. Guard-clauses appear at the top of every public method; the happy path follows at the bottom. This style — fail fast, succeed late — avoids deeply nested conditionals and makes the normal flow obvious.

// 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; private final ValidationService validator; public TaskService(TaskRepository repository, ValidationService validator) { this.repository = repository; this.validator = validator; } // ------------------------------------------------------------------ // // Use-case: create a new task // // ------------------------------------------------------------------ // public Task createTask(String title, String description, Priority priority, LocalDate dueDate) { validator.validateCreateTask(title, description, priority, dueDate); Task task = new Task( UUID.randomUUID(), title.strip(), // normalise whitespace before persisting description == null ? "" : description.strip(), priority, Status.OPEN, dueDate ); return repository.save(task); } // ------------------------------------------------------------------ // // Use-case: mark a task as done // // ------------------------------------------------------------------ // public Task completeTask(UUID id) { Task existing = repository.findById(id) .orElseThrow(() -> new TaskNotFoundException(id)); validator.validateStatusTransition(existing.status(), Status.DONE); // Records are immutable — produce a new instance with updated status Task completed = new Task( existing.id(), existing.title(), existing.description(), existing.priority(), Status.DONE, existing.dueDate() ); return repository.save(completed); } // ------------------------------------------------------------------ // // Use-case: list tasks, with optional filtering // // ------------------------------------------------------------------ // public List<Task> listAllTasks() { return repository.findAll(); } public List<Task> listOverdueTasks() { LocalDate today = LocalDate.now(); return repository.findAll().stream() .filter(t -> t.status() != Status.DONE) .filter(t -> t.dueDate() != null && t.dueDate().isBefore(today)) .toList(); } // ------------------------------------------------------------------ // // Use-case: delete a task // // ------------------------------------------------------------------ // public void deleteTask(UUID id) { if (repository.findById(id).isEmpty()) { throw new TaskNotFoundException(id); } repository.delete(id); } }
Normalise inputs at the service boundary. The call to title.strip() before constructing the record ensures the database never stores accidental leading/trailing whitespace. Do this in the service, not in the model, so the model stays a pure data container and the normalisation logic is visible and testable in one place.

Where Business Rules Live — and Where They Do Not

A recurring design question is: should logic live in the model, the service, or the repository? The answer follows from responsibilities:

  • Model (record/entity) — invariants that are always true regardless of context. The compact constructor in Task that rejects a blank title is a model-level invariant.
  • Service — use-case rules that involve multiple objects, state transitions, or cross-field constraints. "A task cannot be completed if it is already done" is a service-level rule.
  • Repository — persistence concerns only. Filtering for overdue tasks is not a repository concern; it is a business rule that happens to use a date comparison. Keep it in the service with a stream.
Do not encode business rules in SQL queries or JPA specifications. It feels efficient to push everything into a WHERE clause, but it scatters your business logic across two languages, makes it invisible to unit tests (which do not hit the database), and couples your rules to a specific persistence technology. Use the repository for data retrieval; use the service for rule evaluation.

Constructor Injection — No Framework Required

Notice that neither service class uses Spring, CDI, or any DI framework. Dependencies are declared as final fields and assigned in the constructor. The wiring happens in Main.java (or a hand-written composition root) in plain Java:

// Main.java (composition root) package com.example.taskmanager; import com.example.taskmanager.repository.InMemoryTaskRepository; import com.example.taskmanager.service.TaskService; import com.example.taskmanager.service.ValidationService; import com.example.taskmanager.ui.ConsoleApp; public class Main { public static void main(String[] args) { var repository = new InMemoryTaskRepository(); var validator = new ValidationService(); var taskService = new TaskService(repository, validator); var app = new ConsoleApp(taskService); app.run(args); } }

This pattern — called a composition root — keeps the wiring of the entire object graph in a single, obvious place. Swapping InMemoryTaskRepository for a JdbcTaskRepository is a one-line change here and zero changes anywhere else.

Trade-offs: When to Introduce a Dedicated ValidationService

For very small applications a separate ValidationService is overkill — inline guard clauses inside the service are fine. Introduce a dedicated class when:

  • The same rules apply across multiple use-cases (create and update share the same title constraints).
  • You want to test validation in complete isolation, separate from persistence concerns.
  • The rules are complex enough that a single service method becomes hard to read.

Summary

The service layer is the heart of the application: it validates inputs through a focused ValidationService, enforces state-transition rules, normalises data at the boundary, and orchestrates persistence without leaking infrastructure concerns upward or downward. Keeping services thin, injecting dependencies via the constructor, and respecting the rule that business logic belongs in the service — not in repositories or queries — produces a codebase that is a pleasure to extend and test. Next, you will build the interface layer that calls these services.