Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Refactoring Java Domain Models with Encapsulation, Interfaces, and Composition

Welcome back. In the previous lesson, you treated JDK, Maven, and IntelliJ configuration as separate layers and learned to diagnose mismatches with evidence rather than trial and error. With the environment stable, the next task is improving the code that lives inside the project.

This lesson refreshes a practical object-oriented design skill: taking a small, overly exposed Java domain model and reshaping it so that it protects its own state, depends on clear behavior contracts, and is assembled from focused objects. We will use a simplified job-application tracker model, which can later grow into your portfolio project.

By the end, you should be able to recognize when a class needs encapsulation, when an interface represents a useful behavioral boundary, and when composition is a better design than adding another superclass.


Objects should own both state and behavior

A useful domain object is not just a bag of fields. It represents something in the problem domain, keeps track of its valid state, and provides operations that make sense for that thing.

An object combines fields that hold its state with methods that define the behavior allowed to act on that state. Encapsulation keeps those two aspects together rather than exposing raw data for unrelated code to modify.

For a job-application tracker, a JobApplication might own:

  • its company and role;
  • its current application status;
  • its notes;
  • rules for adding a note or changing status.

It should not make every field directly writable by controllers, UI code, or other classes. Consider this starting point:

public class JobApplication {

    public String company;
    public String role;
    public String status;
    public List<String> notes = new ArrayList<>();
}

This class compiles, but it cannot protect itself. Any code can do the following:

application.status = "interveiw";
application.company = "";
application.notes = null;

The compiler cannot catch the misspelled status, and the object has no chance to reject an empty company or prevent its notes list from being replaced.

Encapsulation means that an object controls access to its own data. In Java, that usually begins with private fields, but it does not end there. The larger goal is to expose intentional operations rather than unrestricted mutation.

Creating Classes - Dev.java

Read Oracle Dev.java’s “Creating Classes” for a precise refresher on class declarations, fields, access modifiers, and the reason private fields support encapsulation.

In “Declaring Classes,” read the class-declaration overview. Notice that Java distinguishes a superclass introduced with extends from interfaces introduced with implements. Then move to “Controlling who has Access to a Member.” Read the access-control explanation. Focus on the distinction between direct field access and controlled public methods.

Private fields are necessary, but getters and setters are not the whole design

A mechanical refactor sometimes changes every public field into a private field and generates a getter and setter for all of them:

public void setStatus(String status) {
    this.status = status;
}

That hides the field syntactically, but it does not meaningfully protect the model. Any caller can still set any string as the status.

A stronger API names actions in the language of the domain:

Weak, data-oriented API Stronger, behavior-oriented API
setStatus("OFFER") changeStatus(ApplicationStatus.OFFER, policy)
setNotes(notes) addNote(text, date)
setCompany(company) Require a valid company when constructing the application
public mutable List Return an unmodifiable snapshot of notes

This matters in backend work. A REST controller should ask an application object to perform a valid business action; it should not reach inside the object and modify arbitrary fields.


Refactoring the application’s state

First, make invalid status values unrepresentable. Status is a small, finite set of domain states, so an enum is clearer and safer than a String.

public enum ApplicationStatus {
    DRAFT,
    APPLIED,
    INTERVIEWING,
    OFFER,
    REJECTED
}

Next, give a note its own focused type. A note is more than text because it has a creation date. Making it immutable means that, after construction, its meaningful values cannot silently change.

import java.time.LocalDate;
import java.util.Objects;

public final class ApplicationNote {

    private final String text;
    private final LocalDate createdOn;

    public ApplicationNote(String text, LocalDate createdOn) {
        this.text = requireText(text, "text");
        this.createdOn = Objects.requireNonNull(createdOn, "createdOn");
    }

    public String text() {
        return text;
    }

    public LocalDate createdOn() {
        return createdOn;
    }

    private static String requireText(String value, String fieldName) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(fieldName + " must not be blank");
        }
        return value.trim();
    }
}

