Capstone: A Real Java Application

Modeling the Domain

25 min Lesson 3 of 13

Modeling the Domain

Every real application is built on a domain model — the set of entities, value objects, and enumerations that give your business concepts a home in code. A carefully designed domain model is the difference between a codebase that is easy to extend and one that accumulates bugs as the business evolves. In this lesson we take the requirements from Lesson 1 and translate them into concrete Java types: classes, records, and enums.

What We Are Modeling

Our capstone is a task-management application for a small team. From the requirements analysis we identified these core concepts:

  • User — a person who can own and be assigned tasks.
  • Project — a container that groups related tasks.
  • Task — the primary work item, belonging to a project and optionally assigned to a user.
  • Priority — an ordered classification for tasks (LOW, MEDIUM, HIGH, CRITICAL).
  • TaskStatus — the lifecycle state of a task (TODO, IN_PROGRESS, DONE, CANCELLED).
  • Tag — a lightweight label that can be attached to multiple tasks.

Enums First: Capturing Fixed Vocabularies

Enums encode a finite, well-known set of values. Using String constants instead is a classic mistake — it allows typos, makes exhaustive switch checks impossible, and strips IDE support. Java enums can carry fields and behaviour, making them far more expressive.

public enum Priority { LOW(1), MEDIUM(2), HIGH(3), CRITICAL(4); private final int level; Priority(int level) { this.level = level; } public int level() { return level; } public boolean isUrgentOrAbove() { return this.level >= HIGH.level; } }
public enum TaskStatus { TODO, IN_PROGRESS, DONE, CANCELLED; /** Returns true if no further work can happen on a task in this status. */ public boolean isTerminal() { return this == DONE || this == CANCELLED; } }
Add behaviour to enums, not to callers. Methods like isUrgentOrAbove() and isTerminal() centralise logic that would otherwise be scattered in if chains across the codebase. When the definition changes (e.g., a new status is added), there is exactly one place to update.

Value Objects With Records

Java 16 records are ideal for value objects — immutable data carriers whose identity is defined by their content, not their memory address. A Tag is a perfect fit: two tags with the same name are the same tag.

