diff --git a/onion-architecture/README.md b/onion-architecture/README.md new file mode 100644 index 000000000000..1a7fc394638d --- /dev/null +++ b/onion-architecture/README.md @@ -0,0 +1,292 @@ +--- +title: "Onion Architecture in Java: A Layered Approach to Building Maintainable and Testable Applications" +shortTitle: Onion Architecture +description: "Learn how the Onion Architecture pattern promotes maintainability, testability, and separation of concerns in Java applications. Explore examples, benefits, and best practices." +category: Architectural +language: en +tag: + - Decoupling + - Enterprise patterns + - Integration + - Microservices + - Scalability + - Security +--- +## Intent of Microservices API Gateway Design Pattern + +In this project, the implementation demonstrates **Onion Architecture** with clear dependency direction: infrastructure and application depend on domain, but domain is independent. + +The central intent is to keep business rules in `domain`, orchestrate use cases in `application`, and isolate delivery/persistence/API details in `infrastructure`. + +Current implementation highlights: + +* `domain`: `Person`, `Category`, `PersonRepository`, `DomainException` +* `application`: `SavePersonUseCase`, `GetPersonUseCase`, DTO records +* `infrastructure`: Spring Boot REST controller, JPA entities, repository adapter, bean wiring + +## Also known as + +* Ports and Adapters Architecture +* Hexagonal-style layering (conceptually related) +* Dependency-rule-first architecture + +## Detailed Explanation of Onion Architecture Pattern with Real-World Examples + +Real-world example + +> Imagine a people-management service where business validation must stay consistent no matter how data is stored or exposed. In this codebase, `Person` and `Category` enforce invariants (e.g., age >= 18, required email/category), use cases coordinate behavior, and infrastructure adapts HTTP + JPA concerns. This allows the persistence or web layer to evolve without changing core domain rules. + +In plain words + +> The project keeps business logic in the center and treats frameworks as replaceable details around it. + +Wikipedia says + +> Onion Architecture is a software architecture pattern that emphasizes separation of concerns and dependency inversion by organizing code in concentric layers, with the domain model at the center. + +Sequence diagram + +![Onion Architecture class diagram](./etc/onion-architecture.png) + +Request flow in this implementation: + +1. Client calls REST endpoint in `PersonController` (`/api/persons`, `/api/persons/{id}`) +2. Controller delegates to `SavePersonUseCase` or `GetPersonUseCase` +3. Use case interacts with `PersonRepository` abstraction from `domain` +4. `PersonRepositoryAdapter` maps domain <-> JPA and delegates to `SpringDataPersonRepository` +5. Response DTO (`PersonResponse`) is returned to the client + +## Programmatic Example of Onion Architecture in Java + +This repository exposes a simple person API backed by use cases and domain models. + +Controller (infrastructure layer): + +```java +@RestController +@RequestMapping("/api") +public class PersonController { + + @GetMapping("/persons/{id}") + public ResponseEntity getPerson(@PathVariable Long id) { + var person = getPersonUseCase.execute(id); + return ResponseEntity.ok(person); + } + + @GetMapping("/persons") + public ResponseEntity> getAllPersons() { + var persons = getPersonUseCase.executeAll(); + return ResponseEntity.ok(persons); + } + + @PostMapping("/persons") + public ResponseEntity savePerson(@RequestBody SavePersonCommand command) { + try { + var savedPerson = savePersonUseCase.execute(command); + return ResponseEntity.status(HttpStatus.OK).body(savedPerson); + } catch (DomainException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } +} +``` + +Use case (application layer): + +```java +public class SavePersonUseCase { + + private final PersonRepository repository; + + public PersonResponse execute(SavePersonCommand command) { + var category = new Category(command.categoryId(), command.categoryType()); + var person = new Person( + null, + command.firstName(), + command.lastName(), + command.age(), + command.phoneNumber(), + command.email(), + category); + + var savedPerson = repository.save(person); + return new PersonResponse( + savedPerson.getId(), + savedPerson.getFirstName(), + savedPerson.getLastName(), + savedPerson.getAge(), + savedPerson.getPhoneNumber(), + savedPerson.getEmail(), + savedPerson.getCategory().getId(), + savedPerson.getCategory().getType() + ); + } +} +``` + +Domain validation (domain layer): + +```java +public class Person { + public Person(Long id, String firstName, String lastName, int age, + String phoneNumber, String email, Category category) { + validateNames(firstName, lastName); + validateAge(age); + validatePhone(phoneNumber); + validateEmail(email); + validateCategory(category); + // assign fields... + } +} +``` + +Repository adapter (infrastructure -> domain port): + +```java +@Repository +public class PersonRepositoryAdapter implements PersonRepository { + + private final SpringDataPersonRepository repository; + + @Override + public Optional findById(Long id) { + return repository.findById(id).map(this::mapToDomain); + } + + @Override + public Person save(Person person) { + JpaPersonEntity savedEntity = repository.save(mapToEntity(person)); + return mapToDomain(savedEntity); + } +} +``` + +- **Maven 3.6.0** or higher + +### Build Steps + +1. **Navigate to the onion-architecture module directory:** + ```bash + cd java-design-patterns/onion-architecture + ``` + +2. **Build all modules:** + ```bash + mvn clean package + ``` + This will compile the `domain`, `application`, and `infrastructure` modules and package them into a Spring Boot executable JAR. + +3. **Run the Spring Boot application:** + ```bash + mvn -pl infrastructure spring-boot:run + ``` + Alternatively, after building, run the JAR directly: + ```bash + java -jar infrastructure/target/infrastructure-1.26.0-SNAPSHOT.jar + ``` + +### Accessing the API + +The application exposes REST endpoints at `http://localhost:8080/api`: +There is a Postman collection available in the `etc/postman` folder for testing the API. + +- **Get all persons:** + ```bash + GET http://localhost:8080/api/persons + ``` + +- **Get person by ID:** + ```bash + GET http://localhost:8080/api/persons/{id} + ``` + +- **Create a new person:** + ```bash + POST http://localhost:8080/api/persons + Content-Type: application/json + + { + "firstName": "John", + "lastName": "Doe", + "age": 30, + "phoneNumber": "555-1234", + "email": "john.doe@example.com", + "address": "123 Main St", + "categoryId": 1, + "categoryType": "individual" + } + ``` + +### Run Tests + +To execute unit tests across all modules: + +```bash +mvn clean test +``` + +To run tests for a specific module: + +```bash +mvn -pl domain test +mvn -pl application test +mvn -pl infrastructure test +``` + +### Database + +The application uses an **H2 in-memory database** for demonstration purposes. Configuration is in `infrastructure/src/main/resources/application.properties`: + +- **JDBC URL:** `jdbc:h2:mem:testdb` +- **Username:** `sa` +- **Password:** `password` + +Sample data is initialized from `infrastructure/src/main/resources/data.sql` on application startup. + +## When to Use the Onion Architecture Pattern in Java + +* When domain rules must be stable and independent from frameworks. +* When you want use cases to be testable without HTTP or database setup. +* When infrastructure details (web, JPA, database) should be replaceable. +* When dependency direction must be enforced from outer layers toward the domain core. + +## Onion Architecture Pattern Java Tutorials + +* [Clean Architecture with Spring Boot (Baeldung)](https://www.baeldung.com/spring-boot-clean-architecture) +* [Hexagonal Architecture Explained (Cockburn)](https://alistair.cockburn.us/hexagonal-architecture) +* [Spring Data JPA Reference](https://docs.spring.io/spring-data/jpa/reference/) + +## Benefits and Trade-offs of Microservices API Gateway Pattern + +Benefits: + +* Business validations are centralized in domain constructors (`Person`, `Category`). +* Use cases stay independent from Spring, JPA, and transport concerns. +* Repository abstraction (`PersonRepository`) keeps application logic persistence-agnostic. +* Testability is strong across layers (domain, use case, adapter, controller tests). + +Trade-offs: + +* Additional mapping code between domain models, DTOs, and JPA entities. +* More classes and modules than a simple CRUD-by-controller approach. +* Requires discipline to avoid leaking infrastructure concerns into domain/application. + +## Real-World Applications of Microservices API Gateway Pattern in Java + +* People/contact management services with strict data validation. +* Internal platforms where multiple delivery mechanisms (REST, batch, messaging) can share the same core domain. +* Systems that need incremental infrastructure evolution while preserving business logic. + +## Related Java Design Patterns + +* [Repository](https://martinfowler.com/eaaCatalog/repository.html) - `PersonRepository` defines the domain-facing persistence contract. +* [Adapter](https://refactoring.guru/design-patterns/adapter) - `PersonRepositoryAdapter` bridges domain model and Spring Data JPA. +* [Dependency Injection](https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html) - `ApplicationConfig` wires use case beans with repository implementations. + +## References and Credits + +* Project modules: `domain`, `application`, `infrastructure` +* Java 21 + Maven multi-module setup +* Spring Boot 3.3 (`spring-boot-starter-web`, `spring-boot-starter-data-jpa`, H2) +* Layer-focused tests in each module validating domain invariants and use case behavior + diff --git a/onion-architecture/application/pom.xml b/onion-architecture/application/pom.xml new file mode 100644 index 000000000000..eccdaae2869a --- /dev/null +++ b/onion-architecture/application/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + com.iluwatar + onion-architecture + 1.26.0-SNAPSHOT + + application + Application + + + + com.iluwatar + domain + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + org.mockito + mockito-core + test + + + + org.mockito + mockito-junit-jupiter + test + + + diff --git a/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/PersonResponse.java b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/PersonResponse.java new file mode 100644 index 000000000000..1586f30e48a1 --- /dev/null +++ b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/PersonResponse.java @@ -0,0 +1,37 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.dto; + +public record PersonResponse( + Long id, + String firstName, + String lastName, + int age, + String phoneNumber, + String email, + Long categoryId, + String categoryType) {} diff --git a/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/SavePersonCommand.java b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/SavePersonCommand.java new file mode 100644 index 000000000000..649db88a4ab5 --- /dev/null +++ b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/dto/SavePersonCommand.java @@ -0,0 +1,37 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.dto; + +public record SavePersonCommand( + String firstName, + String lastName, + int age, + String phoneNumber, + String email, + String address, + Long categoryId, + String categoryType) {} diff --git a/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/GetPersonUseCase.java b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/GetPersonUseCase.java new file mode 100644 index 000000000000..abae6a32196a --- /dev/null +++ b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/GetPersonUseCase.java @@ -0,0 +1,75 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.usecase; + +import com.iluwatar.onion.application.dto.PersonResponse; +import com.iluwatar.onion.domain.repository.PersonRepository; +import java.util.Collection; +import java.util.List; + +public class GetPersonUseCase { + + private final PersonRepository repository; + + public GetPersonUseCase(PersonRepository repository) { + this.repository = repository; + } + + public PersonResponse execute(Long id) { + var person = + repository + .findById(id) + .orElseThrow(() -> new RuntimeException("Person not found with id: " + id)); + + return new PersonResponse( + person.getId(), + person.getFirstName(), + person.getLastName(), + person.getAge(), + person.getPhoneNumber(), + person.getEmail(), + person.getCategory().getId(), + person.getCategory().getType()); + } + + public List executeAll() { + return repository.findAll().stream() + .flatMap(Collection::stream) + .map( + person -> + new PersonResponse( + person.getId(), + person.getFirstName(), + person.getLastName(), + person.getAge(), + person.getPhoneNumber(), + person.getEmail(), + person.getCategory().getId(), + person.getCategory().getType())) + .toList(); + } +} diff --git a/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/SavePersonUseCase.java b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/SavePersonUseCase.java new file mode 100644 index 000000000000..8d63e2612fab --- /dev/null +++ b/onion-architecture/application/src/main/java/com/iluwatar/onion/application/usecase/SavePersonUseCase.java @@ -0,0 +1,67 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.usecase; + +import com.iluwatar.onion.application.dto.PersonResponse; +import com.iluwatar.onion.application.dto.SavePersonCommand; +import com.iluwatar.onion.domain.model.Category; +import com.iluwatar.onion.domain.model.Person; +import com.iluwatar.onion.domain.repository.PersonRepository; + +public class SavePersonUseCase { + + private final PersonRepository repository; + + public SavePersonUseCase(PersonRepository repository) { + this.repository = repository; + } + + public PersonResponse execute(SavePersonCommand command) { + var category = new Category(command.categoryId(), command.categoryType()); + var person = + new Person( + null, + command.firstName(), + command.lastName(), + command.age(), + command.phoneNumber(), + command.email(), + category); + + var savedPerson = repository.save(person); + + return new PersonResponse( + savedPerson.getId(), + savedPerson.getFirstName(), + savedPerson.getLastName(), + savedPerson.getAge(), + savedPerson.getPhoneNumber(), + savedPerson.getEmail(), + savedPerson.getCategory().getId(), + savedPerson.getCategory().getType()); + } +} diff --git a/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/GetPersonUseCaseTest.java b/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/GetPersonUseCaseTest.java new file mode 100644 index 000000000000..f9de119c2309 --- /dev/null +++ b/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/GetPersonUseCaseTest.java @@ -0,0 +1,193 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.usecase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.iluwatar.onion.application.dto.PersonResponse; +import com.iluwatar.onion.domain.model.Category; +import com.iluwatar.onion.domain.model.Person; +import com.iluwatar.onion.domain.repository.PersonRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetPersonUseCaseTest { + + @Mock private PersonRepository personRepository; + + private GetPersonUseCase getPersonUseCase; + + @BeforeEach + void setUp() { + getPersonUseCase = new GetPersonUseCase(personRepository); + } + + @Nested + @DisplayName("Execute by ID - Happy Path") + class ExecuteByIdHappyPath { + + @Test + @DisplayName("Should return PersonResponse when person exists") + void shouldReturnPersonResponseWhenPersonExists() { + // Arrange + var person = + new Person( + 1L, + "John", + "Doe", + 25, + "+1234567890", + "john.doe@example.com", + new Category(1L, "Professional")); + + when(personRepository.findById(1L)).thenReturn(Optional.of(person)); + + // Act + var response = getPersonUseCase.execute(1L); + + // Assert + assertNotNull(response); + assertEquals(1L, response.id()); + assertEquals("John", response.firstName()); + assertEquals("Doe", response.lastName()); + assertEquals(25, response.age()); + assertEquals("+1234567890", response.phoneNumber()); + assertEquals("john.doe@example.com", response.email()); + assertEquals(1L, response.categoryId()); + assertEquals("Professional", response.categoryType()); + + verify(personRepository, times(1)).findById(1L); + } + } + + @Nested + @DisplayName("Execute by ID - Error Cases") + class ExecuteByIdErrorCases { + + @Test + @DisplayName("Should throw RuntimeException when person not found") + void shouldThrowExceptionWhenPersonNotFound() { + // Arrange + when(personRepository.findById(999L)).thenReturn(Optional.empty()); + + // Act & Assert + var exception = assertThrows(RuntimeException.class, () -> getPersonUseCase.execute(999L)); + assertTrue(exception.getMessage().contains("Person not found with id: 999")); + + verify(personRepository, times(1)).findById(999L); + } + + @Test + @DisplayName("Should propagate exception when repository throws exception") + void shouldPropagateExceptionWhenRepositoryFails() { + // Arrange + when(personRepository.findById(anyLong())).thenThrow(new RuntimeException("Database error")); + + // Act & Assert + var exception = assertThrows(RuntimeException.class, () -> getPersonUseCase.execute(1L)); + assertTrue(exception.getMessage().contains("Database error")); + + verify(personRepository, times(1)).findById(1L); + } + } + + @Nested + @DisplayName("Execute All - Happy Path") + class ExecuteAllHappyPath { + + @Test + @DisplayName("Should return list of PersonResponses") + void shouldReturnListOfPersonResponses() { + // Arrange + var person1 = + new Person( + 1L, + "John", + "Doe", + 30, + "+9876543255", + "john.doe@example.com", + new Category(2L, "Personal")); + + var person2 = + new Person( + 2L, + "Jane", + "Smith", + 25, + "+9876543210", + "jane.smith@example.com", + new Category(2L, "Personal")); + + when(personRepository.findAll()).thenReturn(Optional.of(List.of(person1, person2))); + + // Act + var responses = getPersonUseCase.executeAll(); + + // Assert + assertNotNull(responses); + assertEquals(2, responses.size()); + + PersonResponse response1 = responses.get(0); + assertEquals(1L, response1.id()); + assertEquals("John", response1.firstName()); + assertEquals("Doe", response1.lastName()); + + PersonResponse response2 = responses.get(1); + assertEquals(2L, response2.id()); + assertEquals("Jane", response2.firstName()); + assertEquals("Smith", response2.lastName()); + + verify(personRepository, times(1)).findAll(); + } + + @Test + @DisplayName("Should return empty list when no persons found") + void shouldReturnEmptyListWhenNoPersonsFound() { + // Arrange + when(personRepository.findAll()).thenReturn(Optional.of(List.of())); + + // Act + List responses = getPersonUseCase.executeAll(); + + // Assert + assertNotNull(responses); + assertTrue(responses.isEmpty()); + + verify(personRepository, times(1)).findAll(); + } + } +} diff --git a/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/SavePersonUseCaseTest.java b/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/SavePersonUseCaseTest.java new file mode 100644 index 000000000000..434147ae6495 --- /dev/null +++ b/onion-architecture/application/src/test/java/com/iluwatar/onion/application/usecase/SavePersonUseCaseTest.java @@ -0,0 +1,253 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.application.usecase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.iluwatar.onion.application.dto.SavePersonCommand; +import com.iluwatar.onion.domain.exception.DomainException; +import com.iluwatar.onion.domain.model.Category; +import com.iluwatar.onion.domain.model.Person; +import com.iluwatar.onion.domain.repository.PersonRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SavePersonUseCaseTest { + + @Mock private PersonRepository personRepository; + + @Captor private ArgumentCaptor personCaptor; + + private SavePersonUseCase savePersonUseCase; + + @BeforeEach + void setUp() { + savePersonUseCase = new SavePersonUseCase(personRepository); + } + + @Nested + @DisplayName("Execute - Happy Path") + class ExecuteHappyPath { + + @Test + @DisplayName("Should save person and return PersonResponse") + void shouldSavePersonAndReturnResponse() { + // Arrange + var command = + new SavePersonCommand( + "John", + "Doe", + 25, + "+1234567890", + "john.doe@example.com", + "123 Main St", + 1L, + "Professional"); + + var category = new Category(1L, "Professional"); + var savedPerson = + new Person( + 100L, // ID generated by database + "John", + "Doe", + 25, + "+1234567890", + "john.doe@example.com", + category); + + when(personRepository.save(any(Person.class))).thenReturn(savedPerson); + + // Act + var response = savePersonUseCase.execute(command); + + // Assert + assertNotNull(response); + assertEquals(100L, response.id()); + assertEquals("John", response.firstName()); + assertEquals("Doe", response.lastName()); + assertEquals(25, response.age()); + assertEquals("+1234567890", response.phoneNumber()); + assertEquals("john.doe@example.com", response.email()); + assertEquals(1L, response.categoryId()); + assertEquals("Professional", response.categoryType()); + + // Verify repository interaction + verify(personRepository, times(1)).save(any(Person.class)); + } + + @Test + @DisplayName("Should pass correct Person object to repository") + void shouldPassCorrectPersonToRepository() { + // Arrange + var command = + new SavePersonCommand( + "Jane", + "Smith", + 30, + "+9876543210", + "jane.smith@example.com", + "456 Oak Ave", + 2L, + "Personal"); + + var category = new Category(2L, "Personal"); + var savedPerson = + new Person(200L, "Jane", "Smith", 30, "+9876543210", "jane.smith@example.com", category); + + when(personRepository.save(any(Person.class))).thenReturn(savedPerson); + + // Act + savePersonUseCase.execute(command); + + // Assert - Verify what was passed to repository + verify(personRepository).save(personCaptor.capture()); + var capturedPerson = personCaptor.getValue(); + + assertNull(capturedPerson.getId()); // New person should have null ID + assertEquals("Jane", capturedPerson.getFirstName()); + assertEquals("Smith", capturedPerson.getLastName()); + assertEquals(30, capturedPerson.getAge()); + assertEquals("+9876543210", capturedPerson.getPhoneNumber()); + assertEquals("jane.smith@example.com", capturedPerson.getEmail()); + assertEquals("Personal", capturedPerson.getCategory().getType()); + } + } + + @Nested + @DisplayName("Execute - Validation Failures") + class ExecuteValidationFailures { + + @Test + @DisplayName("Should throw DomainException when age is less than 18") + void shouldThrowExceptionWhenAgeIsInvalid() { + // Arrange + var command = + new SavePersonCommand( + "Young", + "Person", + 17, // Invalid age + "+1234567890", + "young@example.com", + "123 Main St", + 1L, + "Student"); + + // Act & Assert + var exception = assertThrows(DomainException.class, () -> savePersonUseCase.execute(command)); + assertTrue(exception.getMessage().contains("Age cannot be less than 18")); + + // Verify repository was never called + verify(personRepository, never()).save(any(Person.class)); + } + + @Test + @DisplayName("Should throw DomainException when email is empty") + void shouldThrowExceptionWhenEmailIsEmpty() { + // Arrange + var command = + new SavePersonCommand( + "John", + "Doe", + 25, + "+1234567890", + "", // Empty email + "123 Main St", + 1L, + "Professional"); + + // Act & Assert + var exception = assertThrows(DomainException.class, () -> savePersonUseCase.execute(command)); + assertTrue(exception.getMessage().contains("Email cannot be null or empty")); + + verify(personRepository, never()).save(any(Person.class)); + } + + @Test + @DisplayName("Should throw DomainException when category type is invalid") + void shouldThrowExceptionWhenCategoryTypeIsInvalid() { + // Arrange + var command = + new SavePersonCommand( + "John", + "Doe", + 25, + "+1234567890", + "john@example.com", + "123 Main St", + 1L, + "" // Empty category type + ); + + // Act & Assert + var exception = assertThrows(DomainException.class, () -> savePersonUseCase.execute(command)); + assertTrue(exception.getMessage().contains("Type is null or empty")); + + verify(personRepository, never()).save(any(Person.class)); + } + } + + @Nested + @DisplayName("Execute - Repository Exceptions") + class ExecuteRepositoryExceptions { + + @Test + @DisplayName("Should propagate exception when repository fails") + void shouldPropagateExceptionWhenRepositoryFails() { + // Arrange + var command = + new SavePersonCommand( + "John", + "Doe", + 25, + "+1234567890", + "john.doe@example.com", + "123 Main St", + 1L, + "Professional"); + + when(personRepository.save(any(Person.class))) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + var exception = + assertThrows(RuntimeException.class, () -> savePersonUseCase.execute(command)); + assertTrue(exception.getMessage().contains("Database connection failed")); + + verify(personRepository, times(1)).save(any(Person.class)); + } + } +} diff --git a/onion-architecture/domain/pom.xml b/onion-architecture/domain/pom.xml new file mode 100644 index 000000000000..d1efc50def12 --- /dev/null +++ b/onion-architecture/domain/pom.xml @@ -0,0 +1,47 @@ + + + + 4.0.0 + + com.iluwatar + onion-architecture + 1.26.0-SNAPSHOT + + domain + Domain + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + diff --git a/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/exception/DomainException.java b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/exception/DomainException.java new file mode 100644 index 000000000000..5e0831a57c02 --- /dev/null +++ b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/exception/DomainException.java @@ -0,0 +1,34 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.exception; + +public class DomainException extends RuntimeException { + + public DomainException(String message) { + super(message); + } +} diff --git a/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Category.java b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Category.java new file mode 100644 index 000000000000..8380f4e849ee --- /dev/null +++ b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Category.java @@ -0,0 +1,51 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.model; + +import com.iluwatar.onion.domain.exception.DomainException; + +public class Category { + + private final Long id; + private final String type; + + public Category(Long id, String type) { + if (type == null || type.isEmpty()) { + throw new DomainException("Type is null or empty. Category type is required."); + } + this.id = id; + this.type = type; + } + + public Long getId() { + return id; + } + + public String getType() { + return type; + } +} diff --git a/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Person.java b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Person.java new file mode 100644 index 000000000000..55eced0be29a --- /dev/null +++ b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/model/Person.java @@ -0,0 +1,121 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.model; + +import com.iluwatar.onion.domain.exception.DomainException; + +public class Person { + + private final Long id; + private final String firstName; + private final String lastName; + private final int age; + private final String phoneNumber; + private final String email; + private final Category category; + + public Person( + Long id, + String firstName, + String lastName, + int age, + String phoneNumber, + String email, + Category category) { + validateNames(firstName, lastName); + validateAge(age); + validatePhone(phoneNumber); + validateEmail(email); + validateCategory(category); + + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.age = age; + this.phoneNumber = phoneNumber; + this.email = email; + this.category = category; + } + + private void validateNames(String firstName, String lastName) { + if (firstName == null || lastName == null) { + throw new DomainException("First name and last name cannot be null."); + } + } + + private void validateAge(int age) { + if (age < 18) { + throw new DomainException("Age cannot be less than 18."); + } + } + + private void validatePhone(String phone) { + if (phone == null || phone.isEmpty()) { + throw new DomainException("Phone number cannot be null or empty."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isEmpty()) { + throw new DomainException("Email cannot be null or empty."); + } + } + + private void validateCategory(Category category) { + if (category == null || category.getType().isEmpty()) { + throw new DomainException("Category cannot be null or empty."); + } + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public int getAge() { + return age; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public String getEmail() { + return email; + } + + public Category getCategory() { + return category; + } +} diff --git a/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/repository/PersonRepository.java b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/repository/PersonRepository.java new file mode 100644 index 000000000000..c14aa24a8ef6 --- /dev/null +++ b/onion-architecture/domain/src/main/java/com/iluwatar/onion/domain/repository/PersonRepository.java @@ -0,0 +1,45 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.repository; + +import com.iluwatar.onion.domain.model.Person; +import java.util.List; +import java.util.Optional; + +public interface PersonRepository { + Optional findById(Long id); + + Optional findByFirstName(String firstName); + + Optional findByLastName(String lastName); + + Optional> findAll(); + + Person save(Person person); + + boolean deleteById(Long id); +} diff --git a/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/CategoryTest.java b/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/CategoryTest.java new file mode 100644 index 000000000000..82e88285c35b --- /dev/null +++ b/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/CategoryTest.java @@ -0,0 +1,75 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.model; + +import static org.junit.jupiter.api.Assertions.*; + +import com.iluwatar.onion.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CategoryTest { + + @Test + @DisplayName("Should create category with valid data") + void shouldCreateCategoryWithValidData() { + // Arrange & Act + var category = new Category(1L, "Professional"); + + // Assert + assertNotNull(category); + assertEquals(1L, category.getId()); + assertEquals("Professional", category.getType()); + } + + @Test + @DisplayName("Should create category with null id") + void shouldCreateCategoryWithNullId() { + // Arrange & Act + var category = new Category(null, "Personal"); + + // Assert + assertNull(category.getId()); + assertEquals("Personal", category.getType()); + } + + @Test + @DisplayName("Should throw exception when type is null") + void shouldThrowExceptionWhenTypeIsNull() { + // Act & Assert + var exception = assertThrows(DomainException.class, () -> new Category(1L, null)); + assertTrue(exception.getMessage().contains("Type is null or empty")); + } + + @Test + @DisplayName("Should throw exception when type is empty") + void shouldThrowExceptionWhenTypeIsEmpty() { + // Act & Assert + var exception = assertThrows(DomainException.class, () -> new Category(1L, "")); + assertTrue(exception.getMessage().contains("Type is null or empty")); + } +} diff --git a/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/PersonTest.java b/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/PersonTest.java new file mode 100644 index 000000000000..8f5bef0da1a6 --- /dev/null +++ b/onion-architecture/domain/src/test/java/com/iluwatar/onion/domain/model/PersonTest.java @@ -0,0 +1,179 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.domain.model; + +import static org.junit.jupiter.api.Assertions.*; + +import com.iluwatar.onion.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PersonTest { + + private final Category validCategory = new Category(1L, "Professional"); + + @Nested + @DisplayName("Person Creation - Valid Cases") + class ValidPersonCreation { + + @Test + @DisplayName("Should create person with valid data") + void shouldCreatePersonWithValidData() { + // Arrange & Act + var person = + new Person(1L, "John", "Doe", 25, "+1234567890", "john.doe@example.com", validCategory); + + // Assert + assertNotNull(person); + assertEquals(1L, person.getId()); + assertEquals("John", person.getFirstName()); + assertEquals("Doe", person.getLastName()); + assertEquals(25, person.getAge()); + assertEquals("+1234567890", person.getPhoneNumber()); + assertEquals("john.doe@example.com", person.getEmail()); + assertEquals(validCategory, person.getCategory()); + } + + @Test + @DisplayName("Should create person with minimum valid age (18)") + void shouldCreatePersonWithMinimumValidAge() { + // Arrange & Act + var person = + new Person( + null, + "Jane", + "Smith", + 18, // minimum valid age + "+9876543210", + "jane@example.com", + validCategory); + + // Assert + assertEquals(18, person.getAge()); + } + } + + @Nested + @DisplayName("Person Creation - Invalid Cases") + class InvalidPersonCreation { + + @Test + @DisplayName("Should throw exception when first name is null") + void shouldThrowExceptionWhenFirstNameIsNull() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> + new Person( + 1L, null, "Doe", 25, "+1234567890", "john@example.com", validCategory)); + assertTrue(exception.getMessage().contains("First name and last name cannot be null")); + } + + @Test + @DisplayName("Should throw exception when last name is null") + void shouldThrowExceptionWhenLastNameIsNull() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> + new Person( + 1L, "John", null, 25, "+1234567890", "john@example.com", validCategory)); + assertTrue(exception.getMessage().contains("First name and last name cannot be null")); + } + + @Test + @DisplayName("Should throw exception when age is less than 18") + void shouldThrowExceptionWhenAgeIsLessThan18() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> + new Person( + 1L, "John", "Doe", 17, "+1234567890", "john@example.com", validCategory)); + assertTrue(exception.getMessage().contains("Age cannot be less than 18")); + } + + @Test + @DisplayName("Should throw exception when phone number is null") + void shouldThrowExceptionWhenPhoneIsNull() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> new Person(1L, "John", "Doe", 25, null, "john@example.com", validCategory)); + assertTrue(exception.getMessage().contains("Phone number cannot be null or empty")); + } + + @Test + @DisplayName("Should throw exception when phone number is empty") + void shouldThrowExceptionWhenPhoneIsEmpty() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> new Person(1L, "John", "Doe", 25, "", "john@example.com", validCategory)); + assertTrue(exception.getMessage().contains("Phone number cannot be null or empty")); + } + + @Test + @DisplayName("Should throw exception when email is null") + void shouldThrowExceptionWhenEmailIsNull() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> new Person(1L, "John", "Doe", 25, "+1234567890", null, validCategory)); + assertTrue(exception.getMessage().contains("Email cannot be null or empty")); + } + + @Test + @DisplayName("Should throw exception when email is empty") + void shouldThrowExceptionWhenEmailIsEmpty() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> new Person(1L, "John", "Doe", 25, "+1234567890", "", validCategory)); + assertTrue(exception.getMessage().contains("Email cannot be null or empty")); + } + + @Test + @DisplayName("Should throw exception when category is null") + void shouldThrowExceptionWhenCategoryIsNull() { + // Act & Assert + var exception = + assertThrows( + DomainException.class, + () -> new Person(1L, "John", "Doe", 25, "+1234567890", "john@example.com", null)); + assertTrue(exception.getMessage().contains("Category cannot be null or empty")); + } + } +} diff --git a/onion-architecture/etc/onion-architecture.png b/onion-architecture/etc/onion-architecture.png new file mode 100644 index 000000000000..82f34e3d7fd1 Binary files /dev/null and b/onion-architecture/etc/onion-architecture.png differ diff --git a/onion-architecture/etc/onion-architecture.puml b/onion-architecture/etc/onion-architecture.puml new file mode 100644 index 000000000000..0e00429de2e6 --- /dev/null +++ b/onion-architecture/etc/onion-architecture.puml @@ -0,0 +1,237 @@ +@startuml onion-architecture + +skinparam packageStyle rectangle +skinparam shadowing false +skinparam defaultFontName Arial +skinparam defaultFontSize 11 +skinparam classAttributeIconSize 0 +skinparam ArrowColor #444444 +skinparam ArrowThickness 1.2 + +skinparam package { + BackgroundColor<> #FFF9C4 + BorderColor<> #F9A825 + FontColor<> #5D4037 + FontStyle<> bold +} + +skinparam package { + BackgroundColor<> #C8E6C9 + BorderColor<> #388E3C + FontColor<> #1B5E20 + FontStyle<> bold +} + +skinparam package { + BackgroundColor<> #BBDEFB + BorderColor<> #1976D2 + FontColor<> #0D47A1 + FontStyle<> bold +} + +' ───────────────────────────────────────────────────────────────────────────── +' DOMAIN LAYER (innermost) +' ───────────────────────────────────────────────────────────────────────────── +package "Domain Layer" <> { + + package "model" { + class Person { + - id : Long + - firstName : String + - lastName : String + - age : int + - phoneNumber : String + - email : String + - category : Category + + getId() : Long + + getFirstName() : String + + getLastName() : String + + getAge() : int + + getPhoneNumber() : String + + getEmail() : String + + getCategory() : Category + } + + class Category { + - id : Long + - type : String + + getId() : Long + + getType() : String + } + } + + package "repository" { + interface PersonRepository { + + findById(id : Long) : Optional + + findByFirstName(firstName : String) : Optional + + findByLastName(lastName : String) : Optional + + findAll() : Optional> + + save(person : Person) : Person + + deleteById(id : Long) : boolean + } + } + + package "exception" { + class DomainException { + + DomainException(message : String) + } + } +} + +' ───────────────────────────────────────────────────────────────────────────── +' APPLICATION LAYER +' ───────────────────────────────────────────────────────────────────────────── +package "Application Layer" <> { + + package "usecase" { + class SavePersonUseCase { + - repository : PersonRepository + + SavePersonUseCase(repository : PersonRepository) + + execute(command : SavePersonCommand) : PersonResponse + } + + class GetPersonUseCase { + - repository : PersonRepository + + GetPersonUseCase(repository : PersonRepository) + + execute(id : Long) : PersonResponse + + executeAll() : List + } + } + + package "dto" { + class SavePersonCommand <> { + firstName : String + lastName : String + age : int + phoneNumber : String + email : String + address : String + categoryId : Long + categoryType : String + } + + class PersonResponse <> { + id : Long + firstName : String + lastName : String + age : int + phoneNumber : String + email : String + categoryId : Long + categoryType : String + } + } +} + +' ───────────────────────────────────────────────────────────────────────────── +' INFRASTRUCTURE LAYER (outermost) +' ───────────────────────────────────────────────────────────────────────────── +package "Infrastructure Layer" <> { + + package "web" { + class PersonController { + - savePersonUseCase : SavePersonUseCase + - getPersonUseCase : GetPersonUseCase + + getPerson(id : Long) : ResponseEntity + + getAllPersons() : ResponseEntity> + + savePerson(command : SavePersonCommand) : ResponseEntity + } + } + + package "persistence" { + class PersonRepositoryAdapter { + - repository : SpringDataPersonRepository + + findById(id : Long) : Optional + + findByFirstName(firstName : String) : Optional + + findByLastName(lastName : String) : Optional + + findAll() : Optional> + + save(person : Person) : Person + + deleteById(id : Long) : boolean + } + + interface SpringDataPersonRepository { + + findByFirstName(firstName : String) : Optional + + findByLastName(lastName : String) : Optional + } + + class JpaPersonEntity { + - id : Long + - firstName : String + - lastName : String + - age : int + - phoneNumber : String + - email : String + - category : JpaCategoryEntity + } + + class JpaCategoryEntity { + - id : Long + - type : String + } + } + + package "config" { + class ApplicationConfig <<@Configuration>> { + + savePersonUseCase(repo : PersonRepository) : SavePersonUseCase + + getPersonUseCase(repo : PersonRepository) : GetPersonUseCase + } + + class Application <<@SpringBootApplication>> { + + main(args : String[]) + } + } +} + +' ───────────────────────────────────────────────────────────────────────────── +' DOMAIN: internal relationships +' ───────────────────────────────────────────────────────────────────────────── +Person "1" *-- "1" Category : has +Person ..> DomainException : <> +Category ..> DomainException : <> + +' ───────────────────────────────────────────────────────────────────────────── +' APPLICATION → DOMAIN +' ───────────────────────────────────────────────────────────────────────────── +SavePersonUseCase --> PersonRepository : uses +SavePersonUseCase ..> Person : creates +SavePersonUseCase ..> Category : creates +SavePersonUseCase ..> PersonResponse : returns +SavePersonUseCase ..> SavePersonCommand : receives + +GetPersonUseCase --> PersonRepository : uses +GetPersonUseCase ..> PersonResponse : returns + +' ───────────────────────────────────────────────────────────────────────────── +' INFRASTRUCTURE → APPLICATION / DOMAIN +' ───────────────────────────────────────────────────────────────────────────── +PersonController --> SavePersonUseCase : delegates to +PersonController --> GetPersonUseCase : delegates to +PersonController ..> DomainException : catches + +PersonRepositoryAdapter ..|> PersonRepository : implements +PersonRepositoryAdapter --> SpringDataPersonRepository : uses +PersonRepositoryAdapter ..> JpaPersonEntity : maps +PersonRepositoryAdapter ..> JpaCategoryEntity : maps +PersonRepositoryAdapter ..> Person : maps to / from +PersonRepositoryAdapter ..> Category : maps to / from + +SpringDataPersonRepository --> JpaPersonEntity : manages +JpaPersonEntity "1" *-- "1" JpaCategoryEntity : contains + +ApplicationConfig ..> SavePersonUseCase : <<@Bean>> +ApplicationConfig ..> GetPersonUseCase : <<@Bean>> +ApplicationConfig --> PersonRepository : injects + +legend right + Layer dependency rule + Infrastructure → Application → Domain + Outer layers depend on inner layers. + Domain has NO external dependencies. + ---- + ■ Domain Layer + ■ Application Layer + ■ Infrastructure Layer +endlegend + +@enduml + diff --git a/onion-architecture/etc/postman/onion-architecture.postman_collection.json b/onion-architecture/etc/postman/onion-architecture.postman_collection.json new file mode 100644 index 000000000000..ed0cbd5c58f5 --- /dev/null +++ b/onion-architecture/etc/postman/onion-architecture.postman_collection.json @@ -0,0 +1,83 @@ +{ + "info": { + "_postman_id": "79aca374-7080-46c1-938f-89b327a84d6a", + "name": "onion-architecture", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "51221021", + "_collection_link": "https://go.postman.co/collection/51221021-79aca374-7080-46c1-938f-89b327a84d6a?source=collection_link" + }, + "item": [ + { + "name": "savePerson", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"number1\": 5,\r\n \"number2\": 3,\r\n \"operation\": \"multiply\"\r\n }" + }, + "url": { + "raw": "http://localhost:8080/api/persons", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "persons" + ] + } + }, + "response": [] + }, + { + "name": "getPersons", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"number1\": 5,\r\n \"number2\": 3,\r\n \"operation\": \"multiply\"\r\n }" + }, + "url": { + "raw": "http://localhost:8080/api/persons", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "persons" + ] + } + }, + "response": [] + }, + { + "name": "getPersonById", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/persons/1", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "persons", + "1" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/onion-architecture/infrastructure/pom.xml b/onion-architecture/infrastructure/pom.xml new file mode 100644 index 000000000000..d26e2a7ef69d --- /dev/null +++ b/onion-architecture/infrastructure/pom.xml @@ -0,0 +1,87 @@ + + + + 4.0.0 + + com.iluwatar + onion-architecture + 1.26.0-SNAPSHOT + + infrastructure + Infrastructure + + + + com.iluwatar + domain + ${project.version} + + + + com.iluwatar + application + ${project.version} + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/Application.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/Application.java new file mode 100644 index 000000000000..c86c95740599 --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/Application.java @@ -0,0 +1,38 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/config/ApplicationConfig.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/config/ApplicationConfig.java new file mode 100644 index 000000000000..7794c91e3bcf --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/config/ApplicationConfig.java @@ -0,0 +1,47 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.config; + +import com.iluwatar.onion.application.usecase.GetPersonUseCase; +import com.iluwatar.onion.application.usecase.SavePersonUseCase; +import com.iluwatar.onion.domain.repository.PersonRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ApplicationConfig { + + @Bean + public SavePersonUseCase savePersonUseCase(PersonRepository repository) { + return new SavePersonUseCase(repository); + } + + @Bean + public GetPersonUseCase getPersonUseCase(PersonRepository repository) { + return new GetPersonUseCase(repository); + } +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaCategoryEntity.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaCategoryEntity.java new file mode 100644 index 000000000000..aafd242992be --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaCategoryEntity.java @@ -0,0 +1,55 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.persistence; + +import jakarta.persistence.*; + +@Entity +@Table(name = "category") +public class JpaCategoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String type; + + public JpaCategoryEntity() {} + + public JpaCategoryEntity(Long id, String type) { + this.id = id; + this.type = type; + } + + public Long getId() { + return id; + } + + public String getType() { + return type; + } +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaPersonEntity.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaPersonEntity.java new file mode 100644 index 000000000000..bf71b5306c92 --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/JpaPersonEntity.java @@ -0,0 +1,93 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.persistence; + +import jakarta.persistence.*; + +@Entity +@Table(name = "person") +public class JpaPersonEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String firstName; + private String lastName; + private int age; + private String phoneNumber; + private String email; + + @ManyToOne private JpaCategoryEntity category; + + public JpaPersonEntity() {} + + public JpaPersonEntity( + Long id, + String firstName, + String lastName, + int age, + String phoneNumber, + String email, + JpaCategoryEntity category) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.age = age; + this.phoneNumber = phoneNumber; + this.email = email; + this.category = category; + } + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public int getAge() { + return age; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public String getEmail() { + return email; + } + + public JpaCategoryEntity getCategory() { + return category; + } +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapter.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapter.java new file mode 100644 index 000000000000..4c4aa267493a --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapter.java @@ -0,0 +1,102 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.persistence; + +import com.iluwatar.onion.domain.model.Category; +import com.iluwatar.onion.domain.model.Person; +import com.iluwatar.onion.domain.repository.PersonRepository; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Repository; + +@Repository +public class PersonRepositoryAdapter implements PersonRepository { + + private final SpringDataPersonRepository repository; + + public PersonRepositoryAdapter(SpringDataPersonRepository repository) { + this.repository = repository; + } + + @Override + public Optional findById(Long id) { + return repository.findById(id).map(this::mapToDomain); + } + + @Override + public Optional findByFirstName(String firstName) { + return repository.findByFirstName(firstName).map(this::mapToDomain); + } + + @Override + public Optional findByLastName(String lastName) { + return repository.findByLastName(lastName).map(this::mapToDomain); + } + + @Override + public Optional> findAll() { + return repository.findAll().stream() + .map(this::mapToDomain) + .collect(Collectors.collectingAndThen(Collectors.toList(), Optional::of)); + } + + @Override + public Person save(Person person) { + JpaPersonEntity entity = mapToEntity(person); + JpaPersonEntity savedEntity = repository.save(entity); + return mapToDomain(savedEntity); + } + + @Override + public boolean deleteById(Long id) { + repository.deleteById(id); + return repository.findById(id).isEmpty(); + } + + private JpaPersonEntity mapToEntity(Person person) { + return new JpaPersonEntity( + person.getId(), + person.getFirstName(), + person.getLastName(), + person.getAge(), + person.getPhoneNumber(), + person.getEmail(), + new JpaCategoryEntity(person.getCategory().getId(), person.getCategory().getType())); + } + + private Person mapToDomain(JpaPersonEntity entity) { + return new Person( + entity.getId(), + entity.getFirstName(), + entity.getLastName(), + entity.getAge(), + entity.getPhoneNumber(), + entity.getEmail(), + new Category(entity.getCategory().getId(), entity.getCategory().getType())); + } +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/SpringDataPersonRepository.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/SpringDataPersonRepository.java new file mode 100644 index 000000000000..35f86515c70b --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/persistence/SpringDataPersonRepository.java @@ -0,0 +1,37 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.persistence; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpringDataPersonRepository extends JpaRepository { + + Optional findByFirstName(String firstName); + + Optional findByLastName(String lastName); +} diff --git a/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/web/PersonController.java b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/web/PersonController.java new file mode 100644 index 000000000000..417e749f41a6 --- /dev/null +++ b/onion-architecture/infrastructure/src/main/java/com/iluwatar/onion/infrastructure/web/PersonController.java @@ -0,0 +1,72 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.web; + +import com.iluwatar.onion.application.dto.PersonResponse; +import com.iluwatar.onion.application.dto.SavePersonCommand; +import com.iluwatar.onion.application.usecase.GetPersonUseCase; +import com.iluwatar.onion.application.usecase.SavePersonUseCase; +import com.iluwatar.onion.domain.exception.DomainException; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +public class PersonController { + + private final SavePersonUseCase savePersonUseCase; + private final GetPersonUseCase getPersonUseCase; + + public PersonController(SavePersonUseCase savePersonUseCase, GetPersonUseCase getPersonUseCase) { + this.savePersonUseCase = savePersonUseCase; + this.getPersonUseCase = getPersonUseCase; + } + + @GetMapping("/persons/{id}") + public ResponseEntity getPerson(@PathVariable("id") Long id) { + var person = getPersonUseCase.execute(id); + return ResponseEntity.ok(person); + } + + @GetMapping("/persons") + public ResponseEntity> getAllPersons() { + var persons = getPersonUseCase.executeAll(); + return ResponseEntity.ok(persons); + } + + @PostMapping("/persons") + public ResponseEntity savePerson(@RequestBody SavePersonCommand command) { + try { + var savedPerson = savePersonUseCase.execute(command); + return ResponseEntity.status(HttpStatus.OK).body(savedPerson); + } catch (DomainException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + } +} diff --git a/onion-architecture/infrastructure/src/main/resources/application.properties b/onion-architecture/infrastructure/src/main/resources/application.properties new file mode 100644 index 000000000000..6a3ed156f558 --- /dev/null +++ b/onion-architecture/infrastructure/src/main/resources/application.properties @@ -0,0 +1,19 @@ +# You can also specify the server port in the application.properties file. +# Uncomment the following line and set the desired port number to change +# the default port (8080) to a different value. +#server.port=8080 + +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +# Force Spring Boot to always run SQL initialization scripts on startup +spring.sql.init.mode=always + +# If using Spring Data JPA / Hibernate, defer data insertion until AFTER entities are generated +spring.jpa.defer-datasource-initialization=true + +# Keep Hibernate's DDL generation active (or set to 'update') +spring.jpa.hibernate.ddl-auto=create-drop diff --git a/onion-architecture/infrastructure/src/main/resources/data.sql b/onion-architecture/infrastructure/src/main/resources/data.sql new file mode 100644 index 000000000000..6291dd984e31 --- /dev/null +++ b/onion-architecture/infrastructure/src/main/resources/data.sql @@ -0,0 +1,4 @@ +INSERT INTO category (id, type) VALUES (1, 'Teenage'); +INSERT INTO category (id, type) VALUES (2, 'Young Adult'); +INSERT INTO category (id, type) VALUES (3, 'Adult'); +INSERT INTO category (id, type) VALUES (4, 'Senior'); \ No newline at end of file diff --git a/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapterTest.java b/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapterTest.java new file mode 100644 index 000000000000..49fd2816f172 --- /dev/null +++ b/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/persistence/PersonRepositoryAdapterTest.java @@ -0,0 +1,332 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.persistence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.iluwatar.onion.domain.model.Category; +import com.iluwatar.onion.domain.model.Person; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PersonRepositoryAdapterTest { + + @Mock private SpringDataPersonRepository springDataRepository; + + @Captor private ArgumentCaptor entityCaptor; + + private PersonRepositoryAdapter personRepositoryAdapter; + private JpaPersonEntity jpaPersonEntity; + + @BeforeEach + void setUp() { + personRepositoryAdapter = new PersonRepositoryAdapter(springDataRepository); + + // Setup test data + var jpaCategory = new JpaCategoryEntity(1L, "Professional"); + jpaPersonEntity = + new JpaPersonEntity( + 1L, "John", "Doe", 25, "+1234567890", "john.doe@example.com", jpaCategory); + } + + @Nested + @DisplayName("Find By ID") + class FindById { + + @Test + @DisplayName("Should return Person when entity exists") + void shouldReturnPersonWhenEntityExists() { + // Arrange + when(springDataRepository.findById(1L)).thenReturn(Optional.of(jpaPersonEntity)); + + // Act + var result = personRepositoryAdapter.findById(1L); + + // Assert + assertTrue(result.isPresent()); + Person person = result.get(); + assertEquals(1L, person.getId()); + assertEquals("John", person.getFirstName()); + assertEquals("Doe", person.getLastName()); + assertEquals(25, person.getAge()); + assertEquals("+1234567890", person.getPhoneNumber()); + assertEquals("john.doe@example.com", person.getEmail()); + assertEquals(1L, person.getCategory().getId()); + assertEquals("Professional", person.getCategory().getType()); + + verify(springDataRepository, times(1)).findById(1L); + } + + @Test + @DisplayName("Should return empty Optional when entity not found") + void shouldReturnEmptyWhenEntityNotFound() { + // Arrange + when(springDataRepository.findById(999L)).thenReturn(Optional.empty()); + + // Act + var result = personRepositoryAdapter.findById(999L); + + // Assert + assertTrue(result.isEmpty()); + verify(springDataRepository, times(1)).findById(999L); + } + } + + @Nested + @DisplayName("Find By First Name") + class FindByFirstName { + + @Test + @DisplayName("Should return Person when entity with first name exists") + void shouldReturnPersonWhenEntityExists() { + // Arrange + when(springDataRepository.findByFirstName("John")).thenReturn(Optional.of(jpaPersonEntity)); + + // Act + var result = personRepositoryAdapter.findByFirstName("John"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("John", result.get().getFirstName()); + verify(springDataRepository, times(1)).findByFirstName("John"); + } + + @Test + @DisplayName("Should return empty Optional when no entity found") + void shouldReturnEmptyWhenNotFound() { + // Arrange + when(springDataRepository.findByFirstName("Unknown")).thenReturn(Optional.empty()); + + // Act + var result = personRepositoryAdapter.findByFirstName("Unknown"); + + // Assert + assertTrue(result.isEmpty()); + verify(springDataRepository, times(1)).findByFirstName("Unknown"); + } + } + + @Nested + @DisplayName("Find By Last Name") + class FindByLastName { + + @Test + @DisplayName("Should return Person when entity with last name exists") + void shouldReturnPersonWhenEntityExists() { + // Arrange + when(springDataRepository.findByLastName("Doe")).thenReturn(Optional.of(jpaPersonEntity)); + + // Act + var result = personRepositoryAdapter.findByLastName("Doe"); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Doe", result.get().getLastName()); + verify(springDataRepository, times(1)).findByLastName("Doe"); + } + } + + @Nested + @DisplayName("Find All") + class FindAll { + + @Test + @DisplayName("Should return list of Persons when entities exist") + void shouldReturnListOfPersons() { + // Arrange + var category2 = new JpaCategoryEntity(2L, "Personal"); + var person2 = + new JpaPersonEntity( + 2L, "Jane", "Smith", 30, "+9876543210", "jane@example.com", category2); + + when(springDataRepository.findAll()).thenReturn(List.of(jpaPersonEntity, person2)); + + // Act + var result = personRepositoryAdapter.findAll(); + + // Assert + assertTrue(result.isPresent()); + var persons = result.get(); + assertEquals(2, persons.size()); + assertEquals("John", persons.get(0).getFirstName()); + assertEquals("Jane", persons.get(1).getFirstName()); + + verify(springDataRepository, times(1)).findAll(); + } + + @Test + @DisplayName("Should return empty list when no entities exist") + void shouldReturnEmptyListWhenNoEntities() { + // Arrange + when(springDataRepository.findAll()).thenReturn(List.of()); + + // Act + var result = personRepositoryAdapter.findAll(); + + // Assert + assertTrue(result.isPresent()); + assertTrue(result.get().isEmpty()); + verify(springDataRepository, times(1)).findAll(); + } + } + + @Nested + @DisplayName("Save") + class Save { + + @Test + @DisplayName("Should save person and return domain model") + void shouldSavePersonAndReturnDomainModel() { + // Arrange + var personToSave = + new Person( + null, // New person without ID + "Jane", + "Smith", + 28, + "+1111111111", + "jane@example.com", + new Category(2L, "Personal")); + + var savedCategory = new JpaCategoryEntity(2L, "Personal"); + var savedEntity = + new JpaPersonEntity( + 100L, // ID assigned by database + "Jane", + "Smith", + 28, + "+1111111111", + "jane@example.com", + savedCategory); + + when(springDataRepository.save(any(JpaPersonEntity.class))).thenReturn(savedEntity); + + // Act + var result = personRepositoryAdapter.save(personToSave); + + // Assert + assertNotNull(result); + assertEquals(100L, result.getId()); + assertEquals("Jane", result.getFirstName()); + assertEquals("Smith", result.getLastName()); + assertEquals(28, result.getAge()); + + verify(springDataRepository, times(1)).save(any(JpaPersonEntity.class)); + } + + @Test + @DisplayName("Should correctly map domain model to JPA entity") + void shouldCorrectlyMapDomainToEntity() { + // Arrange + var personToSave = + new Person( + null, + "Bob", + "Johnson", + 35, + "+2222222222", + "bob@example.com", + new Category(3L, "Business")); + + var savedEntity = + new JpaPersonEntity( + 200L, + "Bob", + "Johnson", + 35, + "+2222222222", + "bob@example.com", + new JpaCategoryEntity(3L, "Business")); + + when(springDataRepository.save(any(JpaPersonEntity.class))).thenReturn(savedEntity); + + // Act + personRepositoryAdapter.save(personToSave); + + // Assert - Verify what was passed to Spring Data repository + verify(springDataRepository).save(entityCaptor.capture()); + JpaPersonEntity capturedEntity = entityCaptor.getValue(); + + assertEquals("Bob", capturedEntity.getFirstName()); + assertEquals("Johnson", capturedEntity.getLastName()); + assertEquals(35, capturedEntity.getAge()); + assertEquals("+2222222222", capturedEntity.getPhoneNumber()); + assertEquals("bob@example.com", capturedEntity.getEmail()); + assertEquals(3L, capturedEntity.getCategory().getId()); + assertEquals("Business", capturedEntity.getCategory().getType()); + } + } + + @Nested + @DisplayName("Delete By ID") + class DeleteById { + + @Test + @DisplayName("Should return true when person is successfully deleted") + void shouldReturnTrueWhenDeleted() { + // Arrange + doNothing().when(springDataRepository).deleteById(1L); + when(springDataRepository.findById(1L)).thenReturn(Optional.empty()); + + // Act + boolean result = personRepositoryAdapter.deleteById(1L); + + // Assert + assertTrue(result); + verify(springDataRepository, times(1)).deleteById(1L); + verify(springDataRepository, times(1)).findById(1L); + } + + @Test + @DisplayName("Should return false when person still exists after deletion") + void shouldReturnFalseWhenStillExists() { + // Arrange + doNothing().when(springDataRepository).deleteById(1L); + when(springDataRepository.findById(1L)).thenReturn(Optional.of(jpaPersonEntity)); + + // Act + boolean result = personRepositoryAdapter.deleteById(1L); + + // Assert + assertFalse(result); + verify(springDataRepository, times(1)).deleteById(1L); + verify(springDataRepository, times(1)).findById(1L); + } + } +} diff --git a/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/web/PersonControllerTest.java b/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/web/PersonControllerTest.java new file mode 100644 index 000000000000..9e0369b985c8 --- /dev/null +++ b/onion-architecture/infrastructure/src/test/java/com/iluwatar/onion/infrastructure/web/PersonControllerTest.java @@ -0,0 +1,348 @@ +/* + * + * * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * * + * * The MIT License + * * Copyright © 2014-2022 Ilkka Seppälä + * * + * * Permission is hereby granted, free of charge, to any person obtaining a copy + * * of this software and associated documentation files (the "Software"), to deal + * * in the Software without restriction, including without limitation the rights + * * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * * copies of the Software, and to permit persons to whom the Software is + * * furnished to do so, subject to the following conditions: + * * + * * The above copyright notice and this permission notice shall be included in + * * all copies or substantial portions of the Software. + * * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * * THE SOFTWARE. + * + */ +package com.iluwatar.onion.infrastructure.web; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iluwatar.onion.application.dto.PersonResponse; +import com.iluwatar.onion.application.dto.SavePersonCommand; +import com.iluwatar.onion.application.usecase.GetPersonUseCase; +import com.iluwatar.onion.application.usecase.SavePersonUseCase; +import com.iluwatar.onion.domain.exception.DomainException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PersonController.class) +class PersonControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private SavePersonUseCase savePersonUseCase; + + @MockitoBean private GetPersonUseCase getPersonUseCase; + + @Nested + @DisplayName("GET /api/persons/{id}") + class GetPersonById { + + @Test + @DisplayName("Should return person when person exists") + void shouldReturnPersonWhenExists() throws Exception { + // Arrange + var response = + new PersonResponse( + 1L, "John", "Doe", 25, "+1234567890", "john.doe@example.com", 1L, "Professional"); + + when(getPersonUseCase.execute(1L)).thenReturn(response); + + // Act & Assert + mockMvc + .perform(get("/api/persons/1").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.firstName").value("John")) + .andExpect(jsonPath("$.lastName").value("Doe")) + .andExpect(jsonPath("$.age").value(25)) + .andExpect(jsonPath("$.phoneNumber").value("+1234567890")) + .andExpect(jsonPath("$.email").value("john.doe@example.com")) + .andExpect(jsonPath("$.categoryId").value(1)) + .andExpect(jsonPath("$.categoryType").value("Professional")); + + verify(getPersonUseCase, times(1)).execute(1L); + } + + @Test + @DisplayName("Should handle different person IDs") + void shouldHandleDifferentPersonIds() throws Exception { + // Arrange + var response = + new PersonResponse( + 42L, "Jane", "Smith", 30, "+9876543210", "jane@example.com", 2L, "Personal"); + + when(getPersonUseCase.execute(42L)).thenReturn(response); + + // Act & Assert + mockMvc + .perform(get("/api/persons/42").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(42)) + .andExpect(jsonPath("$.firstName").value("Jane")); + + verify(getPersonUseCase, times(1)).execute(42L); + } + } + + @Nested + @DisplayName("GET /api/persons") + class GetAllPersons { + + @Test + @DisplayName("Should return list of persons") + void shouldReturnListOfPersons() throws Exception { + // Arrange + var person1 = + new PersonResponse( + 1L, "John", "Doe", 25, "+1234567890", "john@example.com", 1L, "Professional"); + var person2 = + new PersonResponse( + 2L, "Jane", "Smith", 30, "+9876543210", "jane@example.com", 2L, "Personal"); + + var persons = List.of(person1, person2); + when(getPersonUseCase.executeAll()).thenReturn(persons); + + // Act & Assert + mockMvc + .perform(get("/api/persons").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].firstName").value("John")) + .andExpect(jsonPath("$[1].id").value(2)) + .andExpect(jsonPath("$[1].firstName").value("Jane")); + + verify(getPersonUseCase, times(1)).executeAll(); + } + + @Test + @DisplayName("Should return empty list when no persons exist") + void shouldReturnEmptyListWhenNoPersons() throws Exception { + // Arrange + when(getPersonUseCase.executeAll()).thenReturn(List.of()); + + // Act & Assert + mockMvc + .perform(get("/api/persons").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(0))); + + verify(getPersonUseCase, times(1)).executeAll(); + } + + @Test + @DisplayName("Should handle large list of persons") + void shouldHandleLargeListOfPersons() throws Exception { + // Arrange + var persons = + List.of( + new PersonResponse(1L, "Person1", "Last1", 25, "+1", "p1@example.com", 1L, "Cat1"), + new PersonResponse(2L, "Person2", "Last2", 26, "+2", "p2@example.com", 1L, "Cat1"), + new PersonResponse(3L, "Person3", "Last3", 27, "+3", "p3@example.com", 1L, "Cat1")); + + when(getPersonUseCase.executeAll()).thenReturn(persons); + + // Act & Assert + mockMvc + .perform(get("/api/persons").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(3))); + + verify(getPersonUseCase, times(1)).executeAll(); + } + } + + @Nested + @DisplayName("POST /api/persons") + class SavePerson { + + @Test + @DisplayName("Should create person with valid data") + void shouldCreatePersonWithValidData() throws Exception { + // Arrange + var command = + new SavePersonCommand( + "John", + "Doe", + 25, + "+1234567890", + "john.doe@example.com", + "123 Main St", + 1L, + "Professional"); + + var response = + new PersonResponse( + 1L, "John", "Doe", 25, "+1234567890", "john.doe@example.com", 1L, "Professional"); + + when(savePersonUseCase.execute(any(SavePersonCommand.class))).thenReturn(response); + + // Act & Assert + mockMvc + .perform( + post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.firstName").value("John")) + .andExpect(jsonPath("$.lastName").value("Doe")) + .andExpect(jsonPath("$.age").value(25)) + .andExpect(jsonPath("$.phoneNumber").value("+1234567890")) + .andExpect(jsonPath("$.email").value("john.doe@example.com")) + .andExpect(jsonPath("$.categoryId").value(1)) + .andExpect(jsonPath("$.categoryType").value("Professional")); + + verify(savePersonUseCase, times(1)).execute(any(SavePersonCommand.class)); + } + + @Test + @DisplayName("Should handle validation errors from use case") + void shouldPropagateValidationErrors() throws Exception { + // Arrange + var command = + new SavePersonCommand( + "Young", + "Person", + 17, // Invalid age + "+1234567890", + "young@example.com", + "123 Main St", + 1L, + "Student"); + + when(savePersonUseCase.execute(any(SavePersonCommand.class))) + .thenThrow(new DomainException("Age cannot be less than 18")); + + // Act & Assert + mockMvc + .perform( + post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andExpect(status().is4xxClientError()); + + verify(savePersonUseCase, times(1)).execute(any(SavePersonCommand.class)); + } + + @Test + @DisplayName("Should handle malformed JSON") + void shouldHandleMalformedJson() throws Exception { + // Act & Assert + mockMvc + .perform( + post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{invalid json}")) + .andExpect(status().isBadRequest()); + + verify(savePersonUseCase, never()).execute(any(SavePersonCommand.class)); + } + + @Test + @DisplayName("Should accept all required fields in command") + void shouldAcceptAllRequiredFields() throws Exception { + // Arrange + var command = + new SavePersonCommand( + "Complete", + "Person", + 30, + "+1111111111", + "complete@example.com", + "456 Oak Ave", + 5L, + "Business"); + + var response = + new PersonResponse( + 10L, "Complete", "Person", 30, "+1111111111", "complete@example.com", 5L, "Business"); + + when(savePersonUseCase.execute(any(SavePersonCommand.class))).thenReturn(response); + + // Act & Assert + mockMvc + .perform( + post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.categoryType").value("Business")); + + verify(savePersonUseCase, times(1)).execute(any(SavePersonCommand.class)); + } + } + + @Nested + @DisplayName("Controller Integration") + class ControllerIntegration { + + @Test + @DisplayName("Should handle multiple requests sequentially") + void shouldHandleMultipleRequestsSequentially() throws Exception { + // Arrange + var getResponse = + new PersonResponse( + 1L, "John", "Doe", 25, "+1234567890", "john@example.com", 1L, "Professional"); + + var savePersonCommand = + new SavePersonCommand( + "Jane", "Smith", 30, "+9876543210", "jane@example.com", "456 Oak", 2L, "Personal"); + + var savePersonResponse = + new PersonResponse( + 2L, "Jane", "Smith", 30, "+9876543210", "jane@example.com", 2L, "Personal"); + + when(getPersonUseCase.execute(1L)).thenReturn(getResponse); + when(savePersonUseCase.execute(any(SavePersonCommand.class))).thenReturn(savePersonResponse); + + // Act & Assert - GET request + mockMvc + .perform(get("/api/persons/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("John")); + + // Act & Assert - POST request + mockMvc + .perform( + post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(savePersonCommand))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Jane")); + + verify(getPersonUseCase, times(1)).execute(1L); + verify(savePersonUseCase, times(1)).execute(any(SavePersonCommand.class)); + } + } +} diff --git a/onion-architecture/pom.xml b/onion-architecture/pom.xml new file mode 100644 index 000000000000..07df2a1224c1 --- /dev/null +++ b/onion-architecture/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + onion-architecture + pom + + + 5.10.0 + 5.23.0 + 3.24.2 + + + + domain + application + infrastructure + + diff --git a/pom.xml b/pom.xml index 7835110c008e..36f3b56ff0c4 100644 --- a/pom.xml +++ b/pom.xml @@ -106,7 +106,7 @@ converter curiously-recurring-template-pattern currying - dao-factory + dao-factory data-access-object data-bus data-locality @@ -252,6 +252,7 @@ backpressure actor-model rate-limiting-pattern + onion-architecture @@ -346,6 +347,11 @@ mockito-core ${mockito.version} + + org.mockito + mockito-junit-jupiter + ${mockito.version} + org.mongodb bson