Now JobApplication can compose notes rather than treating them as loose strings. The class owns a List<ApplicationNote> and offers a narrow operation for adding a valid note.

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public final class JobApplication {

    private final String company;
    private final String role;
    private final List<ApplicationNote> notes = new ArrayList<>();

    private ApplicationStatus status = ApplicationStatus.DRAFT;

    public JobApplication(String company, String role) {
        this.company = requireText(company, "company");
        this.role = requireText(role, "role");
    }

    public String company() {
        return company;
    }

    public String role() {
        return role;
    }

    public ApplicationStatus status() {
        return status;
    }

    public void addNote(String text, LocalDate createdOn) {
        notes.add(new ApplicationNote(text, createdOn));
    }

    public List<ApplicationNote> notes() {
        return List.copyOf(notes);
    }

    private static String requireText(String value, String fieldName) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(fieldName + " must not be blank");
        }
        return value.trim();
    }
}

There are several design decisions here:

  1. company and role are final, because this small model treats them as the identity-defining details of an application.
  2. notes is private and its reference is final. Callers cannot replace the list.
  3. addNote() ensures every added note passes through ApplicationNote validation.
  4. notes() uses List.copyOf(notes), so callers can inspect notes but cannot mutate the application’s internal list.
  5. The class exposes no general setStatus() method. We will add status change as a business operation with rules.

This is composition: a JobApplication has notes. ApplicationNote is not a subtype of JobApplication, and forcing an inheritance relationship here would be nonsensical.

Inheritance and Composition (Is-a vs Has-a relationship) in ...

Read the relevant sections of Baeldung’s article to sharpen the semantic distinction between inheritance and composition, then connect it to the JobApplication and ApplicationNote design.

In “Composition’s Basics,” read the composition discussion. Focus on the “has-a” test: a job application has notes, while a note is not a special kind of application. Then continue into “Composition Without Abstraction.” Compare the tightly coupled version, where a class constructs concrete dependencies itself, with the version that can work through an interface. This distinction becomes important when a behavior can genuinely vary.


Interfaces describe a required behavior

An interface defines a contract: code can depend on what an object can do without knowing its concrete class.

In the tracker, status changes may follow different rules. Your personal tracker might allow a status to move directly from APPLIED to OFFER; a team workflow may require an INTERVIEWING stage first. The JobApplication should not need to know every possible workflow implementation.

Define that varying behavior with an interface:

public interface StatusTransitionPolicy {

    boolean allows(ApplicationStatus current, ApplicationStatus target);
}

A standard policy can implement the contract:

public final class StandardStatusTransitionPolicy
        implements StatusTransitionPolicy {

    @Override
    public boolean allows(
            ApplicationStatus current,
            ApplicationStatus target) {

        return switch (current) {
            case DRAFT -> target == ApplicationStatus.APPLIED;
            case APPLIED ->
                    target == ApplicationStatus.INTERVIEWING
                    || target == ApplicationStatus.REJECTED;
            case INTERVIEWING ->
                    target == ApplicationStatus.OFFER
                    || target == ApplicationStatus.REJECTED;
            case OFFER, REJECTED -> false;
        };
    }
}

The switch belongs in the policy because it defines workflow rules. JobApplication stays responsible for protecting its own state, while the policy decides whether a proposed transition is permitted.

Add the controlled operation to JobApplication:

public void changeStatus(
        ApplicationStatus target,
        StatusTransitionPolicy policy) {

    Objects.requireNonNull(target, "target");
    Objects.requireNonNull(policy, "policy");

    if (!policy.allows(status, target)) {
        throw new IllegalStateException(
                "Cannot change status from " + status + " to " + target
        );
    }

    this.status = target;
}

Usage is explicit:

JobApplication application =
        new JobApplication("Acme Labs", "Junior Java Developer");

StatusTransitionPolicy policy =
        new StandardStatusTransitionPolicy();

application.changeStatus(ApplicationStatus.APPLIED, policy);
application.addNote("Applied through company careers page", LocalDate.now());

The key point is the declared type:

StatusTransitionPolicy policy

JobApplication depends on the contract, not directly on StandardStatusTransitionPolicy. Another implementation can follow the same contract:

public final class FlexibleStatusTransitionPolicy
        implements StatusTransitionPolicy {

    @Override
    public boolean allows(
            ApplicationStatus current,
            ApplicationStatus target) {

        return current != ApplicationStatus.OFFER
                && current != ApplicationStatus.REJECTED;
    }
}

