Command Palette

Search for a command to run...

[Advanced Java] Kiến trúc Layered trong Java: Controller, Service và Repository

Gần như mọi project Java đều bắt đầu bằng một class làm tất cả: đọc input, quyết định input đó có hợp lệ không, rồi ghi kết quả xuống database. Nó chạy được. Và nó chạy được cho tới đúng cái ngày bạn muốn kiểm tra một rule mà không cần database, hoặc muốn thêm một đường vào thứ hai, hoặc muốn đổi chỗ lưu dữ liệu — lúc đó nó hỏng, hỏng toàn bộ cùng một lúc.

Đây là bài mở đầu Part 7 của series: kiến trúc application. Bài này cố tình không dùng framework nào, và đó chính là điểm mấu chốt: tách một application thành Controller, Service và Repository là một ý tưởng thiết kế, giá của nó chỉ là ba file và một interface. Không có framework, không có container, không có library dependency injection nào ở đây — tất cả là Java thuần chạy bằng javac.

Ba thanh xếp chồng ghi Controller, Service và Repository với một mũi tên chạy dọc xuống

Mọi chương trình, thông báo lỗi, transcript và lần chạy test bên dưới đều được compile và chạy đúng như code hiển thị, trên OpenJDK 21.0.6 (arm64), với JUnit Jupiter 5.11.3 qua JUnit Platform Console Launcher 1.11.3 và Xerial SQLite JDBC driver 3.46.1.3. Dòng thời gian mà console launcher tự in ra đã được bỏ đi: nó đo một cái máy chứ không đo một thiết kế, và không có chỗ nào trong bài này tranh luận về tốc độ.

Một class làm tất cả mọi thứ

Đây là chức năng đăng ký user, viết theo đúng cách người ta thường viết ở lần đầu tiên. Một class đọc argv, áp bốn rule, rồi insert một 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));
    }
}

Nó compile chỉ với JDK, và khi có SQLite driver trên classpath thì chạy được ngay:

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

Nó làm gì thì không sai. Sai là ở chỗ những thứ đó nằm chung một nơi, và cách nhanh nhất để thấy điều đó là thử kiểm tra một rule. Rule "password phải dài ít nhất tám ký tự" chẳng liên quan gì đến database. Chạy nó khi không có driver trên 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)

Chương trình chưa chạm tới một rule nào. Connection được mở ngay trong constructor, nên mọi phép kiểm tra trong class này đều là một bài test database, kể cả ba cái không hề đụng tới database. Đó là vấn đề thứ nhất, và nó là vấn đề đắt tiền nhất: nghĩa là các rule chỉ được kiểm tra thủ công, trên một store thật, bởi một con người.

Vấn đề thứ hai nằm ngay trong file. Đổi cách lưu trữ — thêm một column, đổi tên bảng, chuyển sang PostgreSQL — thì mở SignupBefore.java. Đổi rule — password giờ phải có chữ số — cũng mở SignupBefore.java. Hai nhóm người với hai lý do chẳng liên quan gì nhau cùng sửa một file, và mỗi lần đổi storage là một lần các rule bị kéo vào vùng nguy hiểm.

Vấn đề thứ ba xuất hiện ngay khi có người xin thêm chức năng import hàng loạt từ file CSV. Đường vào mới cần cả bốn rule và không cần chút nào phần parse argv. Không có cách nào tái sử dụng: chúng là những câu lệnh nằm giữa một method có tham số đầu tiên là String[] args, trong một object đang giữ một JDBC connection. Trên thực tế bốn phép kiểm tra đó sẽ bị copy-paste, và từ đó trở đi có hai định nghĩa khác nhau về thế nào là một đăng ký hợp lệ.

Ba layer, ba nhiệm vụ

Cách tách giải quyết cả ba vấn đề trên còn già hơn cả Java, và nó chỉ có một hình dạng: một chỗ nói chuyện với thế giới bên ngoài, một chỗ giữ rule, một chỗ nói chuyện với store.

