Tổ chức dự án CRUD theo multi-module (Maven)

Video
Spring Boot CRUD với Maven Multi-Module | Tổ chức Modular Monolith cho sinh viên IT
1. Vì sao nên tách code thành nhiều module?
Khi mới làm project, thường mọi thứ được bỏ hết vào một chỗ: controller, service, repository, model… dồn chung trong cùng package hoặc cùng thư mục. Cách này dễ bắt đầu, nhưng khi project lớn dần sẽ rối rất nhanh.
Lúc đó, việc tách thành nhiều module riêng cho từng phần sẽ giúp:
-
Code rõ ràng, dễ tìm kiếm Ví dụ:
auth,user,product,order— mỗi nghiệp vụ có khu vực riêng. -
Dễ bảo trì và mở rộng Muốn thêm tính năng, bạn chỉ cần chạm vào đúng module liên quan.
-
Hỗ trợ teamwork Một người phụ trách
user, người khác phụ tráchorder, giảm va chạm code. -
Có thể tái sử dụng Ví dụ module
authhoặccoreviết tốt có thể đem qua project khác. -
Là bước đệm để học kiến trúc nâng cao Quen với multi-module rồi thì sau này học DDD, Hexagonal hay microservices sẽ dễ hơn nhiều.
Đây cũng là cách rất nhiều công ty đi theo:
- ban đầu là monolith modular
- khi hệ thống phình to mới tách dần sang microservices
Bài viết giới thiệu về multi-module:
https://www.facebook.com/share/p/14FKutNqAZz/
2. Con đường tiến hóa kiến trúc từ CRUD đến microservices
1. CRUD monolith (single-module)
project/
├── controller/
├── service/
├── repository/
└── model/
Dễ bắt đầu, nhưng khi code nhiều sẽ rối, logic chồng chéo, khó teamwork.
2. Multi-module CRUD (modular monolith cơ bản)
project/
├── user/
├── product/
├── order/
└── common/
Code gọn hơn, dễ tìm, dễ teamwork. Đây là bước đệm rất quan trọng.
3. DDD Modular Monolith
project/
├── user/
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── interface/
├── product/
├── order/
└── common/
Domain rõ hơn, nghiệp vụ nằm đúng chỗ, hạ tầng được tách ra tốt hơn.
4. Microservices
user-service/
product-service/
order-service/
gateway/
Mỗi domain chạy thành service riêng, có DB riêng, giao tiếp qua API hoặc event.
Tóm lại
- bắt đầu từ CRUD monolith
- sau đó đi lên multi-module CRUD
- rồi học dần tư duy DDD
- microservices là bước sau, không nên nhảy vào quá sớm
3. Ví dụ cấu trúc multi-module cho project CRUD
Giả sử bạn đang làm một project bán hàng nhỏ: có user, product, order.
Thay vì để mọi thứ vào cùng một chỗ, ta chia project thành các module:
project-root/
├── user/
│ ├── controller/
│ ├── service/
│ ├── repository/
│ └── model/
│
├── product/
│ ├── controller/
│ ├── service/
│ ├── repository/
│ └── model/
│
├── order/
│ ├── controller/
│ ├── service/
│ ├── repository/
│ └── model/
│
└── core/
Ưu điểm:
- dễ tìm code
- dễ chia việc theo module
- dễ thêm
payment,inventory,shipmentvề sau - codebase sạch hơn rất nhiều so với monolith không phân vùng
Đây chính là bước “dọn dẹp monolith” trước khi học tiếp các kiến trúc khó hơn.
4. Tạo Parent Aggregator với packaging pom
Bước 1: Tạo project Maven cha
- File -> New -> Project -> Maven
groupId:com.exampleartifactId:shop
Bước 2: Cấu hình shop/pom.xml
packaging = pom nghĩa là project cha này không sinh ra jar hay war, mà chỉ làm nhiệm vụ:
- quản lý version
- gom module
- quản lý plugin
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.5.4</spring.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<modules>
<!-- thêm module con tại đây -->
</modules>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
Lưu ý:
- parent không có
src/ - nó chỉ quản lý project
5. Tạo module con đầu tiên: shop-app
Cách nhanh trong IntelliJ
- right-click root
shop-> New -> Module -> Spring Initializr - Group:
com.example - Artifact:
shop-app - Dependencies tối thiểu:
- Spring Web
- Validation
- Spring Boot Actuator
Sau đó mở shop-app/pom.xml và đổi parent sang project cha:
<parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>shop-app</artifactId>
<name>shop-app</name>
<description>shop application module</description>
6. Tạo thêm module domain: shop-core
Right-click shop -> New -> Module -> Maven
- Artifact:
shop-core
1. shop-core/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>shop</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>shop-core</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
2. Code mẫu trong shop-core
entity/BaseEntity.java
package com.example.shopcore.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
@Getter
@Setter
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
@Column(updatable = false)
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
}
dto/ApiResponse.java
package com.example.shopcore.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null);
}
}
exception/BusinessException.java
package com.example.shopcore.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
exception/GlobalExceptionHandler.java
package com.example.shopcore.exception;
import com.example.shopcore.dto.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<?>> handleBusiness(BusinessException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ResponseEntity
.badRequest()
.body(ApiResponse.error(errorMessage));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleGeneric(Exception ex) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Internal error: " + ex.getMessage()));
}
}
3. shop-core dùng để làm gì?
BaseEntity-> base class cho mọi entityApiResponse-> chuẩn hóa responseBusinessException-> ném lỗi nghiệp vụGlobalExceptionHandler-> gom xử lý exception
Với shop-core, các module khác sẽ gọn hơn rất nhiều.
7. Viết tiếp module shop-user
1. shop-user/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- tự thêm parent vào đây -->
<artifactId>shop-user</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>shop-core</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>
2. Code mẫu trong shop-user
entity/User.java
package com.example.shopuser.entity;
import com.example.shopcore.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
public class User extends BaseEntity {
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String password;
@Column(length = 100)
private String email;
@Column(length = 20)
private String role;
}
dto/UserDto.java
package com.example.shopuser.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {
private Long id;
@NotBlank(message = "Username không được để trống")
private String username;
@NotBlank(message = "Password không được để trống")
private String password;
@Email(message = "Email không hợp lệ")
private String email;
private String role;
}
repository/UserRepository.java
package com.example.shopuser.repository;
import com.example.shopuser.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
service/UserService.java
package com.example.shopuser.service;
import com.example.shopcore.dto.ApiResponse;
import com.example.shopcore.exception.BusinessException;
import com.example.shopuser.dto.UserDto;
import com.example.shopuser.entity.User;
import com.example.shopuser.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public ApiResponse<UserDto> createUser(UserDto dto) {
if (userRepository.findByUsername(dto.getUsername()).isPresent()) {
throw new BusinessException("Username đã tồn tại");
}
User user = User.builder()
.username(dto.getUsername())
.password(dto.getPassword())
.email(dto.getEmail())
.role(dto.getRole() != null ? dto.getRole() : "USER")
.build();
userRepository.save(user);
dto.setId(user.getId());
return ApiResponse.ok(dto);
}
public ApiResponse<List<UserDto>> getAllUsers() {
List<UserDto> users = userRepository.findAll()
.stream()
.map(u -> UserDto.builder()
.id(u.getId())
.username(u.getUsername())
.email(u.getEmail())
.role(u.getRole())
.build())
.collect(Collectors.toList());
return ApiResponse.ok(users);
}
public ApiResponse<UserDto> getUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new BusinessException("User không tồn tại"));
return ApiResponse.ok(UserDto.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.role(user.getRole())
.build());
}
public ApiResponse<Void> deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new BusinessException("User không tồn tại");
}
userRepository.deleteById(id);
return ApiResponse.ok(null);
}
}
controller/UserController.java
package com.example.shopuser.controller;
import com.example.shopcore.dto.ApiResponse;
import com.example.shopuser.dto.UserDto;
import com.example.shopuser.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ApiResponse<UserDto> createUser(@Valid @RequestBody UserDto dto) {
return userService.createUser(dto);
}
@GetMapping
public ApiResponse<List<UserDto>> getAllUsers() {
return userService.getAllUsers();
}
@GetMapping("/{id}")
public ApiResponse<UserDto> getUser(@PathVariable Long id) {
return userService.getUser(id);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
return userService.deleteUser(id);
}
}
Cấu hình application.yml tại shop-app
spring:
application:
name: shop-app
datasource:
url: jdbc:h2:mem:shopdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
settings:
web-allow-others: true
Test
Nếu chạy lên, bạn sẽ có:
POST /api/usersGET /api/usersGET /api/users/{id}DELETE /api/users/{id}
8. Run và build
- Run: mở
ShopApplicationrồi run - Build toàn bộ: tại root chạy
mvn clean install - Build module riêng: chạy
installở đúng module
9. Lỗi thường gặp và cách xử lý
Child vẫn dùng spring-boot-starter-parent
Bỏ đi, vì child đã có parent là shop.
Không hiện nested module trong Project View
Bạn có thể đã tạo project mới thay vì module mới.
Hãy tạo bằng:
File -> New -> Module...
Version conflict
Đảm bảo parent đã import Spring Boot BOM và child không khai báo version cho starter.
SDK mismatch
Kiểm tra:
- Project SDK = 17
- modules kế thừa từ Project SDK
10. Thực tế doanh nghiệp
Mô hình Monolith Modular rất phổ biến ở:
- ngân hàng
- bảo hiểm
- e-commerce
Người ta thường tách thành các module như:
customercardloanriskauth
Ưu điểm:
- dễ kiểm soát transaction
- dễ giữ consistency
- phù hợp compliance
Khi cần scale hoặc tách hệ thống, một module có thể trở thành microservice như bước tiếp theo tự nhiên.