The concrete rule above may or may not be correct for a real product. Its purpose is to show substitutability: both classes can be used anywhere that expects a StatusTransitionPolicy.

Do not create interfaces automatically for every class. ApplicationNote currently has one clear representation and no meaningful alternative behavior, so a concrete class is appropriate. An interface earns its place when there is a real boundary or behavior likely to vary: a status policy, payment provider, file storage service, or notification sender.


Why composition is often safer than inheritance

Inheritance establishes an is-a relationship. For example, if every subtype preserves the guarantees of a base type, SavingsAccount may reasonably be an Account.

But inheritance is a poor fit when you only want to reuse one behavior. Suppose a model uses this hierarchy:

JobApplication
├── EmailApplication
├── LinkedInApplication
└── PortalApplication

It becomes awkward as requirements change. What if an application is initially submitted through a portal but later followed up by email? Or you introduce a referral channel? Submission method is better represented as a separate collaborator or value, not a rigid subclass identity.

Composition lets a class contain or use focused objects:

  • a JobApplication has ApplicationNote objects;
  • a workflow operation uses a StatusTransitionPolicy;
  • a future application service may use a notification mechanism.

This keeps responsibilities smaller. Changes to status rules do not require restructuring the notes model, and changes to note formatting do not require modifying application-status logic.

The Flaws of Inheritance

Watch “The Flaws of Inheritance” by CodeAesthetic for a visual explanation of why composition plus interfaces can reduce coupling without losing useful abstraction.

Watch the composition refactor. The video replaces a rigid image-class hierarchy with focused collaborating objects; map that idea to keeping application state, status rules, and other responsibilities separate. Then watch interfaces as contracts. Focus on the distinction between reusing implementation and depending on an abstraction. In the tracker example, StatusTransitionPolicy is the small contract, while StandardStatusTransitionPolicy is one implementation.

A practical design check

When deciding how to refactor a relationship, ask these questions in order:

  1. What state must remain valid?
    Keep it private and expose operations that preserve its rules.

  2. Is one thing truly a specialized kind of another?
    If it satisfies a genuine, stable “is-a” relationship, inheritance can be appropriate. Otherwise, do not use inheritance merely to reuse code.

  3. Does an object contain or use another object as a part or collaborator?
    Model that “has-a” relationship with composition.

  4. Do callers need a behavior, but should remain independent of the implementation?
    Introduce a small interface that describes only that required behavior.

  5. Can the design be understood from method names?
    addNote() and changeStatus() communicate intent much better than a collection of public fields and generic setters.

A concise version of the design now looks like this:

Type Main responsibility Relationship
JobApplication Protect application state and coordinate domain actions Composes notes; uses a policy
ApplicationNote Represent one validated, dated note Part of an application
ApplicationStatus Represent valid status values Used by application and policy
StatusTransitionPolicy Define allowed status transitions Interface contract
StandardStatusTransitionPolicy Apply one workflow’s rules Implements the policy

Refactoring routine for your own code

Use this procedure on one of your earlier journal or quiz application models:

  1. Choose one model that currently has public fields, broad setters, or several unrelated responsibilities.
  2. Make fields private and identify the invariants: values that must never be null, blank, or invalid.
  3. Replace broad setters with small domain operations such as publish(), addQuestion(), recordAnswer(), or changeStatus().
  4. Extract a separate class when a field group has its own meaning and validation rules.
  5. Use composition to hold that new object or collection of objects.
  6. Introduce an interface only for behavior that has a legitimate alternative implementation or forms an external boundary.
  7. Compile the project after each small change and update every caller deliberately rather than restoring public setters to silence errors.

Key takeaways

Encapsulation is about more than adding private: it lets a domain object preserve its own valid state through intentional methods. Composition models a meaningful “has-a” relationship and helps keep responsibilities separate. Interfaces provide small behavioral contracts, allowing the rest of the code to depend on capabilities rather than concrete implementations.

For the job tracker, JobApplication owns validated state and notes, ApplicationNote is a focused composed object, and StatusTransitionPolicy separates status rules from the model while allowing different workflows.

Next, you will choose among List, Set, and Map based on the data-access requirement—an important decision when this domain model begins managing collections of applications, notes, and lookup data.

Can't find a good explanation? Sign up and we'll make it for you

Sign up