Command Palette

Search for a command to run...

[Advanced Java] Layered Architecture in Java: Controller, Service and Repository

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.

Three stacked slabs reading Controller, Service and Repository with a single arrow running down through them

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.

Java
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:

Bash
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 hunter2secret
Text
created #1 Alice

Nothing 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:

Bash
java -cp out SignupBefore register not-an-email Alice short
Text
Exception 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.

Three cards: Controller, Service and Repository, each listing what it owns and what it must never hold

  • 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 argv and 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:

Tree
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.java

Two 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.

Java
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);
    }
}
Java
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

Java
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

Java
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:

Java
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:

Java
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:

Java
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.

RegisterCommand in app.cli pointing down at UserService in app.users, which points at the UserRepository interface, with both storage implementations pointing up at that interface

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:

Bash
javac -d out-users   src/app/users/*.java      # the service package, alone
javac -d out-storage src/app/storage/*.java    # the storage package, alone

The first prints nothing, which is javac for "that compiled". The second:

Text
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 errors

Twenty-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:

Text
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:

Java
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));
    }
}
Bash
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 short
Text
created #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:

Text
error: constructor UserService in class UserService cannot be applied to given types;
        UserService service = new UserService();
                              ^
  required: UserRepository
  found:    no arguments

Switching 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:

Java
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));
}
Bash
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" hunter2secret
Text
created #1 Alice Nguyen (AN)
error: email already registered

Same 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:

ChangeOne classThree layers
Add a rule ("passwords need a digit")edit the file that also holds the SQL and the parsingedit UserService.java
Replace SQLite with something elseedit the file that also holds the rulesnew class in app.storage, one line in Main.java
Add a CSV bulk importcopy the four checks into a second fileone new file, zero rules restated
Check "password at least 8 characters"requires the driver jar and a databaserequires 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:

Java
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();
    }
}
Text
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 characters

Every 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 User record with its passwordHash field marked as never leaving, an arrow labelled UserView.from(user), and the UserView record with a computed initials field

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:

Java
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);
Text
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:

Java
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:

Java
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:

Bash
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
Text
[         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.

Java
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());
    }
}
Text
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.

Java
/** 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:

Java
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());
Text
rows seen   = 0
rs.isClosed = true

No 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.

Java
/** 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"));
    }
}
Text
test 1: created alice@example.com
test 2: error: email already registered

The 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.

Related Posts

[Spring Boot Basics] Layered Architecture in Spring Boot: Controller, Service, Repository and Package by Layer vs by Feature

Layered architecture in Spring Boot 4.1.1, refactored and verified on a real catalogue API: the controller that does everything and the startup crash when it is reused, what controller, service and repository each own, where DTO mapping belongs, a step-by-step refactor proven unchanged with curl, a service interface or a concrete class checked with Mockito 5.23, five layering anti-patterns, and package by layer versus package by feature measured on one change, with package-private beans injected and the compiler error that keeps features apart.

[Advanced Java] Comparable vs Comparator in Java: Natural Ordering and Custom Sorting

Comparable and Comparator on OpenJDK 21: the full compareTo contract, the Comparator factory and combinator API, why reversed flips every key composed so far, the int subtraction overflow bug, the TimSort IllegalArgumentException that only fires on large inputs, null keys, and sorting stability measured in comparator invocations.

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.

[Advanced Java] SOLID Principles in Java: Five Rules and When to Break Them

The five SOLID principles in Java on OpenJDK 21, each with a before and after that compiles and runs: a class split by its reasons to change, a growing switch replaced by an interface, a subclass that breaks its caller with no warning, an UnsupportedOperationException the compiler could have prevented, a class that cannot run without a file, and where each principle stops paying for itself.