(Chú ý: Phần này liên quan tới kỹ thuật code nên sẽ có... code. Điều này có thể làm giảm sự tập trung của bạn. Tham khảo thêm: Ví dụ triển khai Layered Architecture)
Định nghĩa chuẩn về Transaction Script, bạn có thể xem ở đây bởi Martin Fowler. Phần dưới đây là giải thích chi tiết và từng bước chuyển đổi sang Domain Model "like".
Trong vài năm gần đây, khi phỏng vấn backend, tôi có hỏi các ứng viên về cách triển khai source code trong một ứng dụng. Phần lớn kết quả tôi nhận được là phương án triển khai như sau:
- Phân ứng dụng thành các package:
controller
(chứa các class Controller, giao tiếp qua Restful API),service
(chứa các interface và class Service, thực hiện nghiệp vụ),repository
(chứa các interface/class Repository để giao tiếp với database), và cuối cùng làentity
(chứa các thực thể nghiệp vụ). - Các class entity thuần túy đóng vai trò
data class
: là những class chỉ chứa các thuộc tính tương ứng với cơ sở dữ liệu và các phương thức get/set cho các thuộc tính đó. - Các class/interface Repository: save/find/search để thao tác với cơ sở dữ liệu, làm trung gian để tầng logic business lấy về các data class rồi thực hiện áp dụng quy tắc nghiệp vụ.
- Và cuối cùng, cái mà chúng ta bàn tới trong bài viết này: các interface Service khai báo các method nghiệp vụ và rồi có các class ServiceImpl hiện thực hóa các interface đó.
Tôi hỏi họ rằng có cách làm nào khác nữa không, câu trả lời thường là không hoặc một vài biến thể tương tự.
Trong bài viết này, chúng ta hãy cùng làm rõ cách triển khai này để xem ưu, nhược điểm là gì và tại sao nó lại phổ biến như vậy.
Bài toán ví dụ
Cùng quay sang bài toán mẫu trong bài này: Tìm tài xế cho đơn hàng.
Transaction Script
Như trong bài toán mẫu đã phân tích, ta có những thông tin sau.
Domain entities:
Shipment: chứa thông tin đơn hàng như mã, khối lượng, loại đơn, vị trí cần tới lấy,... và mã của tài xế thể hiện đơn hàng đã gán cho tài xế đó.
Driver: chứa thông tin tài xế như mã, tải trọng hiện tại, vị trí hiện tại,...
Và tất nhiên, các class này sẽ thường chỉ có getters/setters vì chúng là các data class.
(code dưới đây sử dụng Spring framework)
@Entity class Shipment { @Id private String shipmentID; private double weight; private String type; private double pickupLatitude; private double pickupLongitude; private String driverID; private double fee; @Enumerated(EnumType.STRING) private ShipmentStatus status; // getters and setters // ... }
@Entity
class Shipment {
@Id
private String shipmentID;
private double weight;
private String type;
private double pickupLatitude;
private double pickupLongitude;
private String driverID;
private double fee;
@Enumerated(EnumType.STRING)
private ShipmentStatus status;
// getters and setters
// ...
}
Giải thích:
- fee: phí của đơn hàng sau khi đã gán cho driver. Được tính toán dựa vào khoảng cách.
- status: trạng thái của đơn hàng: PENDING (đang chờ gán tài xế), FINDING (đang tìm tài xế), ASSIGNED (đã tìm được tài xế),...
@Entity class Driver { @Id private String driverID; private double currentLoad; private double currentLatitude; private double currentLongitude; // getters and setters // ... }
@Entity
class Driver {
@Id
private String driverID;
private double currentLoad;
private double currentLatitude;
private double currentLongitude;
// getters and setters
// ...
}
Giải thích:
- currentLoad: tải trọng của tài xế hiện tại.
- currentLatitude và currentLongitude: vị trí hiện tại của tài xế.
Repositories:
Có hai repository tương ứng với hai domain entity bên trên:
ShipmentRepository:
@Repository public interface ShipmentRepository extends JpaRepository { // Additional custom methods here... }
@Repository
public interface ShipmentRepository extends JpaRepository {
// Additional custom methods here...
}
DriverRepository:
@Repository public interface DriverRepository extends JpaRepository { // Additional custom methods here... }
@Repository
public interface DriverRepository extends JpaRepository {
// Additional custom methods here...
}
(Đây có đúng là hai Repository thể hiện đúng bản chất của Repository pattern không? Chúng ta sẽ bàn tới trong bài viết riêng về pattern này. Ngắn gọn trước thì câu trả lời là: Không.)
Services:
Giờ cần lựa chọn tên của service cho nghiệp vụ tìm tài xế cho đơn hàng. Ta nên đặt là gì? ShipmentService hay DriverService?
Nếu sử dụng cách đặt tên này, ví dụ ShipmentService, ta ngầm định rằng cứ nghiệp vụ gì liên quan tới đơn hàng cũng sẽ cần bổ sung thêm một method trong interface này. Khi đã nắm vững cách thiết kế module, bạn sẽ thấy mình vô tình tạo ra những thành phần phần mềm có tính coupling cao, còn cohesion thấp. Kết quả là bạn rất khó tách những method này ra thành các thành phần riêng hoặc service riêng. Dấu hiệu để tách là gì? Bạn xem lại các bài viết về CCP, CRP, và REP nhé.
Vậy nên, tôi sẽ lựa chọn cách khác một chút, tên service thể hiện đúng use case mà service đó đảm nhận: ShipmentAssigmentService
(hoặc cái tên khác ổn hơn: ShipmentAssigmentUseCase
). Trong này có một method duy nhất: assignShipment(...)
.
public interface ShipmentAssignmentService {
void assignShipment(String shipmentID);
}
Có thể bạn thấy ngạc nhiên vì method trên trả về void
. Đây có thể coi là một "quy ước" khi thiết kế (xem CQS): những method nào chỉ thuần túy truy vấn dữ liệu thì khai báo kiểu trả về, còn những method làm thay đổi dữ liệu sẽ khai báo void
, giúp tăng khả năng bảo trì code hơn. Method khai báo như vậy nếu gặp thất bại sẽ throws exceptions.
Cùng tới cách cài đặt cho service này.
Class ShipmentAssignmentServiceImpl
realizes ShipmentAssignmentService
.
Trong class này, ta sẽ cần khai báo hai repository trên để giao tiếp với database, một class tính toán khoảng cách để tìm ra tài xế gần đơn nhất.
Nghiệp vụ chính của method assignShipment() trong ShipmentAssignmentService như sau:
@Override public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException { // Tìm đơn hàng từ shipmentID Shipment shipment = shipmentRepository.findById(shipmentID) .orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found.")); // Tìm tài xế gần nhất List drivers = driverRepository.findAll(); Driver nearestDriver = null; double minDistance = Double.MAX_VALUE; for (Driver driver : drivers) { double distance = DistanceCalculator.calculateDistance( shipment.getPickupLatitude(), shipment.getPickupLongitude(), driver.getCurrentLatitude(), driver.getCurrentLongitude() ); if (distance < minDistance) { minDistance = distance; nearestDriver = driver; } } if (nearestDriver == null) { throw new NoDriverAvailableException("No driver available for shipment ID " + shipmentID); } // Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế shipment.setDriverID(nearestDriver.getDriverID()); nearestDriver.addShipmentID(shipment.getShipmentID()); // Tính phí giao hàng dựa trên khoảng cách double fee = minDistance * COST_PER_KM; shipment.setFee(fee); // Thiết lập phí giao hàng cho đơn hàng // Cập nhật trạng thái đơn hàng là ASSIGNED shipment.setStatus(ShipmentStatus.ASSIGNED); // Lưu thông tin driver và shipment driverRepository.save(nearestDriver); shipmentRepository.save(shipment); }
@Override
public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException {
// Tìm đơn hàng từ shipmentID
Shipment shipment = shipmentRepository.findById(shipmentID)
.orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found."));
// Tìm tài xế gần nhất
List drivers = driverRepository.findAll();
Driver nearestDriver = null;
double minDistance = Double.MAX_VALUE;
for (Driver driver : drivers) {
double distance = DistanceCalculator.calculateDistance(
shipment.getPickupLatitude(), shipment.getPickupLongitude(),
driver.getCurrentLatitude(), driver.getCurrentLongitude()
);
if (distance < minDistance) {
minDistance = distance;
nearestDriver = driver;
}
}
if (nearestDriver == null) {
throw new NoDriverAvailableException("No driver available for shipment ID " + shipmentID);
}
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế
shipment.setDriverID(nearestDriver.getDriverID());
nearestDriver.addShipmentID(shipment.getShipmentID());
// Tính phí giao hàng dựa trên khoảng cách
double fee = minDistance * COST_PER_KM;
shipment.setFee(fee); // Thiết lập phí giao hàng cho đơn hàng
// Cập nhật trạng thái đơn hàng là ASSIGNED
shipment.setStatus(ShipmentStatus.ASSIGNED);
// Lưu thông tin driver và shipment
driverRepository.save(nearestDriver);
shipmentRepository.save(shipment);
}
Cùng tóm tắt lại nghiệp vụ của code trên:
- Lấy thông tin đơn hàng
- Lấy toàn bộ danh sách tài xế
- Tìm tài xế gần nhất
- Gán tài xế lấy đơn
- Tính toán phí ship và chuyển trạng thái đơn
- Lưu xuống database
Cùng đi tới nhận xét để thấy được ưu/nhược điểm của nó.
Nhận xét
Đây là một số nhận xét chính về cách triển khai nghiệp vụ bên trên, class Service.
- Không có domain entities.
- Nghiệp vụ được thể hiện theo hướng thủ tục (procedure).
- Vi phạm Single Responsibility Principle (SRP) và DRY (Don't Repeat Yourself).
Không có Domain Entities
Domain entities được định nghĩa là những thực thể mang hai đặc điểm sau:
- Chứa các dữ liệu nghiệp vụ quan trọng, thể hiện ở thuộc tính của đối tượng.
- Chứa hành vi nghiệp vụ quan trọng, thể hiện ở hành vi của đối tượng đó.
Quan sát vào code trên, ta thấy không có domain entities thực thụ: chúng chỉ là các data class không hơn không kém. Và đây là điểm đặc trưng (không phải cốt lõi) khi xuất hiện trong Transaction Script. Ta sẽ tạo ra những Domain Entities tốt hơn qua những phần sau.
Hướng thủ tục
Thủ tục này được thể hiện như sau:
- Lấy thông tin đơn hàng
- Lấy toàn bộ danh sách tài xế
- Tìm tài xế gần nhất
- Gán tài xế lấy đơn
- Tính toán phí ship và chuyển trạng thái đơn
- Lưu xuống database
Đây chính là Transaction Script mà Martin Fowler đề cập:
Organizes business logic by procedures where each procedure handles a single request from the presentation.
Bạn có thể có suy nghĩ: Nghiệp vụ cần các bước như vậy thì code như vậy là đúng rồi. Nó đâu có vấn đề gì.
Luồng xử lý là đúng nhưng cách code là hướng thủ tục: Các bước bên trên, bạn đưa vào các dòng code, các đoạn code hoặc các method khác nhau. Chúng không có tính đóng gói (encapsulation) để cho hành động đó được phụ trách bởi một đối tượng nghiệp vụ nào đó tương xứng. Và khi mà các đoạn code như vậy phát sinh, chúng có thể phát sinh tại nhiều nơi khác nhau trong ứng dụng của bạn do tính dùng lại yếu. Khi đó, bạn gặp phải các vấn đề như SRP, DRY (chi tiết bên dưới), code phụ thuộc chồng chéo khi logic nghiệp vụ tăng lên. Điểm đến có thể là một "big ball of mud" tiếng tăm.
Code nghiệp vụ không được đóng gói tại các Domain Entities còn làm cho lõi nghiệp vụ ứng dụng bị ảnh hưởng trực tiếp bởi sự thay đổi nghiệp vụ. Điều này khá khó nhận biết và nó thể hiện ở câu hỏi mà tôi thường gặp: tại sao Domain Model pattern lại dễ maintain hơn Transaction Script pattern? Câu trả lời ngắn gọn: Vì Transaction Script thiếu các Domain Entities, là nơi chứa dữ liệu và hành vi nghiệp vụ quan trọng, là những thứ mà về cơ bản là tương đối ổn định; và Transaction Script thiếu một Use Case đóng vai trò điều phối các Domain Entities đó. Bộ điều phối này mới là cái hay thay đổi. Một cách cực đoan, Transaction Script đã phơi bày mọi logic của nó ra bên ngoài.
Transaction Script không phải không có ưu điểm. Nó rất dễ để bắt đầu, hầu như ai cũng có thể làm được chỉ với kinh nghiệm về ngôn ngữ lập trình cơ bản. Đây là pattern phù hợp với những ứng dụng nằm ở vùng biên (supporting applications) - nơi mà sự dễ dàng và ổn định được duy trì và do đó không cần đầu tư nhiều công sức và nguồn lực.
Tuy nhiên, Transaction Script có thể làm lập trình viên cảm thấy nhàm chán với cùng một style code cho mọi vấn đề: Khai báo data classes, giao tiếp chúng với các repositories, đưa chúng vào các services và sử dụng getters/setters để lấy dữ liệu ra cho vào những đoạn logic nghiệp vụ. Lập trình thực sự thú vị hơn như vậy rất nhiều. Khi bạn làm việc với một bài toán trọng điểm, phục vụ chiến lược cạnh tranh của công ty, bạn sẽ cảm thấy may mắn và hãnh diện vì được lựa chọn vào đội ngũ đó vì đó phải là đội ngũ giỏi nhất để những con người đó làm ra những phần mềm đáp ứng được sự thay đổi phức tạp của yêu cầu kinh doanh trong một khoảng thời gian ngắn nhất. Lúc đó, bạn phải suy nghĩ rất nhiều về các cách thiết kế để tìm ra cách phù hợp nhất. Sự nhàm chán sẽ không có chỗ.
Vi phạm SRP và DRY
Đoạn logic này làm quá nhiều việc, nó vi phạm nguyên lý SRP (một class chỉ nên có một lý do để thay đổi). Vậy những lý do khác nhau có thể khiến class này thay đổi là gì?
- Quy tắc tìm khoảng cách gần nhất.
- Quy tắc tìm tài xế phù hợp.
- Quy tắc tính toán phí vận chuyển.
Hãy cùng phân tích chi tiết hơn.
Quy tắc tìm khoảng cách gần nhất
Xem lại đoạn code tính toán khoảng cách của mỗi tài xế tới đơn hàng:
double distance = DistanceCalculator.calculateDistance( shipment.getPickupLatitude(), shipment.getPickupLongitude(), driver.getCurrentLatitude(), driver.getCurrentLongitude() );
double distance = DistanceCalculator.calculateDistance(
shipment.getPickupLatitude(), shipment.getPickupLongitude(),
driver.getCurrentLatitude(), driver.getCurrentLongitude()
);
Xem lại thiết kế trong ý tưởng thiết kế ban đầu của bài toán, ta thấy rằng DistanceCalculator là một class cụ thể, đúng hơn, nó là dạng utility class: nhận đầu vào và hai vị trí rồi trả về khoảng cách.
Đây không phải là ý tượng tệ nhưng nó đang được cài đặt với cách tính khoảng cách đường chim bay. Nếu sau này, yêu cầu nghiệp vụ thay đổi, cần tính khoảng cách thực tế di chuyển trên bản đồ thì class này cần thay đổi hoặc bị thay thế bởi class khác.
Từ đó ta thấy code này còn vi phạm nguyên lý Dependency Inversion Principle (DIP) do Service phụ thuộc vào một class cụ thể không ổn định (DistanceCalculator).
Để cải thiện, ta nên khai báo một "trừu tượng ổn định" là DistanceCalculator dưới dạng một interface và được thực thi cụ thể bởi hai class tính toán theo đường chim bay và bản đồ thực tế:

Như vậy, với cách thiết kế này, Service của ta sẽ phụ thuộc vào DistanceCalculator interface, còn thuật toán cụ thể sẽ được inject vào sau.
Quy tắc tìm tài xế phù hợp
Đây mới là điều mà ta dễ thấy nhất khi nhìn vào code nghiệp vụ trên:
// Tìm tài xế gần nhất List drivers = driverRepository.findAll(); Driver nearestDriver = null; double minDistance = Double.MAX_VALUE; for (Driver driver : drivers) { double distance = DistanceCalculator.calculateDistance( shipment.getPickupLatitude(), shipment.getPickupLongitude(), driver.getCurrentLatitude(), driver.getCurrentLongitude() ); if (distance < minDistance) { minDistance = distance; nearestDriver = driver; } } if (nearestDriver == null) { throw new NoDriverAvailableException("No driver available for shipment ID " + shipmentID); }
// Tìm tài xế gần nhất
List drivers = driverRepository.findAll();
Driver nearestDriver = null;
double minDistance = Double.MAX_VALUE;
for (Driver driver: drivers) {
double distance = DistanceCalculator.calculateDistance(
shipment.getPickupLatitude(), shipment.getPickupLongitude(),
driver.getCurrentLatitude(), driver.getCurrentLongitude()
);
if (distance < minDistance) {
minDistance = distance;
nearestDriver = driver;
}
}
Đoạn code này thể hiện sự vi phạm SRP nghiêm trọng: nó phụ trách hoàn toàn nghiệp vụ tìm tài xế.
Cải tiến SRP
Để cải tiến cho cả hai nghiệp vụ nhỏ trên (tính toán khoảng cách gần nhất và tìm tài xế gần nhất), ta sẽ cần thống nhất rằng cả đoạn code này chỉ để phục vụ một nghiệp vụ: tìm ra tài xế phù hợp cho đơn hàng. Nhưng vì quy tắc tìm ra tài xế này, giả sử dự án đang trong giai đoạn thử nghiệm chiến lược kinh doanh, biến động với tần suất dày đặc, theo những cách lựa chọn khác nhau:
- Lúc này là tìm tài xế theo khoảng cách gần nhất.
- Sau có thể tìm tài xế trong một phạm vi nào đó với tối ưu về khối lượng vận chuyển.
- Tìm tài xế tại những khu vực ưu tiên.
- Tìm tài xế ưu tiên những người có điểm uy tín cao.
- ...
Vậy ta cần để nghiệp vụ này trở thành một "core" của use case này. Vì thế, ta sẽ để nó bên dưới một interface: DriverFinder

Driver findDriver(Shipment shipment)
Như vậy, sau khi thực hiện cải tiến này, ShipmentAssignmentService
đã không còn xung đột về trách nhiệm tìm tài xế nữa mà trách nhiệm này đã được đẩy sang nhóm class khác. Nếu sau này, nghiệp vụ tìm tài xế thay đổi thì Service không bị ảnh hưởng nữa.
Để bạn dễ mường tượng, code của ta lúc này sẽ như sau:
@Override public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException { // Tìm đơn hàng từ shipmentID Shipment shipment = shipmentRepository.findById(shipmentID) .orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found.")); // Tìm tài xế phù hợp Driver driver = driverFinder.findDriver(shipment); // Tiếp tục các nghiệp vụ sau... ... }
@Override
public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException {
// Tìm đơn hàng từ shipmentID
Shipment shipment = shipmentRepository.findById(shipmentID)
.orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found."));
// Tìm tài xế phù hợp
Driver driver = driverFinder.findDriver(shipment);
// Tiếp tục các nghiệp vụ sau...
...
}
Mọi logic tìm tài xế phù hợp, dù là dựa trên khoảng cách, dựa trên thời gian hay cách tính khoảng cách có thay đổi thì đều gói gọn trong driverFinder.findDriver(shipment)
. Service hoàn toàn không bị ảnh hướng nếu thuật toán lựa chọn tài xế bị thay đổi.
Quy tắc tính toán phí vận chuyển
Tới giờ thì cũng dễ hiểu tại sao phần tính phí này lại vi phạm SRP phải không? Việc tính phí này không nên để tại service này, nó nên thuộc về đối tượng Driver vì đây là tính phí cho tài xế.
Code cũ:
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế shipment.setDriverID(driver.getDriverID()); driver.addShipmentID(shipment.getShipmentID()); // Tính phí giao hàng dựa trên khoảng cách double fee = minDistance * COST_PER_KM; driver.addFee(fee); // Cập nhật trạng thái đơn hàng là ASSIGNED shipment.setStatus(ShipmentStatus.ASSIGNED);
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế
shipment.setDriverID(driver.getDriverID());
driver.addShipmentID(shipment.getShipmentID());
// Tính phí giao hàng dựa trên khoảng cách
double fee = minDistance * COST_PER_KM;
driver.addFee(fee);
// Cập nhật trạng thái đơn hàng là ASSIGNED
shipment.setStatus(ShipmentStatus.ASSIGNED);
(code trên đã đổi tên biến "nearestDriver" thành "driver" theo refactoring phần SRP.
Phiên bản 1: Chỉ cập nhật tính phí:
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế shipment.setDriverID(driver.getDriverID()); driver.addShipmentID(shipment.getShipmentID()); // Tính phí giao hàng dựa trên khoảng cách driver.addFee(shipment, feeCalculator); // Cập nhật trạng thái đơn hàng là ASSIGNED shipment.setStatus(ShipmentStatus.ASSIGNED);
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế
shipment.setDriverID(driver.getDriverID());
driver.addShipmentID(shipment.getShipmentID());
// Tính phí giao hàng dựa trên khoảng cách
driver.addFee(shipment, feeCalculator);
// Cập nhật trạng thái đơn hàng là ASSIGNED
shipment.setStatus(ShipmentStatus.ASSIGNED);
Tuy sự thay đổi về code thể hiện ở trên không nhiều nhưng là một bước tiến lớn: Ta đặt tính toán phí vào một interface chuyên trách, feeCalculator
, giúp đảm bảo nguyên lý DIP (cũng như SRP) để có thể thay đổi cách tính toán phí sau này, thay vì ban đầu xuất phát với logic tính phí tường minh double fee = minDistance * COST_PER_KM;
. Trong method addFee()
, feeCalculator
sẽ sử dụng cả Driver và Shipment để tính ra phí cuối cùng.
Nhưng code như vậy vẫn không giảm đi tính "thủ tục" đặc trưng của Transaction Script trong Service này. Tôi sẽ thay driver.addShipmentID() bằng driver.assignShipment() mà trong đó thực hiện cả việc gán shipment cũng như tính toán, cập nhật phí.
Phiên bản 2:
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế shipment.setDriverID(driver.getDriverID()); driver.assignShipment(shipment, feeCalculator); // Cập nhật trạng thái đơn hàng là ASSIGNED shipment.setStatus(ShipmentStatus.ASSIGNED);
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế
shipment.setDriverID(driver.getDriverID());
driver.assignShipment(shipment, feeCalculator);
// Cập nhật trạng thái đơn hàng là ASSIGNED
shipment.setStatus(ShipmentStatus.ASSIGNED);
Vẫn còn thứ phải tối ưu: shipment.setDriverID() và shipment.setStatus(): Chúng rời rạc, không thể hiện nghiệp vụ, mang tính thủ tục, thiếu đóng gói, và dễ vi phạm DRY. Tối ưu lại như sau.
Phiên bản 3:
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế shipment.assignDriver(driver); driver.assignShipment(shipment, feeCalculator);
// Gán tài xế cho đơn hàng và lưu ID của đơn hàng cho tài xế
shipment.assignDriver(driver);
driver.assignShipment(shipment, feeCalculator);
Vậy là tôi đã thay thế 2 phương thức của shipment bằng một phương thức shipment.assignDriver()
duy nhất.
Có thể bạn sẽ hỏi: tại sao lại tạo ra hai method shipment.assignDriver() và driver.assignShipment() ngược nhau như vậy, sao không gom chúng lại thành driver.assignShipment() rồi gọi shipment.assignDriver() bên trong?
Bạn hoàn toàn có thể làm vậy nếu không áp dụng cứng nhắc Domain-Driven Design. Phương pháp này quy định: một aggregate (ở đây là Driver) không nên thay đổi trạng thái của một aggregate khác (Shipment) một cách trực tiếp. Tất cả phải qua class điều phối (là ShipmentAssignmentService).
Phiên bản gần cuối:
Tới lúc này, code của ta có dạng gần "tối ưu" như sau:
@Override public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException { // Tìm đơn hàng từ shipmentID Shipment shipment = shipmentRepository.findById(shipmentID) .orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found.")); // Tìm tài xế phù hợp Driver driver = driverFinder.findDriver(shipment); // Gán và tính phí shipment.assignDriver(driver); driver.assignShipment(shipment, feeCalculator); // Lưu thông tin driver và shipment driverRepository.save(driver); shipmentRepository.save(shipment); }
@Override
public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException {
// Tìm đơn hàng từ shipmentID
Shipment shipment = shipmentRepository.findById(shipmentID)
.orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found."));
// Tìm tài xế phù hợp
Driver driver = driverFinder.findDriver(shipment);
// Gán và tính phí
shipment.assignDriver(driver);
driver.assignShipment(shipment, feeCalculator);
// Lưu thông tin driver và shipment
driverRepository.save(driver);
shipmentRepository.save(shipment);
}
Tới lúc này, code của ta đã tương đối tốt và đảm bảo được nhiều nguyên lý thiết kế rồi. Nhưng như Robert C. Martin có nói, code nên như một câu chuyện, không cần comment không cần thiết. Quay lại code trên bạn có thấy liệu sau khi bỏ comments đi, người khác có còn hiểu được nghiệp vụ của Service không? Thử nhé:
Phiên bản cuối cùng:
@Override public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException { Shipment shipment = shipmentRepository.findById(shipmentID) .orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found.")); Driver driver = driverFinder.findDriver(shipment); shipment.assignDriver(driver); driver.assignShipment(shipment, feeCalculator); driverRepository.save(driver); shipmentRepository.save(shipment); }
@Override
public void assignShipment(String shipmentID) throws ShipmentNotFoundException, NoDriverAvailableException {
Shipment shipment = shipmentRepository.findById(shipmentID)
.orElseThrow(() -> new ShipmentNotFoundException("Shipment with ID " + shipmentID + " not found."));
Driver driver = driverFinder.findDriver(shipment);
shipment.assignDriver(driver);
driver.assignShipment(shipment, feeCalculator);
driverRepository.save(driver);
shipmentRepository.save(shipment);
}
Có vẻ code trên vẫn thể hiện rất tốt nội dung nghiệp vụ của nó. Vậy ta nên chỉ khi nào thật sự cần thiết hoặc code ở mức low-level thì mới sử dụng comments.
Kết luận
Như vậy, mặc dù không cần nắm chắc DDD, ta vẫn có thể refactoring code của mình tuân thủ được một số nguyên tắc quan trọng như:
- SRP - Single Responsibility Principle
- DRY - Don't Repeat Yourself
- DIP - Dependency Inversion Principle
- Tăng tính đóng gói của class
Code sẽ trở nên sáng sủa, dễ bảo trì hơn.
Bạn nên nhớ rằng, trong một vòng đời của hệ thống phần mềm cho doanh nghiệp (Enterprise Application), phần bảo trì (tiếp nhận yêu cầu kinh doanh mới và cập nhật vào ứng dụng) là phần chiếm phần lớn chi phí về thời gian, tiền bạc, và cả công sức con người. Khi ta làm tối ưu phần này là đã góp phần tối ưu về kinh doanh nói chung của tổ chức.
Và, với những ai đam mê KISS (Keep It Simple, Stupid), bạn vẫn có lý do chính đáng để làm vậy (là làm đơn giản), còn những ai đam mê tối ưu thì cũng vẫn có nơi thi triển:
Hệ thống ứng dụng của một doanh nghiệp sẽ chia thành các subdomain:
- Core subdomain: là nơi tập hợp những ứng dụng quan trọng nhất của tổ chức, là nơi để họ cạnh tranh với đối thủ nên cần phải đầu tư hết sức vào đó ở mọi khía cạnh. Hay nói cách khác, đây là nơi phục vụ hoạt động chiến lược của tổ chức đó.
- Supporting subdomain: là nơi có những ững dụng ít quan trọng, làm các việc đơn giản hơn, bổ trợ cho phần core. KISS có thể phù hợp.
- Generic subdomain: là nơi mà những bài toán có thể đã có sản phẩm thương mai hoặc open source. Ta có thể mua chúng nếu mục tiêu chiến lược của tổ chức lúc đó không bao gồm tối ưu chi phí.
Hãy "đứng dậy" và mạnh dạn sử dụng hướng đối tượng, bởi qua đó, bạn không chỉ nâng cao khả năng thiết kế, lập trình mà còn tạo cảm hứng bất tận trong công việc hàng ngày của mình: coding. Một khi đã "Thinking in Object-Oriented", bạn sẽ thấy ngay cả những bài toán thuộc vùng supporting sudomain bạn vẫn dùng hướng đối tượng một cách tự nhiên mà không hề ảnh hưởng tới tiến độ công việc của mình.