Sitemap

Clean Architecture + SOLID Pattern in Spring Boot

3 min readSep 30, 2025

--

Press enter or click to view image in full size

แนวทางที่เราใช้ในการจัดโครงสร้าง 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));
}
}

Sequence Diagram: End-to-End (Create & Get User)

Press enter or click to view image in full size

--

--

Chiwa Kantawong (Pea)
Chiwa Kantawong (Pea)

Written by Chiwa Kantawong (Pea)

Software Engineering บนดาวเคราะห์ใบนึง Youtube : https://www.youtube.com/@ThaiJavaZone