Development guidelines for Spring Boot 3.2 applications with dual-interface design (web + REST), custom validation, and comprehensive testing patterns.
Expert guidance for developing Spring Boot 3.2+ applications with layered architecture, dual-interface design (Thymeleaf web + REST API), custom validation system, and multi-framework testing approach (JUnit 5, TestNG, Selenium).
Organize code into clear layers with domain-specific concerns:
```
com.yourapp/
├── entity/ # JPA entities with custom validation
├── service/ # @Transactional business logic
├── controller/ # Separate web + REST controllers
├── validation/ # Custom annotations and validators
├── exception/ # Domain-specific exceptions
├── config/ # Application configuration
└── e2e/ # End-to-end test suite
```
**Implementation checklist:**
Implement domain-specific validation beyond standard Bean Validation:
**Custom annotation example:**
```java
@Constraint(validatedBy = PositiveAmountValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface PositiveAmount {
String message() default "Amount must be greater than zero";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
```
**Entity validation pattern:**
```java
@Entity
public class Expense {
@PositiveAmount(message = "Amount must be greater than zero")
@DecimalMax(value = "999999.99", message = "Amount cannot exceed 999,999.99")
private BigDecimal amount;
@NotNull(message = "Category is required")
private Category category;
}
```
**When to use custom validation:**
Apply consistent transaction boundaries with read-only optimization:
```java
@Service
public class ExpenseService {
@Transactional(readOnly = true)
public List<Expense> getExpensesByCategory(Long categoryId) {
// Read-only queries benefit from optimization
return expenseRepository.findByCategoryId(categoryId);
}
@Transactional
public Expense createExpense(Expense expense) {
// Write operations need full transaction
validateBusinessRules(expense);
return expenseRepository.save(expense);
}
}
```
**Transaction guidelines:**
Maintain clear separation between web UI and REST API:
**Web controller (Thymeleaf):**
```java
@Controller
@RequestMapping("/expenses")
public class ExpenseWebController {
@GetMapping
public String listExpenses(Model model) {
model.addAttribute("expenses", expenseService.findAll());
return "expenses/list" + templateConfig.getTemplateSuffix();
}
@PostMapping
public String createExpense(@Valid Expense expense,
BindingResult result,
Model model) {
if (result.hasErrors()) {
return "expenses/form" + templateConfig.getTemplateSuffix();
}
expenseService.save(expense);
return "redirect:/expenses";
}
}
```
**REST controller (JSON API):**
```java
@RestController
@RequestMapping("/api/expenses")
public class ExpenseRestController {
@GetMapping
public ResponseEntity<Map<String, Object>> listExpenses() {
return ResponseEntity.ok(Map.of(
"expenses", expenseService.findAll(),
"timestamp", LocalDateTime.now()
));
}
@PostMapping
public ResponseEntity<Map<String, Object>> createExpense(
@Valid @RequestBody Expense expense) {
Expense saved = expenseService.save(expense);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("expense", saved, "success", true));
}
}
```
**Separation benefits:**
Support multiple UI layouts with runtime configuration:
**Configuration class:**
```java
@Configuration
public class TemplateConfig {
@Value("${app.template.use-new-layout:false}")
private boolean useNewLayout;
public String getTemplateSuffix() {
return useNewLayout ? "-new" : "-classic";
}
public boolean isUseNewLayout() {
return useNewLayout;
}
}
```
**Template organization:**
```
templates/
├── expenses/
│ ├── list-classic.html
│ ├── list-new.html
│ ├── form-classic.html
│ └── form-new.html
```
**application.properties:**
```properties
app.template.use-new-layout=true
```
**Usage in controllers:**
```java
return "expenses/list" + templateConfig.getTemplateSuffix();
// Returns "expenses/list-classic" or "expenses/list-new"
```
Define domain-specific exceptions with meaningful context:
```java
public class ValidationException extends RuntimeException {
private final Map<String, String> fieldErrors;
public ValidationException(String message, Map<String, String> errors) {
super(message);
this.fieldErrors = errors;
}
}
public class EntityNotFoundException extends RuntimeException {
private final String entityType;
private final Object entityId;
public EntityNotFoundException(String entityType, Object id) {
super(String.format("%s not found with id: %s", entityType, id));
this.entityType = entityType;
this.entityId = id;
}
}
```
**Controller exception handlers:**
```java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ValidationException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
ValidationException ex) {
return ResponseEntity.badRequest().body(Map.of(
"error", ex.getMessage(),
"fieldErrors", ex.getFieldErrors()
));
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(
EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
}
```
Combine testing frameworks for comprehensive coverage:
**Unit tests (JUnit 5 + Mockito):**
```java
@ExtendWith(MockitoExtension.class)
class ExpenseServiceTest {
@Mock
private ExpenseRepository repository;
@InjectMocks
private ExpenseService service;
@Test
void shouldReturnExpensesByCategory() {
// Arrange
Long categoryId = 1L;
when(repository.findByCategoryId(categoryId))
.thenReturn(List.of(new Expense()));
// Act
List<Expense> result = service.getExpensesByCategory(categoryId);
// Assert
assertThat(result).hasSize(1);
}
}
```
**E2E tests (TestNG + Selenium + Page Object Model):**
```java
public abstract class BasePage {
protected WebDriver driver;
public BasePage(WebDriver driver) {
this.driver = driver;
PageFactory.initElements(driver, this);
}
protected void navigateTo(String path) {
driver.get("http://localhost:8080" + path);
}
}
public class ExpenseListPage extends BasePage {
@FindBy(id = "add-expense-btn")
private WebElement addButton;
@FindBy(css = ".expense-row")
private List<WebElement> expenseRows;
public ExpenseFormPage clickAddExpense() {
addButton.click();
return new ExpenseFormPage(driver);
}
public int getExpenseCount() {
return expenseRows.size();
}
}
public class ExpenseSmokeTest extends BaseE2ETest {
@Test(groups = {"smoke", "e2e"})
public void shouldCreateNewExpense() {
ExpenseListPage listPage = new ExpenseListPage(driver);
listPage.navigateTo("/expenses");
ExpenseFormPage formPage = listPage.clickAddExpense();
formPage.fillAmount("99.99")
.fillDescription("Test expense")
.selectCategory("Food")
.submit();
listPage = new ExpenseListPage(driver);
assertThat(listPage.getExpenseCount()).isGreaterThan(0);
}
}
```
**TestNG suite configuration (testng.xml):**
```xml
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Expense Tracker E2E Suite" parallel="tests" thread-count="2">
<test name="Smoke Tests">
<groups>
<run>
<include name="smoke"/>
</run>
</groups>
<packages>
<package name="com.expensetracker.e2e"/>
</packages>
</test>
<test name="Regression Tests">
<groups>
<run>
<include name="regression"/>
</run>
</groups>
<packages>
<package name="com.expensetracker.e2e"/>
</packages>
</test>
</suite>
```
```bash
mvn spring-boot:run # Start application (port 8080)
mvn clean install # Build and run all tests
mvn test # Run all tests (unit + integration)
mvn test -Dtest=*SmokeTest* # Run E2E smoke tests only
mvn test -DsuiteXmlFile=src/test/resources/testng/testng.xml # TestNG suite
```
**Test data builders:**
```java
public class ExpenseBuilder {
private BigDecimal amount = BigDecimal.valueOf(50.00);
private String description = "Test expense";
private Category category;
public ExpenseBuilder withAmount(BigDecimal amount) {
this.amount = amount;
return this;
}
public ExpenseBuilder withDescription(String description) {
this.description = description;
return this;
}
public Expense build() {
Expense expense = new Expense();
expense.setAmount(amount);
expense.setDescription(description);
expense.setCategory(category);
return expense;
}
}
```
**Selenium screenshot capture on failure:**
```java
@AfterMethod
public void captureScreenshotOnFailure(ITestResult result) {
if (result.getStatus() == ITestResult.FAILURE) {
File screenshot = ((TakesScreenshot) driver)
.getScreenshotAs(OutputType.FILE);
String path = "target/screenshots/" + result.getName() + ".png";
FileUtils.copyFile(screenshot, new File(path));
}
}
```
Before submitting code, verify:
1. **Mixing web and REST controllers:** Keep them separate for clean separation of concerns
2. **Missing `readOnly = true`:** Query methods benefit from read-only transactions
3. **Validation in wrong layer:** Entity validation for constraints, service validation for business rules
4. **Hardcoded template names:** Always append `templateConfig.getTemplateSuffix()`
5. **Direct Selenium locators in tests:** Use Page Object Model to isolate locators
6. **Generic exceptions:** Create domain-specific exceptions with context
7. **Missing test groups:** Tag TestNG tests with `groups = {"smoke", "regression", "e2e"}`
When adding new features:
1. **Start with entity:** Define entity with custom validation annotations
2. **Create repository:** Extend `JpaRepository` with custom query methods if needed
3. **Implement service:** Add business logic with transaction boundaries
4. **Build controllers:** Create both web and REST controllers
5. **Design templates:** Create both `-classic` and `-new` template variants
6. **Write tests:** Unit tests for service, E2E tests with Page Object Model
7. **Update configuration:** Add any new properties to `application.properties`
When working with this architecture, maintain the separation between web and API concerns, leverage custom validation for domain rules, and ensure comprehensive test coverage across unit and E2E layers.
Leave a review
No reviews yet. Be the first to review this skill!
# Download SKILL.md from killerskills.ai/api/skills/java-spring-boot-web-app-development/raw