Clean Architecture + SOLID Pattern in Spring Boot
แนวทางที่เราใช้ในการจัดโครงสร้าง Spring Boot project เน้น Clean Architecture + SOLID เพื่อความเรียบง่าย อ่านง่าย ดูแลต่อได้จริง และยังเปิดทางไปสู่ Hexagonal Architecture ถ้าวันหนึ่งระบบซับซ้อนขึ้น
DTO Separation E2E — Controller DTO vs Model DTO vs Entity
ทำไมต้องแยก DTO แต่ละชั้น
- Entity: อ็อบเจ็กต์ที่ map กับตาราง DB โดยตรง ใช้สำหรับ persistence เท่านั้น ไม่ควร expose ออก API (อาจมี field ภายใน เช่น audit/password)
- Model DTO (Service/Domain DTO): อ็อบเจ็กต์กลางสำหรับ business logic ใช้แลกเปลี่ยนข้อมูลระหว่าง service/repository โดย ไม่ผูกกับ schema DB หรือรูปแบบ API ช่วยให้เทสต์โดเมนได้ง่ายและลดผลกระทบเวลาเปลี่ยน infra
- Controller DTO (Request/Response): อ็อบเจ็กต์สำหรับ API layer ทำหน้าที่เป็น contract กับโลกภายนอก มี validation และอาจมี field ที่ไม่ใช่โดเมนตรง ๆ (เช่น token, metadata)
ถ้าไม่แยก: API เปลี่ยน → กระทบ service/domain, มีโอกาสหลุดข้อมูลภายใน (entity) ออกนอกระบบ, และ business logic ถูกผูกกับ schema DB มากเกินไป
โครงสร้างแพ็กเกจ (Layer-first + Feature-based)
com.zengcode.projectname
├─ controller/
│ └─ user/
│ ├─ UserController.java
│ └─ dto/
│ ├─ CreateUserRequest.java
│ └─ UserResponse.java
├─ service/
│ └─ user/
│ ├─ RegisterUserService.java
│ └─ FindUserService.java
├─ repository/
│ └─ user/
│ └─ UserRepository.java
├─ model/
│ ├─ entity/
│ │ └─ user/
│ │ └─ UserEntity.java
│ └─ dto/
│ └─ user/
│ └─ UserDto.java
├─ mapper/
│ └─ user/
│ └─ UserMapper.java
├─ exception/
│ ├─ DuplicateEmailException.java
│ └─ UserNotFoundException.java
├─ config/
└─ utils/โค้ดตัวอย่างแบบ Compile ได้จริง (ครบ import + package)
1) Entity (JPA)
package com.zengcode.projectname.model.entity.user;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "users", uniqueConstraints = @UniqueConstraint(name = "uk_user_email", columnNames = "email"))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(nullable = false, length = 255)
private String email;
@Column(nullable = false, length = 120)
private String name;
}2) Repository (Spring Data JPA)
package com.zengcode.projectname.repository.user;
import com.zengcode.projectname.model.entity.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<UserEntity, String> {
boolean existsByEmail(String email);
Optional<UserEntity> findByEmail(String email);
}Get Chiwa Kantawong (Pea)’s stories in your inbox
Join Medium for free to get updates from this writer.
3) Model DTO (ใช้ใน Service/Domain)
package com.zengcode.projectname.model.dto.user;
public record UserDto(String id, String email, String name) {}4) Controller DTOs (Request/Response สำหรับ API)
package com.zengcode.projectname.controller.user.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record CreateUserRequest(
@NotBlank
@Email
String email,
@NotBlank String name
) {}package com.zengcode.projectname.controller.user.dto;
public record UserResponse(String id, String email, String name) {}5) Mapper (MapStruct)
package com.zengcode.projectname.mapper.user;
import com.zengcode.projectname.controller.user.dto.UserResponse;
import com.zengcode.projectname.model.dto.user.UserDto;
import com.zengcode.projectname.model.entity.user.UserEntity;
import org.mapstruct.*;
@Mapper(componentModel = "spring")
public interface UserMapper {
UserDto toDto(UserEntity entity); // Entity -> Model DTO
@Mapping(target = "id", ignore = true)
UserEntity toEntity(UserDto dto); // Model DTO -> Entity
default UserResponse toResponse(UserDto d) { // Model DTO -> Controller DTO (Response)
return new UserResponse(d.id(), d.email(), d.name());
}
}ถ้าไม่ได้ใช้ MapStruct ให้ทำ Manual Mapper (แยกไฟล์ให้ชัด) เพื่อลดการกระจาย logic mapping ในหลายที่
6) Exceptions (Custom)
package com.zengcode.projectname.exception;
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) { super("Email already used: " + email); }
}package com.zengcode.projectname.exception;
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String key) { super("User not found: " + key); }
}7) Services
package com.zengcode.projectname.service.user;
import com.zengcode.projectname.exception.DuplicateEmailException;
import com.zengcode.projectname.mapper.user.UserMapper;
import com.zengcode.projectname.model.dto.user.UserDto;
import com.zengcode.projectname.model.entity.user.UserEntity;
import com.zengcode.projectname.repository.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class RegisterUserService {
private final UserRepository repo; private final UserMapper mapper;
public RegisterUserService(UserRepository repo, UserMapper mapper) { this.repo = repo; this.mapper = mapper; }
@Transactional
public UserDto execute(String email, String name) {
if (repo.existsByEmail(email)) throw new DuplicateEmailException(email);
UserEntity toSave = UserEntity.builder().email(email).name(name).build();
UserEntity saved = repo.save(toSave);
return mapper.toDto(saved);
}
}package com.zengcode.projectname.service.user;
import com.zengcode.projectname.exception.UserNotFoundException;
import com.zengcode.projectname.mapper.user.UserMapper;
import com.zengcode.projectname.model.dto.user.UserDto;
import com.zengcode.projectname.repository.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class FindUserService {
private final UserRepository repo; private final UserMapper mapper;
public FindUserService(UserRepository repo, UserMapper mapper) { this.repo = repo; this.mapper = mapper; }
@Transactional(readOnly = true)
public UserDto byEmail(String email) {
return repo.findByEmail(email)
.map(mapper::toDto)
.orElseThrow(() -> new UserNotFoundException(email));
}
}8) Controller (map/validate เท่านั้น)
package com.zengcode.projectname.controller.user;
import com.zengcode.projectname.controller.user.dto.CreateUserRequest;
import com.zengcode.projectname.controller.user.dto.UserResponse;
import com.zengcode.projectname.mapper.user.UserMapper;
import com.zengcode.projectname.service.user.FindUserService;
import com.zengcode.projectname.service.user.RegisterUserService;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final RegisterUserService registerUserService;
private final FindUserService findUserService;
private final UserMapper mapper;
public UserController(RegisterUserService registerUserService, FindUserService findUserService, UserMapper mapper) {
this.registerUserService = registerUserService;
this.findUserService = findUserService;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest req) {
var dto = registerUserService.execute(req.email(), req.name());
return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toResponse(dto));
}
@GetMapping("/{email}")
public ResponseEntity<UserResponse> find(@PathVariable String email) {
var dto = findUserService.byEmail(email);
return ResponseEntity.ok(mapper.toResponse(dto));
}
}







