name: 01-backend-ddd-development description: Backend development standards for Spring Boot 3.2+ with Java 21, Clean Architecture / DDD, and modern patterns (2026). Covers layered architecture, domain modeling, JPA repository patterns, security hardening, and testing. Use when implementing features, fixing bugs, or refactoring the maritime LMS backend.
Spring Boot 3.2+ Clean Architecture Standard (2026)
Stack: Java 21 + Spring Boot 3.2.6 + PostgreSQL 16 + Hibernate 6.4 Architecture: Modular Monolith with Clean Architecture / DDD Last Updated: February 2026
Architecture Overview
Layer Dependency Rule
Domain ← Application ← Infrastructure
(inner) (middle) (outer)
Domain: Pure Java. Zero framework imports.
Application: Domain ports only. No @Entity, no Spring Data, no HTTP.
Infrastructure: Implements ports. Contains Spring, JPA, REST controllers.
Module Structure
{module}/
├── domain/
│ ├── model/ # Aggregate roots, entities, enums
│ ├── repository/ # Port interfaces (e.g., CourseRepository)
│ ├── valueobject/ # Value objects (CourseCode, Email)
│ └── event/ # Domain events
├── application/
│ ├── usecase/ # Single-responsibility use cases
│ ├── dto/ # Command records, response records
│ └── port/ # Application-level ports (TokenService)
└── infrastructure/
├── persistence/
│ ├── entity/ # @Entity classes (*JpaEntity suffix)
│ ├── mapper/ # JpaEntity <-> Domain mappers
│ └── *Adapter.java # Port implementations
└── web/ # @RestController classes
Java 21 Modern Patterns
Records for DTOs (Immutable by Design)
// Command (input)
public record CreateCourseCommand(
@NotBlank String code,
@NotBlank @Size(max = 255) String title,
@Size(max = 5000) String description,
@NotNull UUID teacherId
) {}
// Response (output)
public record CourseResponse(
UUID id,
String code,
String title,
String description,
String status,
UUID teacherId,
boolean editable,
Instant createdAt
) {}
Sealed Interfaces for Type-Safe Domain Events
public sealed interface DomainEvent permits
CourseCreatedEvent,
CourseApprovedEvent,
CourseRejectedEvent,
UserRegisteredEvent {
Instant occurredAt();
String aggregateId();
}
public record CourseCreatedEvent(
UUID courseId,
String title,
UUID teacherId,
Instant occurredAt
) implements DomainEvent {
public String aggregateId() { return courseId.toString(); }
}
Pattern Matching (Java 21)
// switch expressions with pattern matching
public String getStatusLabel(CourseStatus status) {
return switch (status) {
case DRAFT -> "Draft";
case PENDING -> "Pending Review";
case APPROVED -> "Approved";
case REJECTED -> "Rejected";
case PUBLISHED -> "Published";
case ARCHIVED -> "Archived";
};
}
// instanceof pattern matching
if (exception instanceof EntityNotFoundException e) {
return ResponseEntity.notFound().build();
} else if (exception instanceof BusinessRuleException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
}
Text Blocks for SQL/Messages
@Query("""
SELECT e FROM EnrollmentJpaEntity e
WHERE e.classId = :classId
AND e.status = 'ACTIVE'
ORDER BY e.enrolledAt DESC
""")
List<EnrollmentJpaEntity> findActiveByClassId(@Param("classId") UUID classId);
Virtual Threads (Java 21)
# application.yml - Enable virtual threads
spring:
threads:
virtual:
enabled: true # All @Async and web threads use virtual threads
Domain Layer Patterns
Rich Domain Model (NOT Anemic)
// CORRECT: Domain model with business logic
public class Course extends BaseEntity<UUID> {
private CourseCode code;
private String title;
private CourseStatus status;
private List<Chapter> chapters = new ArrayList<>();
// Factory method with validation
public static Course create(CourseCode code, String title, String desc, UUID teacherId) {
Objects.requireNonNull(code, "Course code is required");
if (title == null || title.isBlank()) {
throw new ValidationException("Title cannot be blank");
}
return new Course(UUID.randomUUID(), code, title, desc, teacherId, CourseStatus.DRAFT);
}
// Business method (NOT a setter)
public void submitForApproval() {
ensureEditable();
if (chapters.isEmpty()) {
throw new BusinessRuleException("Course must have at least one chapter");
}
this.status = CourseStatus.PENDING;
}
public void approve(UUID reviewerId, String comment) {
if (status != CourseStatus.PENDING) {
throw new BusinessRuleException("Only pending courses can be approved");
}
this.status = CourseStatus.APPROVED;
this.reviewedById = reviewerId;
this.reviewComment = comment;
this.reviewedAt = Instant.now();
}
public boolean isEditable() {
return status == CourseStatus.DRAFT || status == CourseStatus.REJECTED;
}
private void ensureEditable() {
if (!isEditable()) {
throw new BusinessRuleException("Course is not editable in status: " + status);
}
}
}
// WRONG: Anemic model with public setters
public class BadCourse {
private String title;
public void setTitle(String title) { this.title = title; } // NO!
public void setStatus(CourseStatus s) { this.status = s; } // NO!
}
Value Objects
// Self-validating, immutable
public record Email(String value) {
public Email {
Objects.requireNonNull(value, "Email cannot be null");
value = value.trim().toLowerCase();
if (!value.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
throw new ValidationException("Invalid email format: " + value);
}
}
public static Email of(String value) {
return new Email(value);
}
public String getDomain() {
return value.substring(value.indexOf('@') + 1);
}
}
public record CourseCode(String value) {
public CourseCode {
Objects.requireNonNull(value, "Course code cannot be null");
if (value.isBlank()) throw new ValidationException("Course code cannot be blank");
}
public static CourseCode of(String value) {
return new CourseCode(value);
}
}
Domain Repository Port (Interface)
// Domain port - NO framework annotations, NO JPA types
public interface CourseRepository {
Course findById(UUID id);
Course save(Course course);
boolean existsByCode(CourseCode code);
List<Course> findByTeacherId(UUID teacherId);
void deleteById(UUID id);
}
Domain Events
// Interface in shared/domain/event/ (NOT infrastructure!)
public interface DomainEventPublisher {
void publish(DomainEvent event);
default void publishAll(Iterable<? extends DomainEvent> events) {
events.forEach(this::publish);
}
}
// Usage in use case
public CourseResponse execute(CreateCourseCommand cmd) {
var course = Course.create(...);
course = courseRepository.save(course);
eventPublisher.publish(new CourseCreatedEvent(course.getId(), course.getTitle()));
return toResponse(course);
}
Application Layer Patterns
Use Case (Single Responsibility)
@Component
@RequiredArgsConstructor
public class CreateAssignmentUseCaseV3 {
// ONLY domain ports - never JPA repos or Spring services
private final AssignmentRepository assignmentRepository;
public UUID execute(CreateAssignmentCommand cmd) {
var assignment = Assignment.create(
cmd.title(),
cmd.description(),
cmd.courseId(),
cmd.teacherId(),
cmd.type()
);
if (cmd.dueDate() != null) {
assignment.setDueDate(cmd.dueDate());
}
assignment = assignmentRepository.save(assignment);
return assignment.getId();
}
}
Application Port (For Infrastructure Services)
// Port in application layer
public interface TokenService {
String generateAccessToken(UUID userId, String email, String role);
String generateRefreshToken(UUID userId, String email, String role);
String extractEmail(String token);
boolean isTokenValid(String token);
}
// Adapter in infrastructure layer
@Component
public class TokenServiceAdapter implements TokenService {
private final JwtTokenAdapter jwtAdapter; // Infrastructure dependency OK here
@Override
public String generateAccessToken(UUID userId, String email, String role) {
return jwtAdapter.generateAccessToken(userId, email, role);
}
}
Infrastructure Layer Patterns
JPA Entity (CRITICAL RULE)
// JPA entity - ALWAYS suffix with JpaEntity
// ALWAYS in infrastructure/persistence/entity/
@Entity
@Table(name = "courses")
@Getter @Setter
@NoArgsConstructor
public class CourseJpaEntity {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String code;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
private CourseStatus status;
@Column(name = "teacher_id", nullable = false)
private UUID teacherId;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
// Audit fields
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}
JPA Repository
// CORRECT: Uses *JpaEntity class
@Repository
public interface CourseJpaRepository extends JpaRepository<CourseJpaEntity, UUID> {
@Query("""
SELECT c FROM CourseJpaEntity c
WHERE c.status = :status
ORDER BY c.createdAt DESC
""")
Page<CourseJpaEntity> findByStatus(@Param("status") CourseStatus status, Pageable pageable);
boolean existsByCode(String code);
List<CourseJpaEntity> findByTeacherId(UUID teacherId);
}
// WRONG: Uses domain model → causes "Not a managed type" error at startup
public interface BadRepository extends JpaRepository<Course, UUID> {} // NEVER!
Repository Adapter
@Component
@RequiredArgsConstructor
public class CourseRepositoryAdapter implements CourseRepository {
private final CourseJpaRepository jpaRepo;
private final CourseEntityMapper mapper;
@Override
public Course findById(UUID id) {
return jpaRepo.findById(id)
.map(mapper::toDomain)
.orElse(null);
}
@Override
public Course save(Course course) {
var entity = mapper.toEntity(course);
var saved = jpaRepo.save(entity);
return mapper.toDomain(saved);
}
@Override
public boolean existsByCode(CourseCode code) {
return jpaRepo.existsByCode(code.value());
}
}
Entity Mapper
@Component
public class CourseEntityMapper {
public Course toDomain(CourseJpaEntity entity) {
return Course.builder()
.id(entity.getId())
.code(CourseCode.of(entity.getCode()))
.title(entity.getTitle())
.description(entity.getDescription())
.status(entity.getStatus())
.teacherId(entity.getTeacherId())
.build();
}
public CourseJpaEntity toEntity(Course domain) {
var entity = new CourseJpaEntity();
entity.setId(domain.getId());
entity.setCode(domain.getCode().value());
entity.setTitle(domain.getTitle());
entity.setDescription(domain.getDescription());
entity.setStatus(domain.getStatus());
entity.setTeacherId(domain.getTeacherId());
return entity;
}
}
REST Controller
@RestController
@RequestMapping("/api/v3/assignments")
@RequiredArgsConstructor
public class AssignmentControllerV3 {
private final CreateAssignmentUseCaseV3 createAssignment;
private final UpdateAssignmentUseCaseV3 updateAssignment;
@PostMapping
@PreAuthorize("hasAnyRole('TEACHER', 'INSTRUCTOR', 'ADMIN')")
public ResponseEntity<?> create(@Valid @RequestBody CreateAssignmentCommand cmd) {
UUID id = createAssignment.execute(cmd);
return ResponseEntity.ok(ApiResponse.success("Assignment created", id));
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('TEACHER', 'INSTRUCTOR', 'ADMIN')")
public ResponseEntity<?> update(
@PathVariable UUID id,
@Valid @RequestBody UpdateAssignmentCommand cmd
) {
updateAssignment.execute(id, cmd);
return ResponseEntity.ok(ApiResponse.success("Assignment updated"));
}
}
Security Patterns (Spring Security 6.x)
SecurityFilterChain (Modern Style)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.httpStrictTransportSecurity(hsts -> hsts.maxAgeInSeconds(31536000))
.contentTypeOptions(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v3/auth/login", "/api/v3/auth/register").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v3/courses", "/api/v3/courses/**").permitAll()
.requestMatchers("/api/v3/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Rate Limiting
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final Map<String, Deque<Instant>> requestCounts = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS = 10;
private static final Duration WINDOW = Duration.ofMinutes(1);
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (isAuthEndpoint(req.getRequestURI())) {
String key = getClientIP(req);
if (isRateLimited(key)) {
res.setStatus(429);
return;
}
}
chain.doFilter(req, res);
}
}
CORS Configuration
@Bean
public CorsConfigurationSource corsConfigurationSource() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
env.getProperty("CORS_ALLOWED_ORIGINS", "http://localhost:4200").split(",")
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
Database Patterns
Flyway Migrations
-- V31__add_new_feature.sql
-- Always use IF NOT EXISTS for idempotency
CREATE TABLE IF NOT EXISTS new_feature (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_new_feature_name ON new_feature(name);
-- Constraints
ALTER TABLE new_feature
ADD CONSTRAINT IF NOT EXISTS fk_new_feature_course
FOREIGN KEY (course_id) REFERENCES courses(id);
Spring Data Pageable (DB-Level Pagination)
// CORRECT: DB-level pagination
@Query("SELECT e FROM CourseJpaEntity e WHERE e.status = :status")
Page<CourseJpaEntity> findByStatus(@Param("status") String status, Pageable pageable);
// Controller
@GetMapping
public ResponseEntity<?> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
var result = repository.findAll(pageable);
return ResponseEntity.ok(ApiResponse.success(result));
}
// WRONG: Manual subList pagination (loads ALL rows)
List<Course> all = repo.findAll();
List<Course> page = all.subList(offset, Math.min(offset + size, all.size())); // NO!
JSONB Columns (Hypersistence Utils)
@Entity
@Table(name = "lessons")
public class LessonJpaEntity {
@Type(JsonType.class)
@Column(name = "content_blocks", columnDefinition = "jsonb")
private List<ContentBlock> contentBlocks;
}
Exception Handling
Domain Exception Hierarchy
// Base exceptions (shared/exception/)
public class EntityNotFoundException extends RuntimeException {
public EntityNotFoundException(String entity, Object id) {
super(entity + " not found with id: " + id);
}
}
public class BusinessRuleException extends RuntimeException {
public BusinessRuleException(String message) {
super(message);
}
}
public class ValidationException extends RuntimeException { ... }
public class UnauthorizedException extends RuntimeException { ... }
Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<?> handleNotFound(EntityNotFoundException e) {
return ResponseEntity.status(404)
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<?> handleBusinessRule(BusinessRuleException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException e) {
var errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(ApiResponse.error("Validation failed", errors));
}
}
Testing Patterns
Domain Model Tests (Pure Logic, Zero Mocks)
@DisplayName("Course Domain Model Tests")
class CourseTest {
@Test
@DisplayName("Should create course in DRAFT status")
void shouldCreateInDraftStatus() {
var course = Course.create(
CourseCode.of("CS101"), "Intro", "Desc", UUID.randomUUID());
assertThat(course.getStatus()).isEqualTo(CourseStatus.DRAFT);
assertThat(course.isEditable()).isTrue();
}
@Test
@DisplayName("Should throw when submitting without chapters")
void shouldThrowWhenNoChapters() {
var course = Course.create(
CourseCode.of("CS101"), "Intro", "Desc", UUID.randomUUID());
assertThatThrownBy(() -> course.submitForApproval())
.isInstanceOf(BusinessRuleException.class);
}
}
Use Case Tests (Mock Ports)
@ExtendWith(MockitoExtension.class)
@DisplayName("CreateCourseUseCase Tests")
class CreateCourseUseCaseTest {
@Mock private CourseRepository courseRepository;
@Mock private DomainEventPublisher eventPublisher;
@InjectMocks private CreateCourseUseCase useCase;
@Test
@DisplayName("Should create course successfully")
void shouldCreateCourse() {
// Given
when(courseRepository.existsByCode(any())).thenReturn(false);
when(courseRepository.save(any())).thenAnswer(i -> i.getArgument(0));
var cmd = new CreateCourseCommand("CS101", "Title", "Desc", UUID.randomUUID());
// When
var response = useCase.execute(cmd);
// Then
assertThat(response.code()).isEqualTo("CS101");
assertThat(response.status()).isEqualTo("DRAFT");
verify(courseRepository).save(any(Course.class));
}
}
Integration Tests (Spring Boot)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class CourseApiIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Test
void shouldCreateCourseViaApi() throws Exception {
var cmd = Map.of(
"code", "TEST001",
"title", "Test Course",
"description", "Description",
"teacherId", UUID.randomUUID().toString()
);
mockMvc.perform(post("/api/v3/authoring/courses")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(cmd))
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_TEACHER"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
}
Caching (Caffeine)
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
var caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return caffeineCacheManager;
}
}
// Usage
@Cacheable(value = "courses", key = "#id")
public Course findById(UUID id) { ... }
@CacheEvict(value = "courses", key = "#course.id")
public Course save(Course course) { ... }
File Storage (Cloudflare R2 / S3-Compatible)
@Service
public class R2StorageService {
private final S3Client s3Client;
private final String bucketName;
public String upload(MultipartFile file, String folder) {
// Validate MIME type
String contentType = file.getContentType();
if (!ALLOWED_MIME_TYPES.contains(contentType)) {
throw new ValidationException("File type not allowed: " + contentType);
}
// Sanitize filename
String safeFilename = sanitizeFilename(file.getOriginalFilename());
String key = folder + "/" + UUID.randomUUID() + "_" + safeFilename;
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(contentType)
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
return key;
}
}
Configuration Best Practices
Profile-Specific Configuration
# application.yml (shared)
spring:
jpa:
hibernate:
ddl-auto: validate # NEVER use create/update in production
properties:
hibernate:
format_sql: false
open-in-view: false # Prevent lazy loading in controllers
# application-dev.yml
spring:
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
flyway:
enabled: true
# application-prod.yml
spring:
jpa:
properties:
hibernate:
show_sql: false
flyway:
enabled: true
Bean Naming (Avoid Collisions)
// When two modules have same-named beans
@Component("assessment_CourseRepository")
public class CourseRepositoryAdapter implements CourseRepository { ... }
@Component("courseAuthoring_CourseRepository")
public class CourseRepositoryImpl implements CourseRepository { ... }
Anti-Patterns to Avoid
| Anti-Pattern | Correct Alternative |
|---|---|
System.out.println() |
Use SLF4J logger: log.info(...) |
RuntimeException("msg") |
EntityNotFoundException, BusinessRuleException |
| JPA repo with domain model | JPA repo with *JpaEntity class |
| Use case importing JPA entity | Use case importing domain port only |
| Public setters on domain model | Business methods (approve, publish, etc.) |
| Manual subList pagination | Spring Data Pageable |
CORS wildcard * |
Specific origins from env var |
| Empty catch blocks | Log the error or throw specific exception |
| Hardcoded secrets | Environment variables |
@Autowired field injection |
Constructor injection via @RequiredArgsConstructor |
Checklist for New Features
[ ] Domain model in {module}/domain/model/ (no @Entity)
[ ] Repository port in {module}/domain/repository/
[ ] Use case in {module}/application/usecase/ (no infra imports)
[ ] Command/Response DTOs with Jakarta validation
[ ] JPA entity with *JpaEntity suffix
[ ] JPA repository uses JpaEntity (NOT domain model)
[ ] Mapper: JpaEntity <-> Domain
[ ] Adapter implements domain port
[ ] Controller with @Valid + @PreAuthorize
[ ] Flyway migration for schema changes
[ ] Domain model tests (pure logic)
[ ] Use case tests (mock repos)
[ ] All 202+ existing tests still pass