Ba card Controller, Service và Repository, mỗi card liệt kê những gì nó sở hữu và những gì tuyệt đối không được chứa

  • Controller dịch thế giới bên ngoài thành một lời gọi, rồi dịch câu trả lời ngược ra. Trong một web application nó đọc HTTP request và ghi status code; ở đây không có web framework nào nên nó đọc argv và trả về một dòng text. Hình dạng thì y hệt — controller từ trước tới nay chỉ là phép dịch đó.
  • Service giữ business rule. Nó quyết định thế nào là một đăng ký hợp lệ, các bước diễn ra theo thứ tự nào, và cái gì được tính là một đơn vị công việc. Đây là layer duy nhất mà code của nó đáng đem ra tranh luận trong một buổi design review.
  • Repository biến object thành dữ liệu được lưu và ngược lại. Nó biết tên bảng, biết SQL, biết driver. Không có thứ gì phía trên nó biết những điều đó.

Luật khiến nó hoạt động

Chỉ chia layer thôi thì chẳng được gì. Chính cái luật này mới là thứ mang lại giá trị:

⚠️ Mỗi layer chỉ gọi layer ngay dưới nó, và layer dưới không được biết layer trên tồn tại.

Cả hai vế đều quan trọng. Vế đầu là lý do controller không bao giờ chạm tới repository: nếu nó chạm, rule mà service đang giữ có thể bị đi vòng qua, và service không còn là câu trả lời cho câu hỏi "rule là gì" nữa. Vế sau mạnh hơn và ít hiển nhiên hơn — repository không được chứa reference nào tới service, không biết ai gọi nó và cũng không biết vì sao. Đó là thứ khiến repository thay thế được, và như phần tiếp theo cho thấy, nó được javac bắt lỗi chứ không phải được giữ bằng tinh thần tự giác.

Việc service sở hữu transaction boundary cũng đến từ đúng luật đó. Chỉ service mới biết "kiểm tra email còn trống, rồi insert user" là một đơn vị công việc; repository chỉ thấy hai lời gọi rời rạc và không thể biết nó đang ở giữa chuyện gì. Bài này không demo hành vi transaction — đó là chủ đề của database — nhưng đó là lý do ranh giới được vẽ ở service chứ không phải thấp hơn.

Cùng một tính năng, tách làm ba

Vẫn tính năng đó, vẫn đúng bốn rule đó. Chỉ là chín file thay vì một:

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

Hai record mang dữ liệu. User là thứ repository map xuống bảng; UserView là thứ service trả ra ngoài. Phần nói về entity và DTO ở dưới sẽ giải thích vì sao chúng không phải cùng một class — bây giờ chỉ cần để ý một điều: một cái có password hash bên trong, cái kia thì không.

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

Controller: argv vào, một dòng text ra

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

Có ba điểm đáng gọi tên ở đây. Nó không chứa rule nào — điều kiện duy nhất trong nó là về hình dạng của argv, và đó đúng là việc của nó. Nó return response chứ không in ra, đó là thứ khiến nó test được mà không phải bắt standard output, và cũng đúng là cách một web framework xử lý giá trị trả về của một handler. Và nó đổi một loại thất bại thành một định dạng output: RegistrationDenied thành một dòng bắt đầu bằng error:, còn qua HTTP thì cũng khối catch đó sẽ thành 400 mà service không phải đổi một chữ nào.

Service: chỉ có rule, không gì khác

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

Hãy đọc phần import. Không có java.sql, không có String[] args, không có System.out. Rule thứ tư thì cần tới store — không hỏi thì không thể biết một email đã bị lấy hay chưa — và nó hỏi qua users.findByEmail(email), một method mà signature của nó không hé lộ gì về cách câu trả lời được tìm ra. RegistrationDenied chỉ là một subclass của RuntimeException mang theo message; service từ chối bằng vốn từ của chính nó và để phía gọi quyết định lời từ chối trông như thế nào khi ra ngoài.

Repository: object vào, object ra

Interface chỉ có bốn dòng, và chuyện nó nằm ở đâu chính là chủ đề của phần kế tiếp:

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

Mọi method đều nhận vào hoặc trả về một User. Không signature nào có ResultSet, có Map tên column hay có số row bị ảnh hưởng — đó chính là khác biệt giữa một repository và một lớp bọc mỏng quanh JDBC, và phần nói về những chỗ hỏng sẽ quay lại điểm này. Một implementation là một 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());
    }
}

Cái còn lại chạy trên SQLite. Chỉ trích hai method đủ để thấy hình dạng — bản thân JDBC đã là chủ đề của hai bài trước đó trong series này và ở đây không có gì mới về nó:

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

