Almost every Java project starts with one class that does everything: it reads the input, decides whether the input is acceptable, and writes the result to a database. It works. It keeps working right up to the day you want to check one of those rules without a database running, or add a second way in, or change where the data is stored — and then it stops working all at once.
This opens Part 7 of the course: application architecture. It is deliberately framework-free, and that is the whole point: splitting an application into a Controller, a Service and a Repository is a design idea that costs nothing but three files and one interface. There is no framework, no container and no dependency injection library anywhere in it — everything here is plain Java on plain javac.
![]()
Every program, error message, transcript and test run below was produced by compiling and running the code shown, unmodified, on OpenJDK 21.0.6 (arm64), with JUnit Jupiter 5.11.3 through the JUnit Platform Console Launcher 1.11.3 and the Xerial SQLite JDBC driver 3.46.1.3. The launcher's own elapsed-time line has been removed from the test output: it measures a machine, not a design, and nothing in this article is an argument about speed.
The class that does everything
Here is a user registration, written the way it usually gets written the first time. One class reads argv, applies four rules, and inserts a row.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HexFormat;
/** Parses the input, applies the rules and writes to the database. All of it. */
public class SignupBefore {
private final Connection conn;
SignupBefore(String dbPath) throws SQLException {
this.conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
try (Statement s = conn.createStatement()) {
s.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL)""");
}
}
String handle(String[] args) throws SQLException {
// 1. read the outside world
if (args.length != 4 || !args[0].equals("register")) {
return "usage: register <email> <name> <password>";
}
String email = args[1].trim().toLowerCase();
String name = args[2].trim();
String password = args[3];
// 2. the business rules
if (!email.contains("@")) return "error: email must contain @";
if (name.isEmpty()) return "error: name must not be blank";
if (password.length() < 8) return "error: password must be at least 8 characters";
try (PreparedStatement ps = conn.prepareStatement("SELECT id FROM users WHERE email = ?")) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return "error: email already registered";
}
}
// 3. storage
String hash = sha256(password);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users(email, display_name, password_hash) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, email);
ps.setString(2, name);
ps.setString(3, hash);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
return "created #" + keys.getLong(1) + " " + name;
}
}
}
static String sha256(String s) {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) throws SQLException {
System.out.println(new SignupBefore("app.db").handle(args));
}
}It compiles with nothing but the JDK, and with the SQLite driver on the classpath it runs:
curl -sSO https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar
javac -d out SignupBefore.java
java -cp out:sqlite-jdbc-3.46.1.3.jar SignupBefore register ALICE@example.com Alice hunter2secretcreated #1 AliceNothing is wrong with what it does. Everything is wrong with where it lives, and the fastest way to see that is to try to check one rule. The rule "a password must be at least eight characters" involves no database whatsoever. Run it without the driver on the classpath:
java -cp out SignupBefore register not-an-email Alice shortException in thread "main" java.sql.SQLException: No suitable driver found for jdbc:sqlite:app.db
at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:708)
at java.sql/java.sql.DriverManager.getConnection(DriverManager.java:253)
at SignupBefore.<init>(SignupBefore.java:17)
at SignupBefore.main(SignupBefore.java:74)The program never reached a single rule. The connection is opened in the constructor, so every check in this class is a database test, including the three that never touch the database. That is the first problem, and it is the expensive one: it means the rules are only ever exercised by hand, against a real store, by a person.
The second problem is in the file itself. A change to the storage format — a new column, a different table name, a move to PostgreSQL — opens SignupBefore.java. A change to the rules — passwords now need a digit — opens SignupBefore.java. Two teams with two unrelated reasons edit the same file, and every storage change puts the rules back in the blast radius.
The third shows up the moment somebody asks for a bulk import from a CSV file. The new entry point needs all four rules and none of the argv parsing. There is no way to reuse them: they are statements in the middle of a method whose first argument is String[] args and whose surrounding object owns a JDBC connection. In practice the four checks get copy-pasted, and from then on there are two definitions of what a valid registration is.
Three layers, three jobs
The split that fixes all three is older than Java and has one shape: something that talks to the outside world, something that holds the rules, and something that talks to the store.

- The Controller translates the outside world into a call and the answer back out. In a web application it reads an HTTP request and writes a status code; here there is no web framework, so it reads
argvand returns a line of text. The shape is identical — that translation is all a controller ever is. - The Service holds the business rules. It decides what a valid registration is, in what order the steps happen, and what counts as one unit of work. It is the only layer whose code is worth arguing about in a design review.
- The Repository turns objects into stored data and back. It knows the table names, the SQL and the driver. Nothing above it does.
The rule that makes it work
Layers on their own buy nothing. The rule is what buys something:
⚠️ Each layer calls only the layer directly below it, and the layer below must not know the one above exists.
Both halves matter. The first half is why a controller never touches a repository: if it does, the rule that the service enforces can be skipped by going around it, and the service is no longer the answer to "what are the rules". The second half is stronger and less obvious — the repository must contain no reference to the service, no knowledge of who called it and no idea why. That is what makes the repository replaceable, and, as the next section shows, it is enforceable by javac rather than by discipline.
The service owning the transaction boundary follows from the same rule. Only the service knows that "check the email is free, then insert the user" is one unit of work; the repository sees two unrelated calls and cannot tell whether it is in the middle of something. This article does not demonstrate transactional behaviour — that is a database subject — but it is the reason the boundary is drawn at the service and not below it.
The same feature, split in three
Same feature, the same four rules. Nine files instead of one:
src/app/
├── Main.java the wiring
├── cli/
│ └── RegisterCommand.java controller
├── users/
│ ├── User.java entity
│ ├── UserView.java DTO
│ ├── UserRepository.java interface, owned by this package
│ ├── RegistrationDenied.java a rule said no
│ └── UserService.java the rules
└── storage/
├── InMemoryUserRepository.java
└── SqliteUserRepository.javaTwo records carry the data. User is what the repository maps; UserView is what the service hands back. The section on entities and DTOs below is about why those are not the same class — for now, note only that one of them has a password hash in it and the other does not.
package app.users;
/** What the storage layer maps: every column, including the ones nobody outside may see. */
public record User(long id, String email, String displayName, String passwordHash) {
/** A user that has not been stored yet: id 0 means "the repository assigns it". */
public static User newUser(String email, String displayName, String passwordHash) {
return new User(0, email, displayName, passwordHash);
}
public User withId(long id) {
return new User(id, email, displayName, passwordHash);
}
}package app.users;
/** What leaves the service: no hash, plus a field that is computed rather than stored. */
public record UserView(long id, String displayName, String email, String initials) {
public static UserView from(User u) {
return new UserView(u.id(), u.displayName(), u.email(), initials(u.displayName()));
}
private static String initials(String displayName) {
StringBuilder sb = new StringBuilder();
for (String part : displayName.trim().split("\\s+")) {
if (!part.isEmpty()) sb.append(Character.toUpperCase(part.charAt(0)));
}
return sb.toString();
}
}The controller: argv in, one line of text out
package app.cli;
import app.users.RegistrationDenied;
import app.users.UserService;
import app.users.UserView;
/** Translates one line of argv into a call, and the answer back into a line of text. */
public final class RegisterCommand {
private final UserService users;
public RegisterCommand(UserService users) {
this.users = users;
}
public String handle(String[] args) {
if (args.length != 4 || !args[0].equals("register")) {
return "usage: register <email> <name> <password>";
}
try {
UserView created = users.register(args[1], args[2], args[3]);
return "created #" + created.id() + " " + created.displayName()
+ " (" + created.initials() + ")";
} catch (RegistrationDenied e) {
return "error: " + e.getMessage();
}
}
}Three things are worth naming here. It contains no rule — the only condition in it is about the shape of argv, which is genuinely its job. It returns the response instead of printing it, which is what makes it testable without capturing standard output and is exactly what a web framework does with a handler's return value. And it converts one failure type into one output format: RegistrationDenied becomes a line beginning with error:, and over HTTP the same catch would become a 400 without the service changing at all.
The service: the rules, and nothing else
package app.users;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
/** The rules. No parsing, no printing, no SQL. */
public final class UserService {
private final UserRepository users;
public UserService(UserRepository users) {
this.users = users;
}
public UserView register(String rawEmail, String rawName, String password) {
String email = rawEmail.trim().toLowerCase();
String name = rawName.trim();
if (!email.contains("@")) throw new RegistrationDenied("email must contain @");
if (name.isEmpty()) throw new RegistrationDenied("name must not be blank");
if (password.length() < 8) throw new RegistrationDenied("password must be at least 8 characters");
if (users.findByEmail(email).isPresent()) throw new RegistrationDenied("email already registered");
User stored = users.save(User.newUser(email, name, sha256(password)));
return UserView.from(stored);
}
public List<UserView> directory() {
return users.findAll().stream().map(UserView::from).toList();
}
private static String sha256(String s) {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}Read the imports. There is no java.sql, no String[] args, no System.out. The fourth rule needs the store — you cannot know an email is taken without asking — and it asks through users.findByEmail(email), a method whose signature says nothing about how the answer is found. RegistrationDenied is a plain RuntimeException subclass carrying a message; the service refuses in its own vocabulary and lets the caller decide what a refusal looks like on the wire.
The repository: objects in, objects out
The interface is four lines, and where it lives is the subject of the next section:
package app.users;
import java.util.List;
import java.util.Optional;
/**
* Declared in the service's own package, in the service's own vocabulary.
* It mentions no driver, no SQL and no connection.
*/
public interface UserRepository {
User save(User user);
Optional<User> findByEmail(String email);
List<User> findAll();
}Every method takes or returns a User. Nothing in the signatures is a ResultSet, a Map of column names or a row count — that is the difference between a repository and a thin wrapper over JDBC, and the section on what goes wrong comes back to it. One implementation is a Map:
package app.storage;
import app.users.User;
import app.users.UserRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** The same interface, backed by a Map. No database, no jar, no setup. */
public final class InMemoryUserRepository implements UserRepository {
private final Map<String, User> byEmail = new LinkedHashMap<>();
private long nextId = 1;
@Override
public User save(User user) {
User stored = user.id() == 0 ? user.withId(nextId++) : user;
byEmail.put(stored.email(), stored);
return stored;
}
@Override
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(byEmail.get(email));
}
@Override
public List<User> findAll() {
return new ArrayList<>(byEmail.values());
}
}The other is the SQLite one. Only the two methods that show the shape are quoted — JDBC itself was the subject of two earlier articles in this course and there is nothing new about it here:
package app.storage;
/** The only class in the program that knows SQL exists. */
public final class SqliteUserRepository implements UserRepository {
private final Connection conn;
public SqliteUserRepository(Connection conn) {
this.conn = conn;
// CREATE TABLE IF NOT EXISTS users (...) elided
}
@Override
public User save(User user) {
String sql = "INSERT INTO users(email, display_name, password_hash) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, user.email());
ps.setString(2, user.displayName());
ps.setString(3, user.passwordHash());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
keys.next();
return user.withId(keys.getLong(1));
}
} catch (SQLException e) {
throw new IllegalStateException("cannot save " + user.email(), e);
}
}
/** The row stops here. Everything above this method sees a User. */
private static User map(ResultSet rs) throws SQLException {
return new User(rs.getLong("id"), rs.getString("email"),
rs.getString("display_name"), rs.getString("password_hash"));
}
}The map method is the whole boundary in one place: below it there are columns, above it there are objects, and the checked SQLException is turned into an unchecked one so that UserService does not have to declare a database exception it cannot do anything about.
Which way the dependencies point
UserRepository is declared in app.users, next to the service that uses it, and not in app.storage next to the classes that implement it. That single placement decision is what the whole design rests on.

The general principle — that high-level policy and low-level detail should both depend on an abstraction owned by the policy — was argued earlier in this course as dependency inversion, so it is not re-derived here. What that argument leaves out is the mechanical part: which package the file goes in, and what happens at the wiring point. That is what the rest of this section is.
The package layout is the proof
If the arrows really point the way the diagram claims, then app.users can be compiled with nothing else on the classpath, and app.storage cannot. Both are one command:
javac -d out-users src/app/users/*.java # the service package, alone
javac -d out-storage src/app/storage/*.java # the storage package, aloneThe first prints nothing, which is javac for "that compiled". The second:
src/app/storage/InMemoryUserRepository.java:3: error: package app.users does not exist
import app.users.User;
^
src/app/storage/InMemoryUserRepository.java:4: error: package app.users does not exist
import app.users.UserRepository;
^
25 errorsTwenty-five errors, all of them the same complaint: the detail cannot exist without the policy, and the policy does not know the detail exists. Every import that appears anywhere in app.users, deduplicated, is a JDK package:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;This is a structural property, not a convention. Anyone who adds import app.storage.SqliteUserRepository; to the service breaks the first command, and a two-line check in a build script turns that into a failed build. Compare it with the version where the interface is declared in app.storage: the compile would still succeed, the code would look almost identical, and the service would now be unable to compile without the storage package — which means it could not be tested without one either.
Wiring it up by hand
Nothing constructs its own collaborators, so somebody has to. That somebody is main, and it is three statements long:
package app;
import app.cli.RegisterCommand;
import app.storage.InMemoryUserRepository;
import app.users.UserRepository;
import app.users.UserService;
/** The only place that decides which implementation the program runs with. */
public class Main {
public static void main(String[] args) {
UserRepository repository = new InMemoryUserRepository();
UserService service = new UserService(repository);
RegisterCommand controller = new RegisterCommand(service);
System.out.println(controller.handle(args));
}
}javac -d out $(find src -name '*.java')
java -cp out app.Main register ALICE@Example.com "Alice Nguyen" hunter2secret
java -cp out app.Main register bad-email Alice shortcreated #1 Alice Nguyen (AN)
error: email must contain @Read those three statements bottom-up and they are the diagram: the repository is built first because it depends on nothing, the service is handed a repository, the controller is handed a service. Forgetting a step is not a runtime surprise, because a constructor that requires its collaborator cannot be called without one:
error: constructor UserService in class UserService cannot be applied to given types;
UserService service = new UserService();
^
required: UserRepository
found: no argumentsSwitching the program to SQLite is one changed line in this file. Nothing in app.cli or app.users is touched, recompiled differently, or re-tested:
try (Connection conn = DriverManager.getConnection("jdbc:sqlite:app.db")) {
UserRepository repository = new SqliteUserRepository(conn); // the one line that changed
UserService service = new UserService(repository);
RegisterCommand controller = new RegisterCommand(service);
System.out.println(controller.handle(args));
}java -cp out:sqlite-jdbc-3.46.1.3.jar app.MainSqlite register alice@example.com "Alice Nguyen" hunter2secret
java -cp out:sqlite-jdbc-3.46.1.3.jar app.MainSqlite register ALICE@example.com "Alice Again" hunter2secretcreated #1 Alice Nguyen (AN)
error: email already registeredSame controller, same service, same output format, different store. And this hand-written block is precisely what a dependency injection container automates: it reads the constructor signatures, works out that UserService needs a UserRepository, finds the one implementation available, and builds the graph in the right order — so that adding a class does not mean editing main. That is worth a great deal in an application with two hundred classes. In an application with ten, three lines in main are clearer than any container, and every one of them is a line you can step through in a debugger.
What the split actually bought
The point of layering is never speed; it is the size of a change. Four changes, counted in files:
| Change | One class | Three layers |
|---|---|---|
| Add a rule ("passwords need a digit") | edit the file that also holds the SQL and the parsing | edit UserService.java |
| Replace SQLite with something else | edit the file that also holds the rules | new class in app.storage, one line in Main.java |
| Add a CSV bulk import | copy the four checks into a second file | one new file, zero rules restated |
| Check "password at least 8 characters" | requires the driver jar and a database | requires neither |
The third row is worth showing, because "reusable" is a word tutorials use and rarely demonstrate. The importer is the second caller of the same service object:
package app.cli;
import app.users.RegistrationDenied;
import app.users.UserService;
import java.util.List;
/** A second caller. It adds a file format; it re-states not one rule. */
public final class BulkImport {
private final UserService users;
public BulkImport(UserService users) {
this.users = users;
}
public List<String> handle(List<String> csvLines) {
return csvLines.stream().map(line -> {
String[] cell = line.split(",", -1);
if (cell.length != 3) return line + " -> error: expected 3 columns";
try {
return line + " -> #" + users.register(cell[0], cell[1], cell[2]).id();
} catch (RegistrationDenied e) {
return line + " -> error: " + e.getMessage();
}
}).toList();
}
}alice@example.com,Alice Nguyen,hunter2secret -> #1
ALICE@example.com,Alice Again,hunter2secret -> error: email already registered
not-an-email,Bob,hunter2secret -> error: email must contain @
carol@example.com,Carol,short -> error: password must be at least 8 charactersEvery rule applied, including the case-insensitive duplicate check, in a file that contains no rules. The only thing BulkImport knows is that a line has three comma-separated cells.
Entity or DTO: when one class is not enough
User and UserView hold nearly the same data, and a reasonable person's first instinct is to delete one of them. Here is the argument for keeping both, and then the argument for deleting one anyway.

The object your storage layer maps has to contain everything the table contains, password hash included, or the repository cannot write a row. The object your service hands out must not contain it. That is not a style preference — it is the difference between a leak and no leak, and records make the leak effortless, because toString() prints every component:
UserView view = service.register("alice@example.com", "Alice Nguyen", "hunter2secret");
User entity = repository.findByEmail("alice@example.com").orElseThrow();
System.out.println("entity: " + entity);
System.out.println("view: " + view);entity: User[id=1, email=alice@example.com, displayName=Alice Nguyen, passwordHash=5ccefdc7743291dfc5bb3825925c70d480198650b125db3ef9e7ccdf38a2e016]
view: UserView[id=1, displayName=Alice Nguyen, email=alice@example.com, initials=AN]One log.info("created " + user) on the entity puts a password hash in a log file that gets shipped to a search index. Return the entity from a web handler and a JSON serializer writes every field it can reach, including that one. Neither is a bug anyone writes deliberately; both are what happens when there is only one class and it is the one with the hash in it.
The traffic runs the other way too. initials is in the view and in no column — it is computed by the mapper, because the caller needs it and storing it would mean a second place that has to be kept in step with displayName. A masked email, a formatted total, a permission flag derived from a role: all of them belong to the shape you hand out, not the shape you store.
The cost, honestly
Two classes and a mapper for every concept is real overhead, and it is paid on every field you add: a new column means editing the entity, the mapper, the view, and every test that constructs either one. For a five-table internal tool with one caller and no public API, that overhead buys nothing and one class is the right call. The honest rule is that the second class appears when the two shapes actually diverge — a field that must not leave, or a field that does not exist in the store — and not one day earlier. In this example the divergence is exactly two fields, which is precisely when it starts to be worth it.
Nothing about that decision changes the layering. A service can return entities to its callers and still be a service; it just has to be a service in a program where no entity has a secret in it.
Testing each layer without a database
The claim that opened this article was that the rules could not be checked without a database. Here is the check, with an in-memory repository and no database anywhere:
package app.users;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import app.storage.InMemoryUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class UserServiceTest {
private UserRepository repository;
private UserService service;
@BeforeEach
void setUp() {
repository = new InMemoryUserRepository();
service = new UserService(repository);
}
@Test
void assignsAnIdAndComputesInitials() {
UserView v = service.register("alice@example.com", "Alice Nguyen", "hunter2secret");
assertEquals(1, v.id());
assertEquals("AN", v.initials());
}
@Test
void rejectsAShortPassword() {
RegistrationDenied e = assertThrows(RegistrationDenied.class,
() -> service.register("bob@example.com", "Bob", "short"));
assertEquals("password must be at least 8 characters", e.getMessage());
}
@Test
void treatsEmailAsCaseInsensitive() {
service.register("alice@example.com", "Alice", "hunter2secret");
RegistrationDenied e = assertThrows(RegistrationDenied.class,
() -> service.register("ALICE@Example.com", "Alice", "hunter2secret"));
assertEquals("email already registered", e.getMessage());
}
@Test
void storesAHashAndNeverReturnsIt() {
service.register("alice@example.com", "Alice", "hunter2secret");
User stored = repository.findByEmail("alice@example.com").orElseThrow();
assertNotEquals("hunter2secret", stored.passwordHash());
assertEquals(64, stored.passwordHash().length());
assertTrue(service.directory().toString().indexOf("hunter2secret") < 0);
}
}The controller is testable the same way, because it returns its response instead of printing it. Its test wires the real service on top of the same in-memory repository, which means three layers exercised end to end with no database and no mock:
package app.cli;
import static org.junit.jupiter.api.Assertions.assertEquals;
import app.storage.InMemoryUserRepository;
import app.users.UserService;
import org.junit.jupiter.api.Test;
class RegisterCommandTest {
private final RegisterCommand command =
new RegisterCommand(new UserService(new InMemoryUserRepository()));
@Test
void printsUsageWhenTheArgumentsAreWrong() {
assertEquals("usage: register <email> <name> <password>",
command.handle(new String[] { "register", "alice@example.com" }));
}
@Test
void turnsADeniedRegistrationIntoAnErrorLine() {
assertEquals("error: email must contain @",
command.handle(new String[] { "register", "nope", "Alice", "hunter2secret" }));
}
@Test
void formatsTheCreatedUser() {
assertEquals("created #1 Alice Nguyen (AN)",
command.handle(new String[] { "register", "alice@example.com", "Alice Nguyen", "hunter2secret" }));
}
}Both classes run from the console launcher, with no build tool:
curl -sSO https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.11.3/junit-platform-console-standalone-1.11.3.jar
javac -cp junit-platform-console-standalone-1.11.3.jar:out -d out $(find test -name '*.java')
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
--select-class=app.users.UserServiceTest \
--select-class=app.cli.RegisterCommandTest --details=summary[ 5 containers found ]
[ 0 containers skipped ]
[ 5 containers started ]
[ 0 containers aborted ]
[ 5 containers successful ]
[ 0 containers failed ]
[ 7 tests found ]
[ 0 tests skipped ]
[ 7 tests started ]
[ 0 tests aborted ]
[ 7 tests successful ]
[ 0 tests failed ]Seven tests, no jdbc: URL, no schema, no cleanup between tests — @BeforeEach builds a fresh Map and that is the entire fixture. The layer that still needs a database is the repository, and it is the only one: a test for SqliteUserRepository opens a real connection and checks that what was saved comes back, which is a worthwhile test and a slower one. The point of the split is that it is the only kind of test that needs one.
Where layering goes wrong
The failure modes are more interesting than the success case, because all four of them produce code that still has three packages and looks layered in a diagram.
The anaemic service
A service whose every method is one line of forwarding to the repository holds no rules, so the rules end up in whichever caller was written first — and the second caller never gets them.
record Account(long id, String email) {}
/** A service that holds no rule. Every method is one line of forwarding. */
final class AccountService {
private final List<Account> store = new ArrayList<>();
Account create(String email) { // no validation anywhere
Account a = new Account(store.size() + 1, email);
store.add(a);
return a;
}
List<Account> findAll() { return store; }
}
/** So the rule ends up here, in one of the two callers. */
final class AccountController {
private final AccountService accounts;
AccountController(AccountService accounts) { this.accounts = accounts; }
String handle(String email) {
if (!email.contains("@")) return "error: email must contain @"; // the rule lives here
return "created #" + accounts.create(email).id();
}
}
/** The other caller never got the memo. */
final class AccountImporter {
private final AccountService accounts;
AccountImporter(AccountService accounts) { this.accounts = accounts; }
String handle(String csvLine) {
return "imported #" + accounts.create(csvLine.trim()).id();
}
}
public class Anaemic {
public static void main(String[] args) {
AccountService service = new AccountService();
System.out.println(new AccountController(service).handle("not-an-email"));
System.out.println(new AccountImporter(service).handle("not-an-email"));
System.out.println("stored: " + service.findAll());
}
}error: email must contain @
imported #1
stored: [Account[id=1, email=not-an-email]]The controller refused the value and the importer stored it, in the same run, through the same service object. This is the most common way a layered codebase becomes a badly organised one: the diagram is right and the rules are in the wrong box. The test is blunt — if deleting the service and calling the repository from the controller would change nothing, the service is not a layer, it is a forwarding fee.
The repository that returns rows
A repository is defined by its signatures. Return a ResultSet, a Map of column names or a raw row and the abstraction is gone: the caller has to know the column names, and it inherits the lifecycle of an open cursor.
/** A "repository" that returns rows instead of objects. */
class RowRepository {
private final Connection conn;
RowRepository(Connection conn) { this.conn = conn; }
/** Leaks the ResultSet, and with it the open cursor it depends on. */
ResultSet findAll() throws SQLException {
try (Statement s = conn.createStatement()) {
return s.executeQuery("SELECT id, email, display_name FROM users ORDER BY id");
}
}
}The try-with-resources closes the Statement, and closing a Statement closes the ResultSet it produced. The caller does everything right — one row was inserted before the call — and still gets nothing:
ResultSet rs = new RowRepository(conn).findAll();
int rows = 0;
while (rs.next()) rows++;
System.out.println("rows seen = " + rows);
System.out.println("rs.isClosed = " + rs.isClosed());rows seen = 0
rs.isClosed = trueNo exception, no warning, one row in the table and zero rows seen. That silence is driver behaviour — this run used the Xerial SQLite driver 3.46.1.3, and a different driver may throw instead — which is exactly the problem: the caller's correctness now depends on which vendor jar is on the classpath. A repository that returns List<User> has no such failure mode, because the rows have already been consumed and mapped before the method returns.
The service that is a bag of static methods
The third failure mode is a class called SomethingService whose methods are all static and whose state is a static field. It is a service in name, and it has no seam: there is no constructor to pass a different repository to, so a test runs against whatever the class decided to use, and the tests share it.
/** A "service" that is a bag of static methods over static state. */
final class UserUtils {
private static final Map<String, String> STORE = new LinkedHashMap<>();
private UserUtils() {}
static String register(String email) {
if (STORE.containsKey(email)) return "error: email already registered";
STORE.put(email, email);
return "created " + email;
}
}
public class StaticBag {
public static void main(String[] args) {
System.out.println("test 1: " + UserUtils.register("alice@example.com"));
System.out.println("test 2: " + UserUtils.register("alice@example.com"));
}
}test 1: created alice@example.com
test 2: error: email already registeredThe second call is a different test, and it fails because the first one left data behind. There is no argument to change, no object to recreate, and no way to reset the store short of adding a method that exists only for tests. The instance version has none of these problems, and the difference between the two is one keyword.
The fourth failure mode needs no example, because it looks like ordinary code: a business rule in the controller. if (order.total() > 1000) discount = 0.1; in a request handler is a rule, and it is now invisible to every other entry point, untestable without constructing a request, and permanently out of reach of the service that is supposed to be the answer to "what are the rules". The question to ask about any line in a controller is whether it would still be true if the request arrived over a different protocol. If it would, it belongs one layer down.
FAQ
What is layered architecture in Java?
Splitting an application into layers with one responsibility each — typically Controller for translating the outside world into calls, Service for business rules, Repository for storage — where each layer depends only on the layer below it. It needs no framework and no library: three packages, one interface and a constructor argument are the whole mechanism.
What is the difference between a service and a repository?
The repository knows how data is stored and nothing about why. The service knows the rules and nothing about how they are stored. A useful test: a method that could be described as "find, save or delete these objects" belongs in the repository; a method described as "when X, then Y is not allowed" belongs in the service.
Should the repository return an entity or a DTO?
An entity — an object in your domain's own vocabulary. The DTO is the shape you hand to a caller, and deciding it is the service's job, not the storage layer's. A repository that returns DTOs has to know what each caller wants, which is the dependency running backwards.
Do I need all three layers in a small project?
No. Three layers cost you two indirections, and for a script or a one-screen tool that is a bad trade. The point at which they start paying is the point at which there is more than one entry point, a rule anyone argues about, or a test you would like to run without the database. Most projects reach all three sooner than the person who started them expected.
Where do transactions belong?
At the service, because only the service knows which sequence of repository calls is one unit of work. A repository method that starts and commits its own transaction cannot be composed with a second one, so the two writes it takes to move money between accounts become two independent transactions with a window between them.
Can the controller call the repository directly?
It compiles, and it is how layering quietly stops being layering. Once one handler skips the service, the service is no longer the complete answer to what the rules are, and the next person reading the code has to check both paths. If a read is genuinely rule-free, the honest fix is a one-line service method that forwards it, not a shortcut around the layer.
Do I need a dependency injection framework for this?
No. The wiring in this article is three constructor calls in main, and it works, is debuggable and requires no dependency at all. A container becomes worth its weight when the graph is large enough that maintaining those calls by hand is a chore, or when it brings other things you want with it — configuration, lifecycle management, transaction handling.
Conclusion
Layering is a design idea, not a feature of any framework. The whole mechanism is in this article: one interface owned by the package that uses it, one implementation per store, constructor arguments instead of new inside a method, and one place in main that decides which implementation runs. What it buys is measured in files touched per change and in what you can test without a database — seven tests here, none of which need one.
Keep the rule and the rest follows: each layer calls only the layer below, and the layer below does not know the one above exists. When that stops being true, the packages still look right and the design has already gone.
Article 33 begins the framework half of Part 7 with the Spring Framework and Spring Boot, where the container reads those same constructor signatures and builds the object graph for you — the wiring done by hand here, done by convention instead.