feat: Add the implementation of Onion-Architecture (#3532)

* Add the implementation of Onion-Architecture

* Execute maven command: mvn spotless:apply
This commit is contained in:
Hattori Henzo
2026-08-16 20:30:42 +03:00
committed by GitHub
parent b55fc2bcb5
commit 688095a4c7
32 changed files with 3182 additions and 1 deletions
+292
View File
@@ -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<PersonResponse> getPerson(@PathVariable Long id) {
var person = getPersonUseCase.execute(id);
return ResponseEntity.ok(person);
}
@GetMapping("/persons")
public ResponseEntity<List<PersonResponse>> getAllPersons() {
var persons = getPersonUseCase.executeAll();
return ResponseEntity.ok(persons);
}
@PostMapping("/persons")
public ResponseEntity<PersonResponse> 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<Person> 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
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iluwatar</groupId>
<artifactId>onion-architecture</artifactId>
<version>1.26.0-SNAPSHOT</version>
</parent>
<artifactId>application</artifactId>
<name>Application</name>
<dependencies>
<dependency>
<groupId>com.iluwatar</groupId>
<artifactId>domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- JUnit 5 for testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito for mocking -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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) {}
@@ -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) {}
@@ -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<PersonResponse> 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();
}
}
@@ -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());
}
}
@@ -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<PersonResponse> responses = getPersonUseCase.executeAll();
// Assert
assertNotNull(responses);
assertTrue(responses.isEmpty());
verify(personRepository, times(1)).findAll();
}
}
}
@@ -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<Person> 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));
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iluwatar</groupId>
<artifactId>onion-architecture</artifactId>
<version>1.26.0-SNAPSHOT</version>
</parent>
<artifactId>domain</artifactId>
<name>Domain</name>
<dependencies>
<!-- JUnit 5 for testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<Person> findById(Long id);
Optional<Person> findByFirstName(String firstName);
Optional<Person> findByLastName(String lastName);
Optional<List<Person>> findAll();
Person save(Person person);
boolean deleteById(Long id);
}
@@ -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"));
}
}
@@ -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"));
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