Method map gói trọn cả ranh giới vào một chỗ: dưới nó là column, trên nó là object, và SQLException dạng checked được đổi thành unchecked để UserService không phải khai báo một database exception mà nó chẳng làm gì được.

Dependency chỉ về phía nào

UserRepository được khai trong app.users, ngay cạnh service dùng nó, chứ không phải trong app.storage cạnh những class implement nó. Chỉ riêng quyết định đặt file đó ở đâu đã là nền móng của cả thiết kế này.

RegisterCommand trong app.cli chỉ xuống UserService trong app.users, UserService chỉ sang interface UserRepository, còn hai implementation ở app.storage chỉ ngược lên interface đó

Nguyên tắc chung — policy ở tầng cao và detail ở tầng thấp đều phải phụ thuộc vào một abstraction do policy sở hữu — đã được trình bày kỹ ở một bài trước trong series này dưới tên dependency inversion, nên ở đây không nhắc lại. Thứ mà lập luận đó bỏ qua là phần cơ học: file nằm ở package nào, và chuyện gì xảy ra tại điểm wiring. Đó chính là nội dung còn lại của phần này.

Chính package layout là bằng chứng

Nếu mũi tên thật sự chỉ đúng như hình vẽ, thì app.users phải compile được mà không cần gì khác trên classpath, còn app.storage thì không. Mỗi bên chỉ một câu lệnh:

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

Câu đầu không in ra gì, đó là cách javac nói "compile xong". Câu thứ hai:

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

Hai mươi lăm lỗi, tất cả đều cùng một than phiền: detail không tồn tại nổi nếu thiếu policy, còn policy thì không biết detail có tồn tại. Mọi import xuất hiện ở bất cứ đâu trong app.users, sau khi bỏ trùng, đều là package của JDK:

Text
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;

Đây là một tính chất cấu trúc chứ không phải một quy ước. Ai thêm import app.storage.SqliteUserRepository; vào service là làm hỏng câu lệnh đầu tiên, và một kiểm tra hai dòng trong build script sẽ biến chuyện đó thành một build fail. Hãy so với phương án khai interface trong app.storage: vẫn compile được, code nhìn gần như y hệt, nhưng service từ đó không compile nổi nếu thiếu package storage — nghĩa là cũng không test nổi nếu thiếu nó.

Tự wiring bằng tay

Không class nào tự tạo collaborator của mình, nên phải có ai đó làm việc đó. Người đó là main, và nó gồm đúng ba câu lệnh:

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 @

Đọc ba câu lệnh đó từ dưới lên thì đúng là cái hình vẽ: repository được dựng trước vì nó không phụ thuộc gì cả, service được đưa cho một repository, controller được đưa cho một service. Quên một bước không phải là bất ngờ lúc runtime, vì một constructor bắt buộc phải có collaborator thì không thể gọi mà thiếu nó:

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

Chuyển chương trình sang SQLite chỉ là một dòng đổi trong file này. Không có gì trong app.cli hay app.users bị đụng tới, bị compile khác đi, hay phải test lại:

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

Cùng controller, cùng service, cùng định dạng output, khác store. Và đúng cái khối viết tay này là thứ mà một dependency injection container tự động hoá: nó đọc signature của constructor, suy ra UserService cần một UserRepository, tìm ra implementation duy nhất đang có, rồi dựng cả đồ thị theo đúng thứ tự — để việc thêm một class không đồng nghĩa với sửa main. Với một application hai trăm class thì điều đó cực kỳ đáng giá. Với một application mười class, ba dòng trong main rõ ràng hơn mọi container, và từng dòng đều là dòng bạn bước qua được trong debugger.

Việc tách lớp thực sự đổi lại được gì

Mục đích của layering chưa bao giờ là tốc độ; nó là kích thước của một thay đổi. Bốn thay đổi, đếm theo số file:

Thay đổiMột classBa layer
Thêm một rule ("password phải có chữ số")sửa đúng file đang chứa cả SQL lẫn phần parsesửa UserService.java
Thay SQLite bằng thứ khácsửa đúng file đang chứa các rulethêm class trong app.storage, đổi một dòng ở Main.java
Thêm chức năng import CSVcopy bốn phép kiểm tra sang file thứ haimột file mới, không nhắc lại rule nào
Kiểm tra "password ít nhất 8 ký tự"cần driver jar và một databasekhông cần cái nào

