refactor(project): replace mechanical constructors with Lombok

This commit is contained in:
sechmachine
2026-08-15 11:37:02 +07:00
parent b9b150ff8c
commit e5639c1461
9 changed files with 207 additions and 60 deletions
@@ -0,0 +1,115 @@
# Test Evidence: Targeted Project Lombok boilerplate
- **Test type:** Unit
- **Requirement IDs:** `ARC-002`, `ARC-005`, `ARC-006`, `TST-001`
- **Scenario IDs:** `AC-TST-001`
- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest#eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis`
- **Implementation commit:** `pending`
## Protected behavior
Project Spring components use targeted required-argument constructor generation only when their
constructors assign required final dependencies. Project JPA entities use only protected no-arg
constructor generation. Records remain records, and entity identity, lazy associations, explicit
domain accessors, aggregate constructors, and mutation methods do not gain broad generated APIs.
## Test method
The source-contract test inspects only `feature.project` production sources. It requires
`@RequiredArgsConstructor` on the three injection-only components, removes the stateless advice's
handwritten no-arg constructor, and requires protected `@NoArgsConstructor` on the three JPA
entities. It also rejects broad entity Lombok annotations, confirms representative explicit domain
APIs remain, and verifies every Project immutable DTO/value type remains a Java record.
## Hand-derived expected result
Seven handwritten constructors are mechanical and eligible for removal: three dependency-assignment
constructors, one empty advice constructor, and three empty protected JPA constructors. The three
entity domain constructors, all aggregate mutation methods, defensive-copy accessors, derived
membership/leadership accessors, validation constructors, exception constructors, and all thirteen
records must remain explicit or remain records because they carry behavior or preserve the existing
API shape.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectLombokBoilerplateTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
ProjectLombokBoilerplateTest.eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis
expected ProjectController.java to contain import lombok.RequiredArgsConstructor; and
@RequiredArgsConstructor, but neither was present and the handwritten assignment-only constructor
remained.
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectLombokBoilerplateTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
[INFO] BUILD SUCCESS
./mvnw -Dtest=ProjectLombokBoilerplateTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectTaskMutationContextTest test
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -Dtest=ProjectControllerTest test
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=ProjectServiceIntegrationTest test
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -Dtest='Project*Test' test
[INFO] Tests run: 45, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all \
-Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc
[INFO] BUILD SUCCESS
git diff --check
(no output; exit 0)
```
## External-test boundaries
The source audit does not by itself prove Lombok annotation processing, Spring constructor
injection, Hibernate materialization, PostgreSQL mappings, Thymeleaf behavior, Project authorization,
locking, or aggregate lifecycle rules. Those boundaries are covered by the compile, scoped
Javadoc/doclint, Project unit/web, and PostgreSQL 18.4 integration gates above. The first sandboxed
unit-suite attempt could not attach Mockito's Byte Buddy agent; the unchanged command passed after
approved execution outside that sandbox. Browser E2E behavior remains outside this unit milestone.
@@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService; import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.security.Principal; import java.security.Principal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
@@ -27,22 +28,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
*/ */
@Controller @Controller
@RequestMapping("/projects") @RequestMapping("/projects")
@RequiredArgsConstructor
public class ProjectController { public class ProjectController {
private final ProjectQueryService pages; private final ProjectQueryService pages;
private final ProjectService projects; private final ProjectService projects;
/**
* Creates the MVC adapter for Project queries and mutations.
*
* @param pages authorized Project read operations
* @param projects transactional Project mutation operations
*/
public ProjectController(ProjectQueryService pages, ProjectService projects) {
this.pages = pages;
this.projects = projects;
}
/** /**
* Lists only Projects visible to the authenticated actor and exposes Project creation only * Lists only Projects visible to the authenticated actor and exposes Project creation only
* to Mentors. * to Mentors.
@@ -17,10 +17,6 @@ import org.springframework.web.servlet.ModelAndView;
@ControllerAdvice(assignableTypes = ProjectController.class) @ControllerAdvice(assignableTypes = ProjectController.class)
public class ProjectControllerAdvice { public class ProjectControllerAdvice {
/** Creates the stateless Project exception-to-view adapter. */
public ProjectControllerAdvice() {
}
/** /**
* Hides whether a requested Project or nested resource exists. * Hides whether a requested Project or nested resource exists.
* *
@@ -22,6 +22,8 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/** /**
* JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals. * JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals.
@@ -32,6 +34,7 @@ import java.util.Set;
*/ */
@Entity @Entity
@Table(name = "projects") @Table(name = "projects")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectEntity { public class ProjectEntity {
@Id @Id
@@ -75,10 +78,6 @@ public class ProjectEntity {
@Version @Version
private long version; private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectEntity() {
}
private ProjectEntity( private ProjectEntity(
long mentorUserId, long mentorUserId,
String name, String name,
@@ -11,6 +11,8 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.Instant; import java.time.Instant;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/** /**
* JPA leadership interval attached to an active same-Project membership. * JPA leadership interval attached to an active same-Project membership.
@@ -20,6 +22,7 @@ import java.time.Instant;
*/ */
@Entity @Entity
@Table(name = "project_leadership_terms") @Table(name = "project_leadership_terms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectLeadershipTermEntity { public class ProjectLeadershipTermEntity {
@Id @Id
@@ -46,10 +49,6 @@ public class ProjectLeadershipTermEntity {
@Column(name = "ended_by_mentor_user_id") @Column(name = "ended_by_mentor_user_id")
private Long endedByMentorUserId; private Long endedByMentorUserId;
/** Constructor reserved for JPA materialization. */
protected ProjectLeadershipTermEntity() {
}
ProjectLeadershipTermEntity( ProjectLeadershipTermEntity(
ProjectEntity project, ProjectEntity project,
ProjectMembershipEntity membership, ProjectMembershipEntity membership,
@@ -11,6 +11,8 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version; import jakarta.persistence.Version;
import java.time.Instant; import java.time.Instant;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/** /**
* JPA membership interval linking one Intern to one Project. * JPA membership interval linking one Intern to one Project.
@@ -20,6 +22,7 @@ import java.time.Instant;
*/ */
@Entity @Entity
@Table(name = "project_memberships") @Table(name = "project_memberships")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProjectMembershipEntity { public class ProjectMembershipEntity {
@Id @Id
@@ -48,10 +51,6 @@ public class ProjectMembershipEntity {
@Version @Version
private long version; private long version;
/** Constructor reserved for JPA materialization. */
protected ProjectMembershipEntity() {
}
ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) { ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) {
this.project = project; this.project = project;
this.internUserId = internUserId; this.internUserId = internUserId;
@@ -15,6 +15,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -26,22 +27,12 @@ import org.springframework.transaction.annotation.Transactional;
* members but expose no current Leader or active-member context. * members but expose no current Leader or active-member context.
*/ */
@Service @Service
@RequiredArgsConstructor
public class ProjectQueryService { public class ProjectQueryService {
private final ProjectRepository projects; private final ProjectRepository projects;
private final AccountService accounts; private final AccountService accounts;
/**
* Creates the Project read service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and internship-eligibility boundary
*/
public ProjectQueryService(ProjectRepository projects, AccountService accounts) {
this.projects = projects;
this.accounts = accounts;
}
/** /**
* Resolves an active authenticated account to its stable user identifier. * Resolves an active authenticated account to its stable user identifier.
* *
@@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock; import java.time.Clock;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -22,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
* imports their repositories or entities. * imports their repositories or entities.
*/ */
@Service @Service
@RequiredArgsConstructor
public class ProjectService { public class ProjectService {
private final ProjectRepository projects; private final ProjectRepository projects;
@@ -30,28 +32,6 @@ public class ProjectService {
private final TaskQueryService taskQueries; private final TaskQueryService taskQueries;
private final Clock clock; private final Clock clock;
/**
* Creates the Project mutation service.
*
* @param projects Project aggregate repository
* @param accounts public Account identity and eligibility boundary
* @param queries DTO-only Project query boundary reused for locked Task context
* @param taskQueries public Task activation-guard boundary
* @param clock server clock supplying persisted mutation instants
*/
public ProjectService(
ProjectRepository projects,
AccountService accounts,
ProjectQueryService queries,
TaskQueryService taskQueries,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.queries = queries;
this.taskQueries = taskQueries;
this.clock = clock;
}
/** /**
* Atomically creates a planned Mentor-owned Project, eligible initial membership, and first * Atomically creates a planned Mentor-owned Project, eligible initial membership, and first
* leadership term. {@code saveAndFlush} exposes database invariant violations before commit. * leadership term. {@code saveAndFlush} exposes database invariant violations before commit.
@@ -0,0 +1,77 @@
package com.lab.labtimesheet.feature.project.repository;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
class ProjectLombokBoilerplateTest {
private static final Path PROJECT_SOURCE =
Path.of("src/main/java/com/lab/labtimesheet/feature/project");
@Test
void eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis() throws IOException {
assertRequiredArgsConstructor("controller/ProjectController.java", "ProjectController");
assertRequiredArgsConstructor("service/ProjectService.java", "ProjectService");
assertRequiredArgsConstructor("service/ProjectQueryService.java", "ProjectQueryService");
String advice = source("exception/ProjectControllerAdvice.java");
assertThat(advice).doesNotContain("public ProjectControllerAdvice()");
assertProtectedJpaConstructor("model/entity/ProjectEntity.java", "ProjectEntity");
assertProtectedJpaConstructor(
"model/entity/ProjectLeadershipTermEntity.java", "ProjectLeadershipTermEntity");
assertProtectedJpaConstructor(
"model/entity/ProjectMembershipEntity.java", "ProjectMembershipEntity");
assertThat(source("model/entity/ProjectEntity.java"))
.contains("public static ProjectEntity plan(", "public ProjectMembershipEntity addMember(")
.doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString");
assertThat(source("model/entity/ProjectLeadershipTermEntity.java"))
.contains("public long internUserId()", "Instant end(Instant at, long mentorUserId)")
.doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString");
assertThat(source("model/entity/ProjectMembershipEntity.java"))
.contains("public long internUserId()", "public boolean isCurrent()")
.doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString");
for (String recordSource : List.of(
"model/ProjectInternEligibility.java",
"model/ProjectLeaderChange.java",
"model/dto/ProjectActorView.java",
"model/dto/ProjectCreateCommand.java",
"model/dto/ProjectCreateForm.java",
"model/dto/ProjectDashboardSummary.java",
"model/dto/ProjectDetail.java",
"model/dto/ProjectLeadershipTermView.java",
"model/dto/ProjectMemberForm.java",
"model/dto/ProjectMemberView.java",
"model/dto/ProjectSummary.java",
"model/dto/ProjectTaskContext.java",
"model/dto/ProjectTaskMemberView.java")) {
assertThat(source(recordSource)).contains(" record ");
}
}
private static void assertRequiredArgsConstructor(String relativePath, String typeName) throws IOException {
assertThat(source(relativePath))
.contains("import lombok.RequiredArgsConstructor;", "@RequiredArgsConstructor")
.doesNotContain("public " + typeName + "(");
}
private static void assertProtectedJpaConstructor(String relativePath, String typeName) throws IOException {
assertThat(source(relativePath))
.contains(
"import lombok.AccessLevel;",
"import lombok.NoArgsConstructor;",
"@NoArgsConstructor(access = AccessLevel.PROTECTED)")
.doesNotContain("protected " + typeName + "()");
}
private static String source(String relativePath) throws IOException {
return Files.readString(PROJECT_SOURCE.resolve(relativePath));
}
}