Arranging a Java Application
An application that stays easy to change has its rules in one place, its I/O at the edges, and dependencies that point inward: the domain knows nothing about the database, the web framework, or the JSON library. That holds whether the application is a 500-line command-line tool or a large service.
The domain is plain Java
No framework, no annotations, nothing that needs a network or a disk to test.
import java.util.*;
public class Domain {
record Money(long pence) {
Money plus(Money other) { return new Money(pence + other.pence); }
boolean isMoreThan(Money other) { return pence > other.pence; }
@Override public String toString() { return String.format("%.2f", pence / 100.0); }
}
record Basket(List<Money> lines) {
Basket {
lines = List.copyOf(lines); // defensive copy: nobody can edit it later
}
Money total() {
return lines.stream().reduce(new Money(0), Money::plus);
}
boolean qualifiesForFreeDelivery() {
return total().isMoreThan(new Money(5000));
}
}
public static void main(String[] args) {
var basket = new Basket(List.of(new Money(1999), new Money(2500), new Money(1200)));
System.out.println(basket.total());
System.out.println("free delivery: " + basket.qualifiesForFreeDelivery());
}
}
Every rule there is testable with a constructor call and an assertion. No
database, no mocks, no Spring context. List.copyOf in the compact constructor
matters: without it a caller keeps a reference to the list inside your record
and can change it afterwards.
Dependencies point inward
The rules declare what they need as an interface. The implementation lives further out and is handed in - which is all "dependency injection" means, and it needs no framework.
import java.util.*;
public class Ports {
// --- domain ---------------------------------------------------------
record Course(String slug, String title) { }
// The rules say what they need. They do not say where it comes from.
interface CourseRepository {
Optional<Course> findBySlug(String slug);
}
sealed interface Outcome permits Found, NotFound { }
record Found(Course course) implements Outcome { }
record NotFound(String slug) implements Outcome { }
// One job, its dependencies handed in, no I/O of its own.
static final class ViewCourse {
private final CourseRepository courses;
ViewCourse(CourseRepository courses) {
this.courses = Objects.requireNonNull(courses);
}
Outcome handle(String slug) {
return courses.findBySlug(slug)
.<Outcome>map(Found::new)
.orElseGet(() -> new NotFound(slug));
}
}
// --- edge -----------------------------------------------------------
// In production this talks to MySQL. In a test it is four lines.
record InMemoryCourses(Map<String, Course> rows) implements CourseRepository {
@Override public Optional<Course> findBySlug(String slug) {
return Optional.ofNullable(rows.get(slug));
}
}
public static void main(String[] args) {
var useCase = new ViewCourse(new InMemoryCourses(
Map.of("java", new Course("java", "Java: From Basics to Expert"))));
for (String slug : List.of("java", "cobol")) {
String message = switch (useCase.handle(slug)) {
case Found(Course course) -> "found " + course.title();
case NotFound(String missing) -> "no course called " + missing;
};
System.out.println(message);
}
}
}
Three things are doing work there. The repository is an interface owned by the
domain, so the database depends on the rules rather than the other way round.
The dependency arrives through the constructor, so nothing can construct a
ViewCourse in an unusable state. And the result is a sealed type, so "no such
course" is data the caller must handle rather than an exception it may ignore.
Packages, and what to make public
Java's access control works on packages, so the package is the unit of design, not a folder to sort files into. Group by feature, not by technical layer:
com.omniacad.catalogue
Course.java public: the domain type
CourseRepository.java public: the port
ViewCourse.java public: the use case
MySqlCourseRepository.java package-private: an implementation detail
com.omniacad.catalogue.web
CourseController.java
com.omniacad.catalogue.web.* cannot see anything package-private in
catalogue, which is exactly the boundary you want. Default to package-private
and promote to public only when something outside genuinely needs it - the
opposite of the habit most people learn.
Wire it together in exactly one place
public class Main {
interface Clock { long now(); }
interface Mailer { void send(String to, String subject); }
record Notifier(Clock clock, Mailer mailer) {
void welcome(String address) {
mailer.send(address, "Welcome, at " + clock.now());
}
}
public static void main(String[] args) {
// The composition root: the only place that knows every concrete class.
// Everything above this line took its collaborators as parameters, so
// every one of them can be tested without touching a real mailbox.
Clock clock = () -> 1_700_000_000L;
Mailer mailer = (to, subject) -> System.out.println("to " + to + ": " + subject);
new Notifier(clock, mailer).welcome("ada@example.com");
}
}
Keep it dull. Anything clever in the composition root is something that cannot be tested.
What the build should enforce
Style arguments in code review are review spent on the wrong thing. Let the
build settle them: -Xlint:all -Werror on javac, plus a static analyser
(SpotBugs, Error Prone) and a formatter in CI. And write the tests against the
domain, where they run in milliseconds and do not need a container.
public class Tests {
record Money(long pence) {
Money plus(Money other) { return new Money(pence + other.pence); }
}
// What a real test looks like against a plain domain: no setup, no mocks.
// In a project this is JUnit; the assertions are the same shape.
public static void main(String[] args) {
assertEquals(new Money(300), new Money(100).plus(new Money(200)));
assertEquals(new Money(0), new Money(0).plus(new Money(0)));
System.out.println("2 assertions passed, in about a millisecond");
}
static void assertEquals(Object expected, Object actual) {
if (!expected.equals(actual)) {
throw new AssertionError("expected " + expected + " but was " + actual);
}
}
}
What to take away
- Domain rules in plain Java; I/O at the edges; dependencies pointing inward.
- Declare what you need as an interface the domain owns, and pass it in.
- Defensive copies (
List.copyOf) keep a record genuinely immutable. - A sealed result type makes the caller handle every expected outcome.
- Package-private by default; the package is the design unit, grouped by feature.
- One dull composition root, and let the build enforce the style.