This commit is contained in:
gushakov
2021-12-12 21:37:47 +01:00
commit 6d774ee1e9
34 changed files with 952 additions and 0 deletions

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
* text eol=lf
*.png binary
*.jpg binary
*.gif binary
*.jar binary

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.iml
.idea/
target/

12
README.md Normal file
View 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
View 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
View 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>

View 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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
package com.github.cleanddd.dto;
import lombok.Data;
@Data
public class CreateCourseRequest {
String title;
}

View File

@@ -0,0 +1,9 @@
package com.github.cleanddd.dto;
import lombok.Data;
@Data
public class CreateStudentRequest {
String fullName;
}

View File

@@ -0,0 +1,10 @@
package com.github.cleanddd.dto;
import lombok.Data;
@Data
public class EnrollRequest {
Integer courseId;
Integer studentId;
}

View 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;
}

View File

@@ -0,0 +1,9 @@
package com.github.cleanddd.dto;
import lombok.Data;
@Data
public class EnrollmentsQuery {
Integer studentId;
}

View File

@@ -0,0 +1,7 @@
package com.github.cleanddd.exception;
public class EntityDoesNotExistError extends GenericEnrollmentError {
public EntityDoesNotExistError(String message) {
super(message);
}
}

View File

@@ -0,0 +1,8 @@
package com.github.cleanddd.exception;
public class GenericEnrollmentError extends RuntimeException {
public GenericEnrollmentError(String message) {
super(message);
}
}

View 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());
}
}

View 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;
}

View 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;
}

View 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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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));
}
}

View 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);
}
}

View 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

View File

@@ -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);
}
}

View 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);
}
}