Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testRuntimeOnly 'com.h2database:h2'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
package chaeso.zip.server.sample.application;

import chaeso.zip.server.sample.application.dto.CreateSampleCommand;
import chaeso.zip.server.sample.application.dto.SampleResponse;
import chaeso.zip.server.sample.domain.Sample;
import chaeso.zip.server.sample.domain.SampleNotFoundException;
import chaeso.zip.server.sample.domain.SampleRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 샘플 애플리케이션 서비스. 트랜잭션 경계와 유스케이스 흐름을 담당하며 도메인 객체에 작업을 위임한다.
* 샘플 애플리케이션 서비스. 유스케이스 진입점(인터페이스)으로, 구현은 {@link SampleServiceImpl} 가 담당한다.
*
* <p>컨벤션:
* <ul>
* <li>클래스 기본은 {@code @Transactional(readOnly = true)}, 쓰기 메서드에만 {@code @Transactional}</li>
* <li>생성자 주입({@code @RequiredArgsConstructor} + {@code final})</li>
* <li>표현 계층은 인터페이스에만 의존하고 구현체를 직접 참조하지 않는다</li>
* <li>외부에는 엔티티가 아닌 응답 DTO 를 반환</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SampleService {
public interface SampleService {

private final SampleRepository sampleRepository;
/**
* 커맨드를 받아 새로운 샘플을 생성한다.
*/
SampleResponse create(CreateSampleCommand command);

@Transactional
public SampleResponse create(String name) {
Sample sample = sampleRepository.save(Sample.create(name));
return SampleResponse.from(sample);
}
/**
* 식별자로 샘플을 단건 조회한다.
*/
SampleResponse getById(Long id);

public SampleResponse getById(Long id) {
Sample sample = sampleRepository.findById(id)
.orElseThrow(() -> new SampleNotFoundException(id));
return SampleResponse.from(sample);
}

public List<SampleResponse> getAll() {
return sampleRepository.findAll().stream()
.map(SampleResponse::from)
.toList();
}
/**
* 전체 샘플 목록을 조회한다.
*/
List<SampleResponse> getAll();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package chaeso.zip.server.sample.application;

import chaeso.zip.server.sample.application.dto.CreateSampleCommand;
import chaeso.zip.server.sample.application.dto.SampleResponse;
import chaeso.zip.server.sample.domain.Sample;
import chaeso.zip.server.sample.domain.SampleNotFoundException;
import chaeso.zip.server.sample.domain.SampleRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* {@link SampleService} 구현체. 트랜잭션 경계와 유스케이스 흐름을 담당하며 도메인 객체에 작업을 위임한다.
*
* <p>컨벤션:
* <ul>
* <li>클래스 기본은 {@code @Transactional(readOnly = true)}, 쓰기 메서드에만 {@code @Transactional}</li>
* <li>생성자 주입({@code @RequiredArgsConstructor} + {@code final})</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SampleServiceImpl implements SampleService {

private final SampleRepository sampleRepository;

@Override
@Transactional
public SampleResponse create(CreateSampleCommand command) {
Sample sample = sampleRepository.save(Sample.create(command.name()));
return SampleResponse.from(sample);
}

@Override
public SampleResponse getById(Long id) {
Sample sample = sampleRepository.findById(id)
.orElseThrow(() -> new SampleNotFoundException(id));
return SampleResponse.from(sample);
}

@Override
public List<SampleResponse> getAll() {
return sampleRepository.findAll().stream()
.map(SampleResponse::from)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package chaeso.zip.server.sample.application.dto;

/**
* 샘플 생성 유스케이스의 입력 커맨드. 애플리케이션 계층의 입력 경계로, 표현 계층의 요청 DTO 와 분리한다.
*
* <p>컨벤션:
* <ul>
* <li>애플리케이션 서비스의 입력은 원시 타입 나열 대신 Command 객체로 받는다</li>
* <li>HTTP/검증 관심사(웹 어노테이션)는 표현 계층 요청 DTO 가, 유스케이스 입력 형태는 Command 가 담당</li>
* <li>표현 계층 요청 DTO 의 {@code toCommand()} 로 변환해 전달한다</li>
* </ul>
*/
public record CreateSampleCommand(String name) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public class SampleController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<SampleResponse> create(@Valid @RequestBody CreateSampleRequest request) {
return ApiResponse.success(sampleService.create(request.name()));
return ApiResponse.success(sampleService.create(request.toCommand()));
}

@Operation(summary = "샘플 단건 조회", description = "식별자로 샘플을 조회한다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package chaeso.zip.server.sample.presentation.dto;

import chaeso.zip.server.sample.application.dto.CreateSampleCommand;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

/**
* 샘플 생성 요청 DTO. 요청 검증은 표현 계층 DTO 에서 수행한다.
* 샘플 생성 요청 DTO. 요청 검증은 표현 계층 DTO 에서 수행하고, {@link #toCommand()} 로 애플리케이션 커맨드로 변환한다.
*/
@Schema(description = "샘플 생성 요청")
public record CreateSampleRequest(
Expand All @@ -14,4 +15,7 @@ public record CreateSampleRequest(
@Size(max = 100, message = "이름은 100자를 초과할 수 없습니다.")
String name) {

public CreateSampleCommand toCommand() {
return new CreateSampleCommand(name);
}
}
5 changes: 4 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ spring:
password: ${DB_PASSWORD:postgres}
hikari:
maximum-pool-size: ${DB_POOL_SIZE:10}
flyway:
enabled: ${FLYWAY_ENABLED:true}
baseline-on-migrate: true
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:validate}
properties:
hibernate:
format_sql: true
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/db/migration/V1__create_sample_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- 컨벤션 예시: 스키마는 Flyway 마이그레이션으로만 관리하고, JPA ddl-auto 는 validate 로 검증만 한다.
-- 파일명 규칙: V<버전>__<설명>.sql (예: V2__add_sample_status.sql)

create table sample (
id bigint generated by default as identity primary key,
name varchar(100) not null,
created_at timestamp not null,
updated_at timestamp not null
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package chaeso.zip.server.architecture;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.Architectures;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;

/**
* 계층/패키지 의존 방향을 코드로 강제하는 아키텍처 테스트.
*
* <p>계층 규칙: presentation → application → domain (역방향 의존 금지)
*/
@AnalyzeClasses(
packages = "chaeso.zip.server",
importOptions = ImportOption.DoNotIncludeTests.class)
class LayerDependencyTest {

@ArchTest
static final ArchRule 계층_의존_방향을_지킨다 = Architectures.layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Presentation").definedBy("..presentation..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Presentation");

@ArchTest
static final ArchRule 도메인은_애플리케이션_표현_계층에_의존하지_않는다 = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..application..", "..presentation..")
.as("도메인 계층은 상위 계층(application/presentation)에 의존해서는 안 된다");

@ArchTest
static final ArchRule 도메인은_웹_계층에_의존하지_않는다 = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework.web..")
.as("도메인 계층은 Spring Web 에 의존해서는 안 된다");

@ArchTest
static final ArchRule 컨트롤러는_표현_계층에만_위치한다 = classes()
.that().areAnnotatedWith(RestController.class)
.should().resideInAPackage("..presentation..")
.as("@RestController 는 presentation 패키지에만 위치해야 한다");

@ArchTest
static final ArchRule 서비스는_애플리케이션_계층에만_위치한다 = classes()
.that().areAnnotatedWith(Service.class)
.should().resideInAPackage("..application..")
.as("@Service 는 application 패키지에만 위치해야 한다");
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;

import chaeso.zip.server.sample.application.dto.CreateSampleCommand;
import chaeso.zip.server.sample.application.dto.SampleResponse;
import chaeso.zip.server.sample.domain.Sample;
import chaeso.zip.server.sample.domain.SampleNotFoundException;
Expand All @@ -27,14 +28,14 @@ class SampleServiceTest {
private SampleRepository sampleRepository;

@InjectMocks
private SampleService sampleService;
private SampleServiceImpl sampleService;

@Test
@DisplayName("샘플을 생성하면 저장 후 응답을 반환한다")
void create() {
given(sampleRepository.save(any(Sample.class))).willAnswer(invocation -> invocation.getArgument(0));

SampleResponse response = sampleService.create("채소");
SampleResponse response = sampleService.create(new CreateSampleCommand("채소"));

assertThat(response.name()).isEqualTo("채소");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package chaeso.zip.server.sample.presentation;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import chaeso.zip.server.sample.application.SampleService;
import chaeso.zip.server.sample.application.dto.CreateSampleCommand;
import chaeso.zip.server.sample.application.dto.SampleResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
Expand Down Expand Up @@ -37,7 +38,7 @@ class SampleControllerTest {
@Test
@DisplayName("샘플 생성 요청이 성공하면 201 과 공통 응답 포맷을 반환한다")
void create_success() throws Exception {
given(sampleService.create(anyString()))
given(sampleService.create(any(CreateSampleCommand.class)))
.willReturn(new SampleResponse(1L, "채소", LocalDateTime.now(), LocalDateTime.now()));

mockMvc.perform(post("/api/v1/samples")
Expand Down
2 changes: 2 additions & 0 deletions src/test/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
spring:
flyway:
enabled: false
datasource:
url: jdbc:h2:mem:chaeso-zip;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
Expand Down
Loading