Dòng thứ ba đáng được chứng minh, vì "tái sử dụng được" là từ mà các bài hướng dẫn hay nói và hiếm khi cho xem. Importer là caller thứ hai của cùng một 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

Mọi rule đều được áp, kể cả phép kiểm tra trùng email không phân biệt hoa thường, trong một file không chứa rule nào. Thứ duy nhất BulkImport biết là một dòng có ba ô ngăn cách bằng dấu phẩy.

Entity hay DTO: khi một class là không đủ

UserUserView giữ gần như cùng một mớ dữ liệu, và phản xạ đầu tiên của một người tỉnh táo là xoá bớt một cái. Đây là lý lẽ để giữ cả hai, và ngay sau đó là lý lẽ để vẫn cứ xoá một cái.

Record User với field passwordHash được đánh dấu là không bao giờ đi ra, một mũi tên ghi UserView.from(user), và record UserView với field initials được tính ra

Object mà storage layer map bắt buộc phải chứa mọi thứ bảng đang chứa, kể cả password hash, nếu không repository không ghi nổi một row. Object mà service trả ra ngoài thì bắt buộc không được chứa nó. Đó không phải chuyện sở thích — đó là khác biệt giữa rò rỉ và không rò rỉ, và record khiến việc rò rỉ dễ đến mức không cần cố gắng, vì toString() in ra mọi 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]

Chỉ một câu log.info("created " + user) trên entity là đủ đẩy một password hash vào file log rồi được đẩy tiếp lên một search index. Trả entity thẳng từ một web handler thì JSON serializer sẽ ghi ra mọi field nó với tới được, kể cả field đó. Không ai cố tình viết ra hai lỗi này; cả hai đều là hệ quả của việc chỉ có một class, và class đó là class có hash bên trong.

Chiều ngược lại cũng đúng. initials có trong view và không có trong column nào — nó do mapper tính ra, vì phía gọi cần nó còn lưu nó xuống nghĩa là có thêm một chỗ nữa phải giữ đồng bộ với displayName. Một email che bớt, một tổng tiền đã format, một cờ quyền suy ra từ role: tất cả đều thuộc về hình dạng bạn trả ra, không phải hình dạng bạn lưu.

Cái giá, nói thẳng

Hai class cộng một mapper cho mỗi khái niệm là chi phí thật, và nó bị trả cho từng field bạn thêm vào: một column mới nghĩa là sửa entity, sửa mapper, sửa view, và sửa mọi test đang dựng một trong hai. Với một tool nội bộ năm bảng, một caller và không có public API, chi phí đó chẳng đổi lại được gì và một class mới là lựa chọn đúng. Luật trung thực là: class thứ hai chỉ xuất hiện khi hai hình dạng thật sự khác nhau — có field không được đi ra, hoặc có field không tồn tại trong store — chứ không sớm hơn một ngày nào. Trong ví dụ này khác nhau đúng hai field, và đó chính là lúc nó bắt đầu đáng giá.

Không điều gì trong quyết định đó làm thay đổi việc chia layer. Một service trả entity ra cho caller vẫn là một service; chỉ là nó phải là service trong một chương trình mà không entity nào có bí mật bên trong.

Test từng layer mà không cần database

Điều mở đầu bài viết này là các rule không thể kiểm tra được nếu không có database. Đây là phép kiểm tra đó, với một repository in-memory và không có database ở đâu cả:

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

Controller cũng test được theo đúng cách đó, vì nó trả về response chứ không in ra. Test của nó dựng service thật trên cùng một repository in-memory, nghĩa là ba layer chạy xuyên suốt mà không cần database và không cần mock nào:

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

Cả hai class chạy được từ console launcher, không cần 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          ]

Bảy test, không một URL jdbc:, không schema, không phải dọn dẹp giữa các test — @BeforeEach dựng một Map mới và đó là toàn bộ fixture. Layer duy nhất vẫn cần database là repository, và nó là cái duy nhất: một test cho SqliteUserRepository mở connection thật rồi kiểm tra thứ đã lưu có quay về đúng không, đó là một test đáng viết và chậm hơn. Điểm mấu chốt của việc tách lớp là nó là loại test duy nhất còn cần tới database.

Layered sai ở chỗ nào

Những kiểu hỏng lại thú vị hơn trường hợp thành công, vì cả bốn kiểu đều tạo ra code vẫn có ba package và nhìn trên sơ đồ vẫn rất "layered".

