initial
This commit is contained in:
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.jar binary
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.iml
|
||||
.idea/
|
||||
target/
|
||||
12
README.md
Normal file
12
README.md
Normal file
@@ -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)
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -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:
|
||||
50
pom.xml
Normal file
50
pom.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.6.1</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>org.example</groupId>
|
||||
<artifactId>cleanddd</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
13
src/main/java/com/github/cleanddd/CleanDddApplication.java
Normal file
13
src/main/java/com/github/cleanddd/CleanDddApplication.java
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Enrollment> findEnrollments(Integer studentId) {
|
||||
|
||||
return jdbcOps.queryForStream(EnrollmentRow.SQL,
|
||||
Map.of("studentId", studentId),
|
||||
new BeanPropertyRowMapper<>(EnrollmentRow.class))
|
||||
.map(mapper::map)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -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<CourseEntity, Integer> {
|
||||
|
||||
boolean existsCourseEntityByTitleLike(String title);
|
||||
}
|
||||
@@ -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<StudentEntity, Integer> {
|
||||
|
||||
boolean existsByFullNameLike(String fullName);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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<Integer> coursesIds;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.cleanddd.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateCourseRequest {
|
||||
|
||||
String title;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.cleanddd.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateStudentRequest {
|
||||
|
||||
String fullName;
|
||||
}
|
||||
10
src/main/java/com/github/cleanddd/dto/EnrollRequest.java
Normal file
10
src/main/java/com/github/cleanddd/dto/EnrollRequest.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.github.cleanddd.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class EnrollRequest {
|
||||
|
||||
Integer courseId;
|
||||
Integer studentId;
|
||||
}
|
||||
18
src/main/java/com/github/cleanddd/dto/EnrollmentRow.java
Normal file
18
src/main/java/com/github/cleanddd/dto/EnrollmentRow.java
Normal file
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.cleanddd.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class EnrollmentsQuery {
|
||||
|
||||
Integer studentId;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.cleanddd.exception;
|
||||
|
||||
public class EntityDoesNotExistError extends GenericEnrollmentError {
|
||||
public EntityDoesNotExistError(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.cleanddd.exception;
|
||||
|
||||
public class GenericEnrollmentError extends RuntimeException {
|
||||
public GenericEnrollmentError(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
||||
47
src/main/java/com/github/cleanddd/model/Course.java
Normal file
47
src/main/java/com/github/cleanddd/model/Course.java
Normal file
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
15
src/main/java/com/github/cleanddd/model/EnrollResult.java
Normal file
15
src/main/java/com/github/cleanddd/model/EnrollResult.java
Normal file
@@ -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;
|
||||
|
||||
}
|
||||
15
src/main/java/com/github/cleanddd/model/Enrollment.java
Normal file
15
src/main/java/com/github/cleanddd/model/Enrollment.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.github.cleanddd.model;
|
||||
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class Enrollment {
|
||||
|
||||
Integer courseId;
|
||||
|
||||
String courseTitle;
|
||||
|
||||
}
|
||||
52
src/main/java/com/github/cleanddd/model/Student.java
Normal file
52
src/main/java/com/github/cleanddd/model/Student.java
Normal file
@@ -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<Integer> coursesIds;
|
||||
|
||||
@Builder
|
||||
public Student(Integer id, String fullName, Set<Integer> 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<Integer> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Enrollment> findEnrollments(Integer studentId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.cleanddd.port;
|
||||
|
||||
public interface RestPresenterOutputPort {
|
||||
// copied from https://github.com/gushakov/clean-rest
|
||||
<T> void presentOk(T content);
|
||||
|
||||
void presentError(Throwable t);
|
||||
}
|
||||
@@ -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 <T> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
25
src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java
Normal file
25
src/main/java/com/github/cleanddd/usecase/UseCaseConfig.java
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
15
src/main/resources/application.properties
Normal file
15
src/main/resources/application.properties
Normal file
@@ -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
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
47
src/test/java/com/github/cleanddd/model/ModelTest.java
Normal file
47
src/test/java/com/github/cleanddd/model/ModelTest.java
Normal file
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user