/** * Immutable value object representing a label attached to tasks. * Equality is structural: two Tags with the same name are equal. */ public record Tag(String name) { // Compact constructor for validation public Tag { if (name == null || name.isBlank()) { throw new IllegalArgumentException("Tag name must not be blank"); } name = name.strip().toLowerCase(); // canonical form } }

The compact constructor (no parentheses) runs inside the canonical constructor, letting you validate and normalise without repeating the assignment boilerplate. After construction a Tag is guaranteed to be non-null, non-blank, and lowercase.

Records vs. classes for value objects: Records auto-generate equals, hashCode, toString, and accessors. Use a class when you need inheritance, lazy initialisation, or mutable state. Use a record when the object is purely a transparent, immutable data holder.

The User Entity

An entity has identity — two User objects with the same id refer to the same person even if other fields differ. We use a class (not a record) because entities typically have a mutable lifecycle and are tracked by identity, not value.

import java.util.UUID; public final class User { private final UUID id; private String displayName; private String email; public User(String displayName, String email) { this.id = UUID.randomUUID(); this.displayName = requireNonBlank(displayName, "displayName"); this.email = requireValidEmail(email); } // Package-private constructor for persistence layer (supply existing id) User(UUID id, String displayName, String email) { this.id = id; this.displayName = displayName; this.email = email; } public UUID id() { return id; } public String displayName() { return displayName; } public String email() { return email; } public void rename(String newName) { this.displayName = requireNonBlank(newName, "displayName"); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User u)) return false; return id.equals(u.id); } @Override public int hashCode() { return id.hashCode(); } @Override public String toString() { return "User[id=" + id + ", name=" + displayName + "]"; } private static String requireNonBlank(String value, String field) { if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return value.strip(); } private static String requireValidEmail(String email) { if (email == null || !email.contains("@")) throw new IllegalArgumentException("Invalid email: " + email); return email.strip().toLowerCase(); } }
Do not expose mutable collections directly. If User later holds a list of assigned tasks, return Collections.unmodifiableList(tasks) or a defensive copy from the getter. Leaking a mutable reference allows callers to corrupt internal state silently.

The Task Entity

Task is the central entity. It aggregates several of our types and enforces business rules through its own methods rather than leaving rule enforcement to callers.

import java.time.Instant; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; import java.util.Set; import java.util.UUID; public final class Task { private final UUID id; private final UUID projectId; private String title; private String description; private Priority priority; private TaskStatus status; private UUID assigneeId; // nullable — unassigned tasks are valid private final Instant createdAt; private Instant updatedAt; private final Set<Tag> tags; public Task(UUID projectId, String title, Priority priority) { this.id = UUID.randomUUID(); this.projectId = Objects.requireNonNull(projectId, "projectId"); this.title = requireNonBlank(title, "title"); this.priority = Objects.requireNonNull(priority, "priority"); this.status = TaskStatus.TODO; this.createdAt = Instant.now(); this.updatedAt = this.createdAt; this.tags = new HashSet<>(); } // --- state transitions ------------------------------------------------- public void assign(UUID userId) { guardNotTerminal(); this.assigneeId = userId; touch(); } public void start() { if (status != TaskStatus.TODO) throw new IllegalStateException("Only TODO tasks can be started; current: " + status); this.status = TaskStatus.IN_PROGRESS; touch(); } public void complete() { guardNotTerminal(); this.status = TaskStatus.DONE; touch(); } public void cancel() { guardNotTerminal(); this.status = TaskStatus.CANCELLED; touch(); } public void addTag(Tag tag) { guardNotTerminal(); tags.add(Objects.requireNonNull(tag)); touch(); } // --- accessors --------------------------------------------------------- public UUID id() { return id; } public UUID projectId() { return projectId; } public String title() { return title; } public Priority priority() { return priority; } public TaskStatus status() { return status; } public UUID assigneeId() { return assigneeId; } public Instant createdAt() { return createdAt; } public Instant updatedAt() { return updatedAt; } public Set<Tag> tags() { return Collections.unmodifiableSet(tags); } // --- private helpers --------------------------------------------------- private void guardNotTerminal() { if (status.isTerminal()) throw new IllegalStateException("Cannot modify a task in status: " + status); } private void touch() { this.updatedAt = Instant.now(); } private static String requireNonBlank(String v, String field) { if (v == null || v.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return v.strip(); } }

The Project Entity

A Project groups tasks and has its own identity. For brevity it is kept lean here — in later lessons the service layer will own the logic of adding tasks to projects.

import java.time.Instant; import java.util.UUID; public final class Project { private final UUID id; private String name; private String description; private final Instant createdAt; public Project(String name, String description) { this.id = UUID.randomUUID(); this.name = requireNonBlank(name, "name"); this.description = description == null ? "" : description.strip(); this.createdAt = Instant.now(); } public UUID id() { return id; } public String name() { return name; } public String description() { return description; } public Instant createdAt() { return createdAt; } public void rename(String newName) { this.name = requireNonBlank(newName, "name"); } @Override public boolean equals(Object o) { if (!(o instanceof Project p)) return false; return id.equals(p.id); } @Override public int hashCode() { return id.hashCode(); } private static String requireNonBlank(String v, String field) { if (v == null || v.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return v.strip(); } }

Design Trade-offs Worth Knowing

  • UUID vs. auto-increment ID: UUIDs are globally unique and safe to generate client-side, which matters for distributed systems and offline-first designs. The cost is larger storage and slightly slower indexed queries in databases. Auto-increment is simpler and more performant for a single-node app. We use UUID here because our architecture anticipates integration with the data layer via JDBC (Lesson 4), where we control the mapping either way.
  • Rich domain model vs. anemic model: Our entities validate themselves and enforce state transitions. An alternative is the anemic model — plain data bags whose logic lives entirely in service classes. The rich model keeps business rules close to the data they protect; the anemic model is simpler and maps more directly to database rows. We favour the rich model for correctness.
  • Records for entities: Records cannot be subclassed and have no private mutable state. They are wrong for entities but right for value objects like Tag.

Summary

The domain model for our capstone application consists of two enums (Priority, TaskStatus), one record value object (Tag), and three entity classes (User, Project, Task). Each type validates its own invariants in the constructor, exposes only what callers need, and enforces state transitions through methods rather than leaving that responsibility to callers. With the domain model in place, Lesson 4 will wire these types to a relational database via JDBC.