Service rỗng ruột

Một service mà mọi method chỉ là một dòng chuyển tiếp xuống repository thì không giữ rule nào, nên rule sẽ rơi vào caller nào được viết trước — và caller thứ hai không bao giờ nhận được nó.

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]]

Controller từ chối giá trị đó còn importer lưu nó xuống, trong cùng một lần chạy, qua cùng một service object. Đây là cách phổ biến nhất để một codebase layered biến thành một codebase sắp xếp tệ: sơ đồ thì đúng còn rule thì nằm sai ô. Phép thử rất thẳng: nếu xoá service đi và gọi thẳng repository từ controller mà không có gì thay đổi, thì service đó không phải một layer, nó là một khoản phí chuyển tiếp.

Repository trả về row

Một repository được định nghĩa bằng chính signature của nó. Trả về ResultSet, trả về Map tên column hay trả về row thô thì abstraction biến mất: phía gọi buộc phải biết tên column, và nó thừa hưởng luôn vòng đời của một cursor đang mở.

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

try-with-resources đóng Statement, và đóng một Statement là đóng luôn ResultSet mà nó tạo ra. Phía gọi làm mọi thứ đúng — một row đã được insert trước lời gọi — mà vẫn không nhận được gì:

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

Không exception, không cảnh báo, bảng có một row và đếm được không row nào. Sự im lặng đó là hành vi của driver — lần chạy này dùng Xerial SQLite driver 3.46.1.3, và một driver khác có thể ném exception thay vì im lặng — mà đó đúng là vấn đề: tính đúng đắn của phía gọi giờ phụ thuộc vào jar của hãng nào đang nằm trên classpath. Một repository trả về List<User> không có kiểu hỏng này, vì các row đã được đọc hết và map xong trước khi method return.

Service chỉ là một túi static method

Kiểu hỏng thứ ba là một class tên SomethingService mà mọi method đều static và state của nó là một field static. Nó là service về mặt tên gọi, và nó không có khe hở nào: không có constructor để truyền vào một repository khác, nên test sẽ chạy trên đúng thứ mà class đó tự chọn, và các test dùng chung thứ đó.

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

Lời gọi thứ hai là một test khác, và nó fail vì test thứ nhất để lại dữ liệu. Không có tham số nào để đổi, không có object nào để dựng lại, và không cách nào reset store ngoài việc thêm một method chỉ tồn tại vì test. Bản instance không dính vấn đề nào trong số đó, và khác biệt giữa hai bản chỉ là một từ khoá.

Kiểu hỏng thứ tư không cần ví dụ, vì nó trông y như code bình thường: một business rule nằm trong controller. if (order.total() > 1000) discount = 0.1; trong một request handler là một rule, và nó lập tức vô hình với mọi đường vào khác, không test nổi nếu không dựng một request, và vĩnh viễn nằm ngoài tầm với của service — nơi đáng lẽ phải là câu trả lời cho "rule là gì". Câu hỏi cần đặt ra với mọi dòng trong controller: dòng này có còn đúng không nếu request tới bằng một protocol khác? Nếu còn, chỗ của nó là một layer thấp hơn.

FAQ

Kiến trúc layered trong Java là gì?

Là việc tách một application thành các layer, mỗi layer một trách nhiệm — thường là Controller để dịch thế giới bên ngoài thành lời gọi, Service giữ business rule, Repository lo phần lưu trữ — trong đó mỗi layer chỉ phụ thuộc vào layer ngay dưới nó. Nó không cần framework và không cần library: ba package, một interface và một constructor argument là toàn bộ cơ chế.

Service và repository khác nhau ở chỗ nào?

Repository biết dữ liệu được lưu ra sao và không biết gì về vì sao. Service biết rule và không biết gì về cách lưu. Một phép thử dùng được: method nào mô tả được bằng "tìm, lưu hoặc xoá những object này" thì thuộc repository; method nào mô tả bằng "khi X thì không được phép Y" thì thuộc service.

Repository nên trả về entity hay DTO?

Entity — một object bằng vốn từ của chính domain bạn. DTO là hình dạng bạn đưa cho caller, và quyết định hình dạng đó là việc của service chứ không phải của storage layer. Một repository trả về DTO sẽ phải biết từng caller muốn gì, và đó chính là dependency chạy ngược chiều.

Project nhỏ có cần đủ ba layer không?

