From 6d774ee1e9823bf0bfc4a6f7f477f29128d40c9e Mon Sep 17 00:00:00 2001 From: gushakov Date: Sun, 12 Dec 2021 21:37:47 +0100 Subject: [PATCH] initial --- .gitattributes | 5 ++ .gitignore | 3 + README.md | 12 +++ docker-compose.yml | 16 ++++ pom.xml | 50 +++++++++++ .../github/cleanddd/CleanDddApplication.java | 13 +++ .../cleanddd/adapter/PersistenceGateway.java | 78 +++++++++++++++++ .../adapter/jpa/CourseEntityRepository.java | 9 ++ .../adapter/jpa/StudentEntityRepository.java | 9 ++ .../adapter/jpa/entity/CourseEntity.java | 26 ++++++ .../adapter/jpa/entity/StudentEntity.java | 29 +++++++ .../adapter/map/DefaultModelMapper.java | 56 ++++++++++++ .../cleanddd/adapter/map/ModelMapper.java | 20 +++++ .../controller/EnrollmentController.java | 54 ++++++++++++ .../cleanddd/dto/CreateCourseRequest.java | 9 ++ .../cleanddd/dto/CreateStudentRequest.java | 9 ++ .../github/cleanddd/dto/EnrollRequest.java | 10 +++ .../github/cleanddd/dto/EnrollmentRow.java | 18 ++++ .../github/cleanddd/dto/EnrollmentsQuery.java | 9 ++ .../exception/EntityDoesNotExistError.java | 7 ++ .../exception/GenericEnrollmentError.java | 8 ++ .../com/github/cleanddd/model/Course.java | 47 ++++++++++ .../github/cleanddd/model/EnrollResult.java | 15 ++++ .../com/github/cleanddd/model/Enrollment.java | 15 ++++ .../com/github/cleanddd/model/Student.java | 52 +++++++++++ .../cleanddd/port/EnrollStudentInputPort.java | 11 +++ .../port/PersistenceOperationsOutputPort.java | 23 +++++ .../port/RestPresenterOutputPort.java | 8 ++ .../cleanddd/presenter/RestPresenter.java | 73 ++++++++++++++++ .../usecase/EnrollStudentUseCase.java | 86 +++++++++++++++++++ .../cleanddd/usecase/UseCaseConfig.java | 25 ++++++ src/main/resources/application.properties | 15 ++++ .../cleanddd/adapter/map/ModelMapperTest.java | 85 ++++++++++++++++++ .../com/github/cleanddd/model/ModelTest.java | 47 ++++++++++ 34 files changed, 952 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pom.xml create mode 100644 src/main/java/com/github/cleanddd/CleanDddApplication.java create mode 100644 src/main/java/com/github/cleanddd/adapter/PersistenceGateway.java create mode 100644 src/main/java/com/github/cleanddd/adapter/jpa/CourseEntityRepository.java create mode 100644 src/main/java/com/github/cleanddd/adapter/jpa/StudentEntityRepository.java create mode 100644 src/main/java/com/github/cleanddd/adapter/jpa/entity/CourseEntity.java create mode 100644 src/main/java/com/github/cleanddd/adapter/jpa/entity/StudentEntity.java create mode 100644 src/main/java/com/github/cleanddd/adapter/map/DefaultModelMapper.java create mode 100644 src/main/java/com/github/cleanddd/adapter/map/ModelMapper.java create mode 100644 src/main/java/com/github/cleanddd/controller/EnrollmentController.java create mode 100644 src/main/java/com/github/cleanddd/dto/CreateCourseRequest.java create mode 100644 src/main/java/com/github/cleanddd/dto/CreateStudentRequest.java create mode 100644 src/main/java/com/github/cleanddd/dto/EnrollRequest.java create mode 100644 src/main/java/com/github/cleanddd/dto/EnrollmentRow.java create mode 100644 src/main/java/com/github/cleanddd/dto/EnrollmentsQuery.java create mode 100644 src/main/java/com/github/cleanddd/exception/EntityDoesNotExistError.java create mode 100644 src/main/java/com/github/cleanddd/exception/GenericEnrollmentError.java create mode 100644 src/main/java/com/github/cleanddd/model/Course.java create mode 100644 src/main/java/com/github/cleanddd/model/EnrollResult.java create mode 100644 src/main/java/com/github/cleanddd/model/Enrollment.java create mode 100644 src/main/java/com/github/cleanddd/model/Student.java create mode 100644 src/main/java/com/github/cleanddd/port/EnrollStudentInputPort.java create mode 100644 src/main/java/com/github/cleanddd/port/PersistenceOperationsOutputPort.java create mode 100644 src/main/java/com/github/cleanddd/port/RestPresenterOutputPort.java create mode 100644 src/main/java/com/github/cleanddd/presenter/RestPresenter.java create mode 100644 src/main/java/com/github/cleanddd/usecase/EnrollStudentUseCase.java create mode 100644 src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/com/github/cleanddd/adapter/map/ModelMapperTest.java create mode 100644 src/test/java/com/github/cleanddd/model/ModelTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a0eecc5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text eol=lf +*.png binary +*.jpg binary +*.gif binary +*.jar binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..395dde9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.iml +.idea/ +target/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..01cf730 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +## Clean DDD + +An example of a Clean architecture DDD application. + +### References + +Here are some references and links which were used for this application: + +1. [Related SO question](https://stackoverflow.com/questions/54013963/ddd-approach-where-to-enforce-business-rules-without-aggregate) + - Interesting question also involving academic domain + +2. [Transactional, roll back transaction manually](https://stackoverflow.com/a/23502214) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7d1b7fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.7' +services: + + db: + image: postgres:latest + container_name: db + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: secret123 + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + postgres-data: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ff3d690 --- /dev/null +++ b/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.6.1 + + + + org.example + cleanddd + 1.0-SNAPSHOT + + + 17 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-web + + + + org.postgresql + postgresql + runtime + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/src/main/java/com/github/cleanddd/CleanDddApplication.java b/src/main/java/com/github/cleanddd/CleanDddApplication.java new file mode 100644 index 0000000..83a4b3b --- /dev/null +++ b/src/main/java/com/github/cleanddd/CleanDddApplication.java @@ -0,0 +1,13 @@ +package com.github.cleanddd; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CleanDddApplication { + + public static void main(String[] args) { + SpringApplication.run(CleanDddApplication.class, args); + } + +} diff --git a/src/main/java/com/github/cleanddd/adapter/PersistenceGateway.java b/src/main/java/com/github/cleanddd/adapter/PersistenceGateway.java new file mode 100644 index 0000000..f131b50 --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/PersistenceGateway.java @@ -0,0 +1,78 @@ +package com.github.cleanddd.adapter; + +import com.github.cleanddd.adapter.jpa.CourseEntityRepository; +import com.github.cleanddd.adapter.jpa.StudentEntityRepository; +import com.github.cleanddd.adapter.map.ModelMapper; +import com.github.cleanddd.dto.EnrollmentRow; +import com.github.cleanddd.exception.EntityDoesNotExistError; +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.Enrollment; +import com.github.cleanddd.model.Student; +import com.github.cleanddd.port.PersistenceOperationsOutputPort; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.stereotype.Service; + +import javax.persistence.EntityNotFoundException; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PersistenceGateway implements PersistenceOperationsOutputPort { + + final CourseEntityRepository courseRepo; + final StudentEntityRepository studentRepo; + final NamedParameterJdbcOperations jdbcOps; + final ModelMapper mapper; + + @Override + public Integer persist(Course course) { + return courseRepo.save(mapper.map(course)).getId(); + } + + @Override + public Course obtainCourseById(Integer courseId) { + try { + return mapper.map(courseRepo.getById(courseId)); + } catch (EntityNotFoundException e) { + throw new EntityDoesNotExistError("Could not find Course with ID: %d".formatted(courseId)); + } + } + + @Override + public boolean courseExistsWithTitle(String title) { + return courseRepo.existsCourseEntityByTitleLike(title); + } + + @Override + public Integer persist(Student student) { + return studentRepo.save(mapper.map(student)).getId(); + } + + @Override + public Student obtainStudentById(Integer studentId) { + try { + return mapper.map(studentRepo.getById(studentId)); + } catch (EntityNotFoundException e) { + throw new EntityDoesNotExistError("Could not find Student with ID: %d".formatted(studentId)); + } + } + + @Override + public boolean studentExistsWithFullName(String fullName) { + return studentRepo.existsByFullNameLike(fullName); + } + + @Override + public Set findEnrollments(Integer studentId) { + + return jdbcOps.queryForStream(EnrollmentRow.SQL, + Map.of("studentId", studentId), + new BeanPropertyRowMapper<>(EnrollmentRow.class)) + .map(mapper::map) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/github/cleanddd/adapter/jpa/CourseEntityRepository.java b/src/main/java/com/github/cleanddd/adapter/jpa/CourseEntityRepository.java new file mode 100644 index 0000000..58e397e --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/jpa/CourseEntityRepository.java @@ -0,0 +1,9 @@ +package com.github.cleanddd.adapter.jpa; + +import com.github.cleanddd.adapter.jpa.entity.CourseEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CourseEntityRepository extends JpaRepository { + + boolean existsCourseEntityByTitleLike(String title); +} diff --git a/src/main/java/com/github/cleanddd/adapter/jpa/StudentEntityRepository.java b/src/main/java/com/github/cleanddd/adapter/jpa/StudentEntityRepository.java new file mode 100644 index 0000000..49be4a6 --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/jpa/StudentEntityRepository.java @@ -0,0 +1,9 @@ +package com.github.cleanddd.adapter.jpa; + +import com.github.cleanddd.adapter.jpa.entity.StudentEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentEntityRepository extends JpaRepository { + + boolean existsByFullNameLike(String fullName); +} diff --git a/src/main/java/com/github/cleanddd/adapter/jpa/entity/CourseEntity.java b/src/main/java/com/github/cleanddd/adapter/jpa/entity/CourseEntity.java new file mode 100644 index 0000000..b54cabb --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/jpa/entity/CourseEntity.java @@ -0,0 +1,26 @@ +package com.github.cleanddd.adapter.jpa.entity; + +import lombok.*; + +import javax.persistence.*; + +@Getter +@Setter +@Entity +@Table(name = "course") +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Integer id; + + @Column + private String title; + + @Column + private Integer numberOfStudents; + +} diff --git a/src/main/java/com/github/cleanddd/adapter/jpa/entity/StudentEntity.java b/src/main/java/com/github/cleanddd/adapter/jpa/entity/StudentEntity.java new file mode 100644 index 0000000..f105a05 --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/jpa/entity/StudentEntity.java @@ -0,0 +1,29 @@ +package com.github.cleanddd.adapter.jpa.entity; + +import lombok.*; + +import javax.persistence.*; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "student") +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Integer id; + + @Column + private String fullName; + + @ElementCollection + @CollectionTable(name = "enrollment", + joinColumns = {@JoinColumn(name = "student_id")}) + @Column(name = "course_id") + private Set coursesIds; +} diff --git a/src/main/java/com/github/cleanddd/adapter/map/DefaultModelMapper.java b/src/main/java/com/github/cleanddd/adapter/map/DefaultModelMapper.java new file mode 100644 index 0000000..b8f0e86 --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/map/DefaultModelMapper.java @@ -0,0 +1,56 @@ +package com.github.cleanddd.adapter.map; + +import com.github.cleanddd.adapter.jpa.entity.CourseEntity; +import com.github.cleanddd.adapter.jpa.entity.StudentEntity; +import com.github.cleanddd.dto.EnrollmentRow; +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.Enrollment; +import com.github.cleanddd.model.Student; +import org.springframework.stereotype.Service; + +@Service +public class DefaultModelMapper implements ModelMapper { + @Override + public Course map(CourseEntity entity) { + return Course.builder() + .id(entity.getId()) + .title(entity.getTitle()) + .numberOfStudents(entity.getNumberOfStudents()) + .build(); + } + + @Override + public CourseEntity map(Course model) { + return CourseEntity.builder() + .id(model.getId()) + .title(model.getTitle()) + .numberOfStudents(model.getNumberOfStudents()) + .build(); + } + + @Override + public Student map(StudentEntity entity) { + return Student.builder() + .id(entity.getId()) + .fullName(entity.getFullName()) + .coursesIds(entity.getCoursesIds()) + .build(); + } + + @Override + public StudentEntity map(Student model) { + return StudentEntity.builder() + .id(model.getId()) + .fullName(model.getFullName()) + .coursesIds(model.getCoursesIds()) + .build(); + } + + @Override + public Enrollment map(EnrollmentRow row) { + return Enrollment.builder() + .courseId(row.getCourseId()) + .courseTitle(row.getCourseTitle()) + .build(); + } +} diff --git a/src/main/java/com/github/cleanddd/adapter/map/ModelMapper.java b/src/main/java/com/github/cleanddd/adapter/map/ModelMapper.java new file mode 100644 index 0000000..1ac3213 --- /dev/null +++ b/src/main/java/com/github/cleanddd/adapter/map/ModelMapper.java @@ -0,0 +1,20 @@ +package com.github.cleanddd.adapter.map; + +import com.github.cleanddd.adapter.jpa.entity.CourseEntity; +import com.github.cleanddd.adapter.jpa.entity.StudentEntity; +import com.github.cleanddd.dto.EnrollmentRow; +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.Enrollment; +import com.github.cleanddd.model.Student; + +public interface ModelMapper { + Course map(CourseEntity entity); + + CourseEntity map(Course model); + + Student map(StudentEntity entity); + + StudentEntity map(Student model); + + Enrollment map(EnrollmentRow row); +} diff --git a/src/main/java/com/github/cleanddd/controller/EnrollmentController.java b/src/main/java/com/github/cleanddd/controller/EnrollmentController.java new file mode 100644 index 0000000..3dec383 --- /dev/null +++ b/src/main/java/com/github/cleanddd/controller/EnrollmentController.java @@ -0,0 +1,54 @@ +package com.github.cleanddd.controller; + +import com.github.cleanddd.dto.CreateCourseRequest; +import com.github.cleanddd.dto.CreateStudentRequest; +import com.github.cleanddd.dto.EnrollRequest; +import com.github.cleanddd.dto.EnrollmentsQuery; +import com.github.cleanddd.port.EnrollStudentInputPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +@RequiredArgsConstructor +@RestController +@Slf4j +public class EnrollmentController { + + final WebApplicationContext appContext; + + @PostMapping("/create-course") + public void createCourse(@RequestBody CreateCourseRequest createCourseRequest) { + final EnrollStudentInputPort enrollStudentUseCase = getUseCase(); + enrollStudentUseCase.createCourse(createCourseRequest.getTitle()); + } + + @PostMapping("/create-student") + public void createStudent(@RequestBody CreateStudentRequest createStudentRequest) { + final EnrollStudentInputPort enrollStudentUseCase = getUseCase(); + enrollStudentUseCase.createStudent(createStudentRequest.getFullName()); + } + + @PostMapping("/enroll") + public void enroll(@RequestBody EnrollRequest enrollRequest) { + final EnrollStudentInputPort enrollStudentUseCase = getUseCase(); + enrollStudentUseCase.enroll(enrollRequest.getCourseId(), + enrollRequest.getStudentId()); + } + + @PostMapping("/enrollments") + public void enrollments(@RequestBody EnrollmentsQuery enrollmentsQuery) { + final EnrollStudentInputPort enrollStudentUseCase = getUseCase(); + enrollStudentUseCase.findEnrollmentsForStudent(enrollmentsQuery.getStudentId()); + } + + /* + Get the use case prototype bean from the web application context. + */ + private EnrollStudentInputPort getUseCase() { + return appContext.getBean(EnrollStudentInputPort.class); + } + +} diff --git a/src/main/java/com/github/cleanddd/dto/CreateCourseRequest.java b/src/main/java/com/github/cleanddd/dto/CreateCourseRequest.java new file mode 100644 index 0000000..97fe341 --- /dev/null +++ b/src/main/java/com/github/cleanddd/dto/CreateCourseRequest.java @@ -0,0 +1,9 @@ +package com.github.cleanddd.dto; + +import lombok.Data; + +@Data +public class CreateCourseRequest { + + String title; +} diff --git a/src/main/java/com/github/cleanddd/dto/CreateStudentRequest.java b/src/main/java/com/github/cleanddd/dto/CreateStudentRequest.java new file mode 100644 index 0000000..607b65b --- /dev/null +++ b/src/main/java/com/github/cleanddd/dto/CreateStudentRequest.java @@ -0,0 +1,9 @@ +package com.github.cleanddd.dto; + +import lombok.Data; + +@Data +public class CreateStudentRequest { + + String fullName; +} diff --git a/src/main/java/com/github/cleanddd/dto/EnrollRequest.java b/src/main/java/com/github/cleanddd/dto/EnrollRequest.java new file mode 100644 index 0000000..af920a0 --- /dev/null +++ b/src/main/java/com/github/cleanddd/dto/EnrollRequest.java @@ -0,0 +1,10 @@ +package com.github.cleanddd.dto; + +import lombok.Data; + +@Data +public class EnrollRequest { + + Integer courseId; + Integer studentId; +} diff --git a/src/main/java/com/github/cleanddd/dto/EnrollmentRow.java b/src/main/java/com/github/cleanddd/dto/EnrollmentRow.java new file mode 100644 index 0000000..7856904 --- /dev/null +++ b/src/main/java/com/github/cleanddd/dto/EnrollmentRow.java @@ -0,0 +1,18 @@ +package com.github.cleanddd.dto; + +import lombok.Data; + +@Data +public class EnrollmentRow { + + public static final String SQL = "select e.student_id, e.course_id, s.full_name as \"student_full_name\", c.title as \"course_title\" from student s" + + " join enrollment e on s.id = e.student_id" + + " join course c on c.id = e.course_id" + + " where s.id = :studentId;"; + + Integer courseId; + String courseTitle; + Integer studentId; + String studentFullName; + +} diff --git a/src/main/java/com/github/cleanddd/dto/EnrollmentsQuery.java b/src/main/java/com/github/cleanddd/dto/EnrollmentsQuery.java new file mode 100644 index 0000000..3f6e88b --- /dev/null +++ b/src/main/java/com/github/cleanddd/dto/EnrollmentsQuery.java @@ -0,0 +1,9 @@ +package com.github.cleanddd.dto; + +import lombok.Data; + +@Data +public class EnrollmentsQuery { + + Integer studentId; +} diff --git a/src/main/java/com/github/cleanddd/exception/EntityDoesNotExistError.java b/src/main/java/com/github/cleanddd/exception/EntityDoesNotExistError.java new file mode 100644 index 0000000..f60a317 --- /dev/null +++ b/src/main/java/com/github/cleanddd/exception/EntityDoesNotExistError.java @@ -0,0 +1,7 @@ +package com.github.cleanddd.exception; + +public class EntityDoesNotExistError extends GenericEnrollmentError { + public EntityDoesNotExistError(String message) { + super(message); + } +} diff --git a/src/main/java/com/github/cleanddd/exception/GenericEnrollmentError.java b/src/main/java/com/github/cleanddd/exception/GenericEnrollmentError.java new file mode 100644 index 0000000..2108fe4 --- /dev/null +++ b/src/main/java/com/github/cleanddd/exception/GenericEnrollmentError.java @@ -0,0 +1,8 @@ +package com.github.cleanddd.exception; + +public class GenericEnrollmentError extends RuntimeException { + public GenericEnrollmentError(String message) { + super(message); + } + +} diff --git a/src/main/java/com/github/cleanddd/model/Course.java b/src/main/java/com/github/cleanddd/model/Course.java new file mode 100644 index 0000000..45faf74 --- /dev/null +++ b/src/main/java/com/github/cleanddd/model/Course.java @@ -0,0 +1,47 @@ +package com.github.cleanddd.model; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Course { + + @Getter + @EqualsAndHashCode.Include + private final Integer id; + + @Getter + private final String title; + + private final AtomicInteger numberOfStudents; + + @Builder + public Course(Integer id, String title, Integer numberOfStudents) { + this.id = id; + this.title = Optional.ofNullable(title) + .filter(t -> !t.isBlank()) + .orElseThrow(() -> new IllegalArgumentException("Title is null or empty")); + this.numberOfStudents = Optional.ofNullable(numberOfStudents) + .map(AtomicInteger::new).orElse(new AtomicInteger(0)); + } + + public Integer getNumberOfStudents() { + return numberOfStudents.intValue(); + } + + public Course enrollStudent() { + return newCourse().numberOfStudents(numberOfStudents.get() + 1).build(); + } + + private CourseBuilder newCourse(){ + return Course.builder() + .id(id) + .title(title) + .numberOfStudents(numberOfStudents.get()); + } + +} diff --git a/src/main/java/com/github/cleanddd/model/EnrollResult.java b/src/main/java/com/github/cleanddd/model/EnrollResult.java new file mode 100644 index 0000000..23771b6 --- /dev/null +++ b/src/main/java/com/github/cleanddd/model/EnrollResult.java @@ -0,0 +1,15 @@ +package com.github.cleanddd.model; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +@Value +@Builder +@EqualsAndHashCode +public class EnrollResult { + + Student student; + boolean courseAdded; + +} diff --git a/src/main/java/com/github/cleanddd/model/Enrollment.java b/src/main/java/com/github/cleanddd/model/Enrollment.java new file mode 100644 index 0000000..abc2895 --- /dev/null +++ b/src/main/java/com/github/cleanddd/model/Enrollment.java @@ -0,0 +1,15 @@ +package com.github.cleanddd.model; + + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Enrollment { + + Integer courseId; + + String courseTitle; + +} diff --git a/src/main/java/com/github/cleanddd/model/Student.java b/src/main/java/com/github/cleanddd/model/Student.java new file mode 100644 index 0000000..67062c2 --- /dev/null +++ b/src/main/java/com/github/cleanddd/model/Student.java @@ -0,0 +1,52 @@ +package com.github.cleanddd.model; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Student { + + @Getter + @EqualsAndHashCode.Include + private final Integer id; + + @Getter + private final String fullName; + + @Getter + private final Set coursesIds; + + @Builder + public Student(Integer id, String fullName, Set coursesIds) { + + this.id = id; + this.fullName = Optional.ofNullable(fullName) + .filter(f -> !f.isBlank()) + .orElseThrow(() -> new IllegalArgumentException("Full name is null or empty")); + this.coursesIds = Optional.ofNullable(coursesIds) + .map(Collections::unmodifiableSet).orElse(Set.of()); + } + + public EnrollResult enrollInCourse(Integer courseId) { + final Set ids = new HashSet<>(coursesIds); + final boolean added = ids.add(courseId); + return EnrollResult.builder() + .student(newStudent().coursesIds(ids).build()) + .courseAdded(added) + .build(); + } + + private StudentBuilder newStudent(){ + return Student.builder() + .id(id) + .fullName(fullName) + .coursesIds(coursesIds); + } + +} diff --git a/src/main/java/com/github/cleanddd/port/EnrollStudentInputPort.java b/src/main/java/com/github/cleanddd/port/EnrollStudentInputPort.java new file mode 100644 index 0000000..7167d17 --- /dev/null +++ b/src/main/java/com/github/cleanddd/port/EnrollStudentInputPort.java @@ -0,0 +1,11 @@ +package com.github.cleanddd.port; + +public interface EnrollStudentInputPort { + void createCourse(String title); + + void createStudent(String fullName); + + void enroll(Integer courseId, Integer studentId); + + void findEnrollmentsForStudent(Integer studentId); +} diff --git a/src/main/java/com/github/cleanddd/port/PersistenceOperationsOutputPort.java b/src/main/java/com/github/cleanddd/port/PersistenceOperationsOutputPort.java new file mode 100644 index 0000000..d0b6fc2 --- /dev/null +++ b/src/main/java/com/github/cleanddd/port/PersistenceOperationsOutputPort.java @@ -0,0 +1,23 @@ +package com.github.cleanddd.port; + +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.Enrollment; +import com.github.cleanddd.model.Student; + +import java.util.Set; + +public interface PersistenceOperationsOutputPort { + Integer persist(Course course); + + Course obtainCourseById(Integer courseId); + + boolean courseExistsWithTitle(String title); + + Integer persist(Student student); + + Student obtainStudentById(Integer studentId); + + boolean studentExistsWithFullName(String fullName); + + Set findEnrollments(Integer studentId); +} diff --git a/src/main/java/com/github/cleanddd/port/RestPresenterOutputPort.java b/src/main/java/com/github/cleanddd/port/RestPresenterOutputPort.java new file mode 100644 index 0000000..11fbd92 --- /dev/null +++ b/src/main/java/com/github/cleanddd/port/RestPresenterOutputPort.java @@ -0,0 +1,8 @@ +package com.github.cleanddd.port; + +public interface RestPresenterOutputPort { + // copied from https://github.com/gushakov/clean-rest + void presentOk(T content); + + void presentError(Throwable t); +} diff --git a/src/main/java/com/github/cleanddd/presenter/RestPresenter.java b/src/main/java/com/github/cleanddd/presenter/RestPresenter.java new file mode 100644 index 0000000..54d98e3 --- /dev/null +++ b/src/main/java/com/github/cleanddd/presenter/RestPresenter.java @@ -0,0 +1,73 @@ +package com.github.cleanddd.presenter; + +import com.github.cleanddd.exception.EntityDoesNotExistError; +import com.github.cleanddd.port.RestPresenterOutputPort; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.DelegatingServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +@RequiredArgsConstructor +public class RestPresenter implements RestPresenterOutputPort { + + private final HttpServletResponse httpServletResponse; + private final MappingJackson2HttpMessageConverter jacksonConverter; + + // REST presenter for Clean Architecture + // copied from https://github.com/gushakov/clean-rest + @Override + public void presentOk(T content) { + + final DelegatingServerHttpResponse httpOutputMessage = + new DelegatingServerHttpResponse(new ServletServerHttpResponse(httpServletResponse)); + + httpOutputMessage.setStatusCode(HttpStatus.OK); + + try { + jacksonConverter.write(content, MediaType.APPLICATION_JSON, httpOutputMessage); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + + @Override + public void presentError(Throwable t) { + + // roll back any transaction, if needed + // code from: https://stackoverflow.com/a/23502214 + try { + TransactionInterceptor.currentTransactionStatus().setRollbackOnly(); + } catch (NoTransactionException e) { + // do nothing if not running in a transactional context + } + + final DelegatingServerHttpResponse httpOutputMessage = + new DelegatingServerHttpResponse(new ServletServerHttpResponse(httpServletResponse)); + + if (t instanceof EntityDoesNotExistError){ + httpOutputMessage.setStatusCode(HttpStatus.BAD_REQUEST); + } + else { + httpOutputMessage.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + } + + try { + jacksonConverter.write(Map.of("error", Optional.ofNullable(t.getMessage()).orElse("null")), + MediaType.APPLICATION_JSON, httpOutputMessage); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + +} diff --git a/src/main/java/com/github/cleanddd/usecase/EnrollStudentUseCase.java b/src/main/java/com/github/cleanddd/usecase/EnrollStudentUseCase.java new file mode 100644 index 0000000..da78f3f --- /dev/null +++ b/src/main/java/com/github/cleanddd/usecase/EnrollStudentUseCase.java @@ -0,0 +1,86 @@ +package com.github.cleanddd.usecase; + +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.EnrollResult; +import com.github.cleanddd.model.Student; +import com.github.cleanddd.port.EnrollStudentInputPort; +import com.github.cleanddd.port.PersistenceOperationsOutputPort; +import com.github.cleanddd.port.RestPresenterOutputPort; + +import javax.transaction.Transactional; +import java.util.Map; + +public class EnrollStudentUseCase implements EnrollStudentInputPort { + + private final RestPresenterOutputPort restPresenter; + private final PersistenceOperationsOutputPort persistenceOps; + + public EnrollStudentUseCase(RestPresenterOutputPort restPresenter, PersistenceOperationsOutputPort persistenceOps) { + this.restPresenter = restPresenter; + this.persistenceOps = persistenceOps; + } + + @Override + @Transactional + public void createCourse(String title) { + + if (persistenceOps.courseExistsWithTitle(title)) { + restPresenter.presentOk(Map.of("exists", "already")); + } else { + final Integer courseId = persistenceOps.persist(Course.builder() + .title(title) + .build()); + + restPresenter.presentOk(Map.of("courseId", courseId)); + } + } + + @Override + @Transactional + public void createStudent(String fullName) { + + if (persistenceOps.studentExistsWithFullName(fullName)){ + restPresenter.presentOk(Map.of("exists", "already")); + } + else { + final Integer studentId = persistenceOps.persist(Student.builder() + .fullName(fullName) + .build()); + + restPresenter.presentOk(Map.of("studentId", studentId)); + } + } + + @Transactional + @Override + public void enroll(Integer courseId, Integer studentId) { + try { + + // try to enroll the student in the course + final Student student = persistenceOps.obtainStudentById(studentId); + final EnrollResult enrollResult = student.enrollInCourse(courseId); + + // proceed only if enrollment has actually resulted in a new + // course added to the set of student's courses + if (enrollResult.isCourseAdded()) { + + persistenceOps.persist(enrollResult.getStudent()); + + final Course course = persistenceOps.obtainCourseById(courseId); + persistenceOps.persist(course.enrollStudent()); + } + + // present the result of enrollment + restPresenter.presentOk(Map.of("studentId", studentId, + "newEnrollment", enrollResult.isCourseAdded(), + "coursesIds", enrollResult.getStudent().getCoursesIds())); + } catch (Exception e) { + restPresenter.presentError(e); + } + } + + @Override + public void findEnrollmentsForStudent(Integer studentId) { + restPresenter.presentOk(persistenceOps.findEnrollments(studentId)); + } +} diff --git a/src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java b/src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java new file mode 100644 index 0000000..e08f7c7 --- /dev/null +++ b/src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java @@ -0,0 +1,25 @@ +package com.github.cleanddd.usecase; + +import com.github.cleanddd.port.EnrollStudentInputPort; +import com.github.cleanddd.port.PersistenceOperationsOutputPort; +import com.github.cleanddd.presenter.RestPresenter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.context.WebApplicationContext; + +import javax.servlet.http.HttpServletResponse; + +@Configuration +public class UseCaseConfig { + + @Bean + @Scope(WebApplicationContext.SCOPE_REQUEST) + public EnrollStudentInputPort enrollStudentUseCase(PersistenceOperationsOutputPort persistenceOps, + HttpServletResponse httpServletResponse, + MappingJackson2HttpMessageConverter jackson2HttpMessageConverter) { + return new EnrollStudentUseCase(new RestPresenter(httpServletResponse, jackson2HttpMessageConverter), persistenceOps); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..461426d --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,15 @@ + +server.error.whitelabel.enabled=false +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.generate-ddl=false + +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.username=postgres +spring.datasource.password=secret123 + +spring.jpa.show-sql=true + +logging.level.org.springframework.orm=debug diff --git a/src/test/java/com/github/cleanddd/adapter/map/ModelMapperTest.java b/src/test/java/com/github/cleanddd/adapter/map/ModelMapperTest.java new file mode 100644 index 0000000..e1bcc0d --- /dev/null +++ b/src/test/java/com/github/cleanddd/adapter/map/ModelMapperTest.java @@ -0,0 +1,85 @@ +package com.github.cleanddd.adapter.map; + +import com.github.cleanddd.adapter.jpa.entity.CourseEntity; +import com.github.cleanddd.adapter.jpa.entity.StudentEntity; +import com.github.cleanddd.model.Course; +import com.github.cleanddd.model.Student; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +public class ModelMapperTest { + + @Test + void testMapCourseToCourseEntity() { + + final ModelMapper mapper = new DefaultModelMapper(); + + final Course course = Course.builder() + .id(1) + .title("English Composition 101") + .build(); + + Assertions.assertThat(course.getNumberOfStudents()) + .isEqualTo(0); + + final CourseEntity entity = mapper.map(course); + + Assertions.assertThat(entity) + .extracting(CourseEntity::getId, CourseEntity::getTitle, CourseEntity::getNumberOfStudents) + .containsOnly(1, "English Composition 101", 0); + + } + + @Test + void testMapCourseEntityToModel() { + + final ModelMapper mapper = new DefaultModelMapper(); + + final CourseEntity entity = new CourseEntity(); + + entity.setId(1); + entity.setTitle("English Composition 101"); + + final Course course = mapper.map(entity); + + Assertions.assertThat(course) + .extracting(Course::getId, Course::getTitle) + .containsOnly(1, "English Composition 101"); + + } + + @Test + void testMapStudentEntityToModel_NullCoursesIdsSetMapsToEmptySet() { + final ModelMapper mapper = new DefaultModelMapper(); + + final StudentEntity entity = new StudentEntity(); + entity.setId(1); + entity.setFullName("Brad Pitt"); + entity.setCoursesIds(null); + + final Student student = mapper.map(entity); + + Assertions.assertThat(student.getCoursesIds()) + .isNotNull() + .isEmpty(); + + } + + @Test + void testMapStudentEntityToModel_SameCoursesIdsSet() { + final ModelMapper mapper = new DefaultModelMapper(); + + final StudentEntity entity = new StudentEntity(); + entity.setId(1); + entity.setFullName("Brad Pitt"); + entity.setCoursesIds(Set.of(1, 2, 3)); + + final Student student = mapper.map(entity); + + Assertions.assertThat(student.getCoursesIds()) + .containsOnly(1, 2, 3); + + } +} diff --git a/src/test/java/com/github/cleanddd/model/ModelTest.java b/src/test/java/com/github/cleanddd/model/ModelTest.java new file mode 100644 index 0000000..9982814 --- /dev/null +++ b/src/test/java/com/github/cleanddd/model/ModelTest.java @@ -0,0 +1,47 @@ +package com.github.cleanddd.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ModelTest { + + @Test + void testStudentBuilder_ErrorIfBuildWithEmptyFullName() { + + Assertions.assertThrows(IllegalArgumentException.class, + () -> Student.builder().build()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> Student.builder() + .fullName(null) + .build()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> Student.builder() + .fullName("") + .build()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> Student.builder() + .fullName(" ") + .build()); + } + + @Test + void testStudentIsImmutable() { + final Student studentBefore = Student.builder() + .id(1) + .fullName("Brad Pitt") + .build(); + + final EnrollResult enrollResult = studentBefore.enrollInCourse(1); + final Student studentAfter = enrollResult.getStudent(); + +// assertThat(studentAfter.hashCode()) +// .isNotEqualTo(studentBefore.hashCode()); + assertThat(studentBefore.getCoursesIds()) + .isEmpty(); + assertThat(studentAfter.getCoursesIds()) + .containsOnly(1); + + } +}