@@ -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<<domain>> #FFF9C4
BorderColor<<domain>> #F9A825
FontColor<<domain>> #5D4037
FontStyle<<domain>> bold
}
skinparam package {
BackgroundColor<<application>> #C8E6C9
BorderColor<<application>> #388E3C
FontColor<<application>> #1B5E20
FontStyle<<application>> bold
}
skinparam package {
BackgroundColor<<infrastructure>> #BBDEFB
BorderColor<<infrastructure>> #1976D2
FontColor<<infrastructure>> #0D47A1
FontStyle<<infrastructure>> bold
}
' ─────────────────────────────────────────────────────────────────────────────
' DOMAIN LAYER (innermost)
' ─────────────────────────────────────────────────────────────────────────────
package "Domain Layer" <<domain>> {
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<Person>
+ findByFirstName(firstName : String) : Optional<Person>
+ findByLastName(lastName : String) : Optional<Person>
+ findAll() : Optional<List<Person>>
+ save(person : Person) : Person
+ deleteById(id : Long) : boolean
}
}
package "exception" {
class DomainException {
+ DomainException(message : String)
}
}
}
' ─────────────────────────────────────────────────────────────────────────────
' APPLICATION LAYER
' ─────────────────────────────────────────────────────────────────────────────
package "Application Layer" <<application>> {
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<PersonResponse>
}
}
package "dto" {
class SavePersonCommand <<record>> {
firstName : String
lastName : String
age : int
phoneNumber : String
email : String
address : String
categoryId : Long
categoryType : String
}
class PersonResponse <<record>> {
id : Long
firstName : String
lastName : String
age : int
phoneNumber : String
email : String
categoryId : Long
categoryType : String
}
}
}
' ─────────────────────────────────────────────────────────────────────────────
' INFRASTRUCTURE LAYER (outermost)
' ─────────────────────────────────────────────────────────────────────────────
package "Infrastructure Layer" <<infrastructure>> {
package "web" {
class PersonController {
- savePersonUseCase : SavePersonUseCase
- getPersonUseCase : GetPersonUseCase
+ getPerson(id : Long) : ResponseEntity<PersonResponse>
+ getAllPersons() : ResponseEntity<List<PersonResponse>>
+ savePerson(command : SavePersonCommand) : ResponseEntity<PersonResponse>
}
}
package "persistence" {
class PersonRepositoryAdapter {
- repository : SpringDataPersonRepository
+ findById(id : Long) : Optional<Person>
+ findByFirstName(firstName : String) : Optional<Person>
+ findByLastName(lastName : String) : Optional<Person>
+ findAll() : Optional<List<Person>>
+ save(person : Person) : Person
+ deleteById(id : Long) : boolean
}
interface SpringDataPersonRepository {
+ findByFirstName(firstName : String) : Optional<JpaPersonEntity>
+ findByLastName(lastName : String) : Optional<JpaPersonEntity>
}
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 : <<throws>>
Category ..> DomainException : <<throws>>
' ─────────────────────────────────────────────────────────────────────────────
' 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
<b>Layer dependency rule</b>
Infrastructure → Application → Domain
Outer layers depend on inner layers.
Domain has NO external dependencies.
----
<color:#F9A825>■</color> Domain Layer
<color:#388E3C>■</color> Application Layer
<color:#1976D2>■</color> Infrastructure Layer
endlegend
@enduml
@@ -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": []
}
]
}
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iluwatar</groupId>
<artifactId>onion-architecture</artifactId>
<version>1.26.0-SNAPSHOT</version>
</parent>
<artifactId>infrastructure</artifactId>
<name>Infrastructure</name>
<dependencies>
<dependency>
<groupId>com.iluwatar</groupId>
<artifactId>domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.iluwatar</groupId>
<artifactId>application</artifactId>
<version>${project.version}</version>
</dependency>
<!-- REST Controller -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA + Spring Data JPA-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<Person> findById(Long id) {
return repository.findById(id).map(this::mapToDomain);
}
@Override
public Optional<Person> findByFirstName(String firstName) {
return repository.findByFirstName(firstName).map(this::mapToDomain);
}
@Override
public Optional<Person> findByLastName(String lastName) {
return repository.findByLastName(lastName).map(this::mapToDomain);
}
@Override
public Optional<List<Person>> 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()));
}
}
@@ -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<JpaPersonEntity, Long> {
Optional<JpaPersonEntity> findByFirstName(String firstName);
Optional<JpaPersonEntity> findByLastName(String lastName);
}
@@ -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<PersonResponse> getPerson(@PathVariable("id") Long id) {
var person = getPersonUseCase.execute(id);
return ResponseEntity.ok(person);
}
@GetMapping("/persons")
public ResponseEntity<List<PersonResponse>> getAllPersons() {
var persons = getPersonUseCase.executeAll();
return ResponseEntity.ok(persons);
}
@PostMapping("/persons")
public ResponseEntity<PersonResponse> 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();
}
}
}
@@ -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
@@ -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');
@@ -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<JpaPersonEntity> 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);
}
}
}
@@ -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));
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iluwatar</groupId>
<artifactId>java-design-patterns</artifactId>
<version>1.26.0-SNAPSHOT</version>
</parent>
<artifactId>onion-architecture</artifactId>
<packaging>pom</packaging>
<properties>
<junit-jupiter.version>5.10.0</junit-jupiter.version>
<mockito.version>5.23.0</mockito.version>
<assertj.version>3.24.2</assertj.version>
</properties>
<modules>
<module>domain</module>
<module>application</module>
<module>infrastructure</module>
</modules>
</project>
+7 -1
View File
@@ -106,7 +106,7 @@
<module>converter</module>
<module>curiously-recurring-template-pattern</module>
<module>currying</module>
<module>dao-factory</module>
<module>dao-factory</module>
<module>data-access-object</module>
<module>data-bus</module>
<module>data-locality</module>
@@ -252,6 +252,7 @@
<module>backpressure</module>
<module>actor-model</module>
<module>rate-limiting-pattern</module>
<module>onion-architecture</module>
</modules>
<repositories>
<repository>
@@ -346,6 +347,11 @@
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>bson</artifactId>