Không. Ba layer khiến bạn trả giá bằng hai lần gián tiếp, và với một script hay một tool một màn hình thì đó là cái giá tồi. Chúng bắt đầu đáng giá khi có hơn một đường vào, khi có một rule mà người ta phải tranh luận, hoặc khi có một test bạn muốn chạy mà không cần database. Phần lớn project chạm cả ba mốc đó sớm hơn người khởi tạo nó tưởng.

Transaction thuộc về đâu?

Thuộc về service, vì chỉ service mới biết chuỗi lời gọi repository nào là một đơn vị công việc. Một repository method tự mở và tự commit transaction của riêng nó thì không ghép được với method thứ hai, nên hai lần ghi để chuyển tiền giữa hai tài khoản sẽ thành hai transaction độc lập với một khe hở ở giữa.

Controller gọi thẳng repository được không?

Compile được, và đó là cách layering âm thầm ngừng là layering. Chỉ cần một handler đi tắt qua service, service không còn là câu trả lời đầy đủ cho câu hỏi rule là gì, và người đọc code sau bạn phải kiểm tra cả hai đường. Nếu một thao tác đọc thật sự không có rule nào, cách sửa trung thực là thêm một service method một dòng chuyển tiếp nó, chứ không phải đi tắt qua layer.

Có cần framework dependency injection cho việc này không?

Không. Phần wiring trong bài này là ba lời gọi constructor trong main, nó chạy được, debug được và không cần thêm dependency nào. Container chỉ đáng công khi đồ thị object đủ lớn để việc bảo trì đống lời gọi đó bằng tay trở thành cực hình, hoặc khi nó kèm theo những thứ khác bạn cần — config, quản lý lifecycle, xử lý transaction.

Kết luận

Layering là một ý tưởng thiết kế, không phải tính năng của framework nào. Toàn bộ cơ chế nằm gọn trong bài này: một interface do chính package dùng nó sở hữu, mỗi store một implementation, constructor argument thay cho new bên trong method, và một chỗ duy nhất trong main quyết định implementation nào được chạy. Thứ đổi lại được đo bằng số file phải mở cho mỗi thay đổi và bằng những gì bạn test được mà không cần database — ở đây là bảy test, không test nào cần tới nó.

Giữ đúng cái luật thì phần còn lại tự đến: mỗi layer chỉ gọi layer ngay dưới, và layer dưới không biết layer trên tồn tại. Khi điều đó không còn đúng, các package vẫn nhìn rất ổn còn thiết kế thì đã hỏng từ trước.

Bài 33 mở đầu nửa framework của Part 7 với Spring Framework và Spring Boot, nơi container đọc chính những signature constructor đó và tự dựng đồ thị object cho bạn — phần wiring làm tay ở đây, làm bằng quy ước.

Bài viết liên quan

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Class Collections trong Java: algorithm, wrapper và factory

java.util.Collections trên OpenJDK 21 sắp xếp theo đúng bản chất từng nhóm method: các algorithm ghi đè tại chỗ cùng mẹo insertion point của binarySearch, nCopies trả về một reference lặp n lần, ba wrapper unmodifiable, synchronized và checked vốn là view chứ không phải bản copy, checkedList bắt heap pollution ngay lúc insert, và các immutable factory đã thay thế phân nửa số method cũ.

[Advanced Java] Functional Interface trong Java: Supplier, Consumer, Function và Predicate

Functional interface trong java.util.function trên OpenJDK 21: lưới shape đứng sau cả 43 interface, @FunctionalInterface thực sự kiểm tra cái gì, vì sao một abstract equals không phá vỡ quy tắc một abstract method, andThen và compose, các combinator của Predicate và Consumer, primitive specialisation cùng phần boxing chúng loại bỏ, và cách tự viết một cái cho riêng mình.

[Advanced Java] Design Pattern phổ biến trong Java: Singleton, Factory, Builder, Observer và Strategy

Năm design pattern trong Java trên OpenJDK 21, mỗi pattern đều được chỉ ra ngay bên trong JDK và đều kèm trường hợp nó gây hại: initialization của singleton và cuộc đua mà null check không đồng bộ thua, Integer.valueOf cùng cache của nó, staged builder được compiler kiểm tra, listener gây leak và listener làm dừng cả lượt broadcast, và Comparator chính là strategy type bạn vẫn dùng hằng ngày.