This document outlines recommended Spring Boot development practices with concrete examples from the meetup4j-modulith-tomato project.
Follow a domain-driven, modular architecture where packages are organized by business modules rather than technical layers.
dev.sivalabs.meetup4j/
├── shared/ # Cross-cutting concerns
│ ├── BaseEntity.java
│ ├── DomainException.java
│ ├── ResourceNotFoundException.java
│ └── SpringEventPublisher.java
│
├── events/ # Events module (bounded context)
│ ├── domain/ # Domain logic
│ │ ├── models/ # ViewModels for read operations
│ │ │ └── EventVM.java
│ │ ├── vo/ # Value Objects
│ │ │ ├── EventId.java
│ │ │ ├── EventCode.java
│ │ │ ├── EventDetails.java
│ │ │ ├── Schedule.java
│ │ │ ├── TicketPrice.java
│ │ │ ├── Capacity.java
│ │ │ └── EventLocation.java
│ │ ├── events/ # Domain events
│ │ │ ├── EventCreated.java
│ │ │ ├── EventPublished.java
│ │ │ └── EventCancelled.java
│ │ ├── EventEntity.java # Aggregate root
│ │ ├── EventRepository.java # Repository interface
│ │ ├── EventService.java # Write operations
│ │ ├── EventQueryService.java # Read operations
│ │ ├── EventMapper.java # Domain to ViewModel mapper
│ │ ├── CreateEventCmd.java # Command
│ │ ├── PublishEventCmd.java
│ │ └── InvalidEventCreationException.java
│ ├── rest/ # REST API layer
│ │ ├── converters/ # Type converters
│ │ │ └── StringToEventCodeConverter.java
│ │ ├── EventsController.java
│ │ ├── CreateEventRequest.java # HTTP Request DTO
│ │ └── CreateEventResponse.java # HTTP Response DTO
│ └── EventsAPI.java # Module's public API (facade)
│
├── registrations/ # Registrations module
│ ├── domain/
│ │ ├── vo/
│ │ │ ├── RegistrationId.java
│ │ │ ├── RegistrationCode.java
│ │ │ └── Email.java
│ │ ├── EventRegistrationEntity.java
│ │ ├── RegistrationRepository.java
│ │ ├── EventRegistrationService.java
│ │ ├── EventRegistrationQueryService.java
│ │ └── RegisterAttendeeCmd.java
│ └── rest/
│ ├── converters/
│ ├── EventRegistrationController.java
│ └── EventRegistrationRequest.java
│
└── config/
└── GlobalExceptionHandler.java
| Type | Convention | Example |
|---|---|---|
| Entities | *Entity |
EventEntity, EventRegistrationEntity |
| Value Objects | Domain name (record) | Email, EventCode, EventId |
| Commands | *Cmd |
CreateEventCmd, PublishEventCmd |
| ViewModels | *VM |
EventVM, RegistrationVM |
| HTTP Request | *Request |
CreateEventRequest, EventRegistrationRequest |
| HTTP Response | *Response |
CreateEventResponse, EventRegistrationResponse |
| Repositories | *Repository |
EventRepository, RegistrationRepository |
| Services (Write) | *Service |
EventService, EventRegistrationService |
| Services (Read) | *QueryService |
EventQueryService, EventRegistrationQueryService |
| Domain Exceptions | *Exception |
InvalidEventCreationException, EventCancellationException |
| Module API | *API |
EventsAPI |
Value Objects represent domain concepts as immutable records with built-in validations. They ensure that invalid data cannot exist in your domain model.
- Use Java records for immutability
- Validate in the compact constructor
- Use Jackson annotations for JSON marshalling/unmarshalling
- Throw
IllegalArgumentExceptionfor invalid data
File: registrations/domain/vo/Email.java
package dev.sivalabs.meetup4j.registrations.domain.vo;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Objects;
import java.util.regex.Pattern;
public record Email(@JsonValue String value) {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
@JsonCreator
public Email {
AssertUtil.requireNotNull(value, "Email cannot be null");
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid email format: " + value);
}
}
public static Email of(String value) {
return new Email(value);
}
}Usage in JSON:
{
"attendeeEmail": "user@example.com"
}Jackson automatically deserializes the string "user@example.com" to Email using @JsonCreator and serializes back using @JsonValue.
File: events/domain/vo/EventCode.java
package dev.sivalabs.meetup4j.events.domain.vo;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import dev.sivalabs.meetup4j.shared.TSIDUtil;
public record EventCode(@JsonValue String code) {
@JsonCreator
public EventCode {
if (code == null || code.trim().isEmpty()) {
throw new IllegalArgumentException("Event code cannot be null");
}
}
public static EventCode of(String code) {
return new EventCode(code);
}
public static EventCode generate() {
return new EventCode(TSIDUtil.generateTsidString());
}
}Benefits:
@JsonValue: Serializes as a plain string instead of{"code": "ABC123"}@JsonCreator: Deserializes from a plain string- Factory methods for creation and generation
File: events/domain/vo/EventDetails.java
package dev.sivalabs.meetup4j.events.domain.vo;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import static dev.sivalabs.meetup4j.shared.AssertUtil.requireNotNull;
public record EventDetails(
@NotBlank(message = "Title is required")
@Size(min = 3, max = 200, message = "Title must be between 3 and 200 characters")
String title,
@NotBlank(message = "Description is required")
@Size(max = 10000, message = "Description cannot exceed 10000 characters")
String description,
@Size(max = 500, message = "Image URL cannot exceed 500 characters")
@Pattern(regexp = "^https?://.*", message = "Image URL must be a valid HTTP/HTTPS URL")
String imageUrl) {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public EventDetails(
@JsonProperty("title") String title,
@JsonProperty("description") String description,
@JsonProperty("imageUrl") String imageUrl
) {
this.title = requireNotNull(title, "title cannot be null");
this.description = requireNotNull(description, "description cannot be null");
this.imageUrl = imageUrl;
}
public static EventDetails of(String title, String description, String imageUrl) {
return new EventDetails(title, description, imageUrl);
}
}Key Features:
- Jakarta Bean Validation annotations (
@NotBlank,@Size,@Pattern) - Explicit
@JsonCreatorwith@JsonPropertyfor property-based deserialization - Factory method for convenience
Entities are aggregate roots with rich domain logic. Use application-generated IDs (UUID/TSID) and embed Value Objects.
- Use TSID or UUID for primary keys
- Embed Value Objects with
@Embeddedand@AttributeOverrides - Create a protected no-arg constructor for JPA
- Create a public constructor with all required fields
- Add domain methods that operate on entity state
- Use optimistic locking with
@Version - Extend
BaseEntityfor audit fields - Validate state and throw exceptions for invalid operations
File: events/domain/EventEntity.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.events.domain.vo.*;
import dev.sivalabs.meetup4j.shared.BaseEntity;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "events")
class EventEntity extends BaseEntity {
@EmbeddedId
@AttributeOverride(name = "id", column = @Column(name = "id", nullable = false))
private EventId id;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "code", nullable = false, unique = true))
private EventCode code;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "title", column = @Column(name = "title", nullable = false)),
@AttributeOverride(name = "description", column = @Column(name = "description")),
@AttributeOverride(name = "imageUrl", column = @Column(name = "image_url"))
})
private EventDetails details;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false)
private EventType type;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "capacity"))
private Capacity capacity;
//.. other fields
@Version
private int version;
// Protected constructor for JPA
protected EventEntity() {}
// Constructor with all required fields
public EventEntity(EventId id,
EventCode code,
EventDetails details,
Schedule schedule,
EventType type,
//...
EventLocation location) {
this.id = id;
this.code = code;
this.details = details;
this.schedule = schedule;
this.type = type;
//...
this.location = location;
}
// Factory method for creating new entities
public static EventEntity createDraft(
EventDetails details,
Schedule schedule,
EventType type,
TicketPrice ticketPrice,
Capacity capacity,
EventLocation location) {
return new EventEntity(
EventId.generate(),
EventCode.generate(),
details,
schedule,
type,
EventStatus.DRAFT,
ticketPrice,
capacity,
location);
}
// Domain logic methods
public boolean hasFreeSeats() {
return capacity == null || capacity.value() == null || capacity.value() > registrationsCount;
}
public boolean isPublished() {
return status == EventStatus.PUBLISHED;
}
public boolean isCancelled() {
return status == EventStatus.CANCELLED;
}
public boolean isStarted() {
return schedule.startDatetime().isBefore(Instant.now());
}
public boolean publish() {
if (this.isPublished()) {
return false;
}
this.status = EventStatus.PUBLISHED;
return true;
}
public boolean cancel() {
if (this.isStarted()) {
throw new EventCancellationException("Cannot cancel events that have already started");
}
if (this.isCancelled()) {
return false;
}
this.status = EventStatus.CANCELLED;
return true;
}
public EventEntity updateRegistrationsCount(int registrationsCount) {
this.registrationsCount = registrationsCount;
return this;
}
// Getters
}To use TSID, add the following dependency:
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-71</artifactId>
<version>3.14.1</version>
</dependency>Now you can use TSID to generate IDs as follows:
import io.hypersistence.tsid.TSID;
public class TSIDUtil {
private TSIDUtil() {}
public static String generateTsidString() {
return TSID.Factory.getTsid().toString();
}
public static Long generateTsidLong() {
return TSID.Factory.getTsid().toLong();
}
}File: shared/BaseEntity.java
package dev.sivalabs.meetup4j.shared;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@Column(name = "created_at", nullable = false, updatable = false)
@CreatedDate
protected Instant createdAt;
@Column(name = "updated_at", nullable = false)
@LastModifiedDate
protected Instant updatedAt;
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}Enable JPA Auditing in your application configuration:
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}Repositories provide collection-like interfaces for aggregate roots with meaningful, business-focused method names.
- Create repositories only for aggregate roots
- Use
@Querywith JPQL for custom queries - Prefer meaningful method names over long Spring Data JPA finder methods
- Use default methods for convenience operations
- Use constructor expressions or Projections for read operations
- Return domain objects from write operations, ViewModels from read operations
File: events/domain/EventRepository.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import dev.sivalabs.meetup4j.events.domain.vo.EventId;
import dev.sivalabs.meetup4j.shared.ResourceNotFoundException;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
interface EventRepository extends JpaRepository<EventEntity, EventId> {
@Query("""
SELECT e FROM EventEntity e
WHERE e.status = dev.sivalabs.meetup4j.events.domain.EventStatus.PUBLISHED
AND e.schedule.startDatetime > :now
ORDER BY e.schedule.startDatetime ASC
""")
List<EventEntity> findUpcomingEvents(@Param("now") Instant now);
@Query("""
SELECT e FROM EventEntity e
WHERE e.code = :code
""")
Optional<EventEntity> findByCode(@Param("code") EventCode code);
// Convenience methods using default interface methods
default EventEntity getEventById(EventId eventId) {
return this.findById(eventId)
.orElseThrow(() -> new ResourceNotFoundException("Event not found with id: " + eventId));
}
default EventEntity getByCode(EventCode eventCode) {
return this.findByCode(eventCode)
.orElseThrow(() -> new ResourceNotFoundException("Event not found with code: " + eventCode));
}
}- Explicit queries: Clear JPQL makes intent obvious
- Type safety: Uses Value Objects (
EventId,EventCode) - Convenience methods:
getEventById()andgetByCode()throw exceptions if not found - Business language: Method names reflect domain concepts
Separate write operations (commands) from read operations (queries) following CQRS principles.
-
Write Services (
*Service):- Annotated with
@Transactional - Accept Commands as input
- Return Value Objects or void
- Perform state changes
- Publish domain events
- Annotated with
-
Read Services (
*QueryService):- Annotated with
@Transactional(readOnly = true) - Accept Value Objects as input
- Return ViewModels (with primitives)
- No state changes
- Optimized for read performance
- Annotated with
File: events/domain/EventService.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.events.domain.events.EventCancelled;
import dev.sivalabs.meetup4j.events.domain.events.EventCreated;
import dev.sivalabs.meetup4j.events.domain.events.EventPublished;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import dev.sivalabs.meetup4j.events.domain.vo.EventId;
import dev.sivalabs.meetup4j.shared.SpringEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class EventService {
private final EventRepository eventRepository;
private final SpringEventPublisher eventPublisher;
EventService(EventRepository eventRepository, SpringEventPublisher eventPublisher) {
this.eventRepository = eventRepository;
this.eventPublisher = eventPublisher;
}
public EventCode createEvent(CreateEventCmd cmd) {
var event = EventEntity.createDraft(
cmd.details(),
cmd.schedule(),
cmd.type(),
cmd.ticketPrice(),
cmd.capacity(),
cmd.location()
);
eventRepository.save(event);
eventPublisher.publish(new EventCreated(
event.getCode().code(),
event.getDetails().title(),
event.getDetails().description()
));
return event.getCode();
}
public void cancelEvent(CancelEventCmd cmd) {
EventEntity event = eventRepository.getByCode(cmd.eventCode());
if(event.cancel()) {
eventRepository.save(event);
eventPublisher.publish(new EventCancelled(
event.getCode().code(),
event.getDetails().title(),
event.getDetails().description()
));
}
}
//...
//...
}File: events/domain/EventQueryService.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.events.domain.models.EventVM;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import dev.sivalabs.meetup4j.events.domain.vo.EventId;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class EventQueryService {
private final EventRepository eventRepository;
private final EventMapper eventMapper;
EventQueryService(EventRepository eventRepository, EventMapper eventMapper) {
this.eventRepository = eventRepository;
this.eventMapper = eventMapper;
}
public List<EventVM> getUpcomingEvents() {
return eventRepository.findUpcomingEvents(Instant.now())
.stream().map(eventMapper::toEventVM).toList();
}
public EventVM getByCode(EventCode eventCode) {
var event = eventRepository.getByCode(eventCode);
return eventMapper.toEventVM(event);
}
//...
}ViewModel Example:
public record EventVM(
Long id,
String code,
String title,
String description,
Instant startDatetime,
Instant endDatetime,
//...
//...
String venue,
String virtualLink,
int registeredUsersCount) {}Mapper Example:
@Component
class EventMapper {
EventVM toEventVM(EventEntity event) {
return new EventVM(
event.getId().id(),
event.getCode().code(),
event.getDetails().title(),
event.getDetails().description(),
//...
event.getLocation().virtualLink(),
event.getRegistrationsCount()
);
}
}The REST layer handles HTTP concerns, converting between HTTP DTOs and domain objects.
- Use converters to bind
@PathVariableand@RequestParamto Value Objects - Use Jackson for
@RequestBodybinding to Request Objects with Value Object properties - Use
@JsonUnwrappedto map flattened JSON to nested objects - Validate with
@Validannotation - Return appropriate HTTP status codes
- Delegate to services for business logic
File: events/rest/EventsController.java
package dev.sivalabs.meetup4j.events.rest;
import dev.sivalabs.meetup4j.events.domain.*;
import dev.sivalabs.meetup4j.events.domain.models.EventVM;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static org.springframework.http.HttpStatus.CREATED;
@RestController
@RequestMapping("/api/events")
class EventsController {
private final EventService eventService;
private final EventQueryService eventQueryService;
EventsController(EventService eventService, EventQueryService eventQueryService) {
this.eventService = eventService;
this.eventQueryService = eventQueryService;
}
@GetMapping("")
EventsResponse findEvents() {
var events = eventQueryService.getUpcomingEvents();
return new EventsResponse(events);
}
@GetMapping("/all")
EventsResponse findAllEvents() {
List<EventVM> events = eventQueryService.findAllEvents();
return new EventsResponse(events);
}
@GetMapping("/{eventCode}")
ResponseEntity<EventVM> findEventByCode(@PathVariable EventCode eventCode) {
var event = eventQueryService.getByCode(eventCode);
return ResponseEntity.ok(event);
}
@PostMapping
ResponseEntity<CreateEventResponse> createEvent(
@RequestBody @Valid CreateEventRequest request) {
var cmd = new CreateEventCmd(
request.details(),
request.schedule(),
request.type(),
request.ticketPrice(),
request.capacity(),
request.location()
);
EventCode eventCode = eventService.createEvent(cmd);
return ResponseEntity.status(CREATED).body(new CreateEventResponse(eventCode.code()));
}
@PatchMapping("/{eventCode}/publish")
ResponseEntity<Void> publishEvent(@PathVariable EventCode eventCode) {
var cmd = new PublishEventCmd(eventCode);
eventService.publishEvent(cmd);
return ResponseEntity.ok().build();
}
}File: events/rest/CreateEventRequest.java
package dev.sivalabs.meetup4j.events.rest;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import dev.sivalabs.meetup4j.events.domain.EventType;
import dev.sivalabs.meetup4j.events.domain.vo.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
public record CreateEventRequest(
@JsonUnwrapped
@Valid
EventDetails details,
@JsonUnwrapped
@Valid
Schedule schedule,
@NotNull(message = "Event type is required")
EventType type,
//...
@Valid TicketPrice ticketPrice
) {
}Sample JSON Request:
{
"title": "Spring Boot Workshop",
"description": "Learn Spring Boot best practices",
"imageUrl": "https://example.com/image.jpg",
"startDatetime": "2024-12-01T10:00:00Z",
"endDatetime": "2024-12-01T17:00:00Z",
"type": "OFFLINE",
"ticketPrice": 50.00,
"capacity": 100,
"venue": "Tech Hub Building A",
"virtualLink": null
}Notice how @JsonUnwrapped flattens the nested EventDetails, Schedule, and EventLocation properties into the top level of the JSON.
File: events/rest/converters/StringToEventCodeConverter.java
package dev.sivalabs.meetup4j.events.rest.converters;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
@Component
public class StringToEventCodeConverter implements Converter<String, EventCode> {
@Override
public EventCode convert(String source) {
return new EventCode(source);
}
}This allows Spring MVC to automatically convert path variables like /{eventCode} from String to EventCode:
@GetMapping("/{eventCode}")
ResponseEntity<EventVM> findEventByCode(@PathVariable EventCode eventCode) {
// eventCode is already an EventCode object, not a String
}Expose a facade (public API) from each module containing only the methods required by other modules.
- Use a
*APIservice class - Mark it with
@Service - Delegate to internal services
- Only expose what other modules need
- Keep internal implementation details hidden
File: events/EventsAPI.java
package dev.sivalabs.meetup4j.events;
import dev.sivalabs.meetup4j.events.domain.EventQueryService;
import dev.sivalabs.meetup4j.events.domain.EventService;
import dev.sivalabs.meetup4j.events.domain.models.EventVM;
import dev.sivalabs.meetup4j.events.domain.vo.EventCode;
import dev.sivalabs.meetup4j.events.domain.vo.EventId;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class EventsAPI {
private final EventService eventService;
private final EventQueryService eventQueryService;
public EventsAPI(EventService eventService, EventQueryService eventQueryService) {
this.eventService = eventService;
this.eventQueryService = eventQueryService;
}
public List<EventVM> getEventsByIds(List<EventId> eventIds) {
return eventQueryService.getEventsByIds(eventIds);
}
public void reserveSlotForEvent(EventId eventId) {
eventService.reserveSlotForEvent(eventId);
}
//...
}Usage in another module (registrations):
@Service
@Transactional
public class EventRegistrationService {
private final EventsAPI eventsAPI; // Injected from events module
public RegistrationCode registerForEvent(RegisterAttendeeCmd cmd) {
// Call public API instead of internal services
eventsAPI.reserveSlotForEvent(eventId);
// ... rest of registration logic
}
}Create domain-specific exceptions that represent business rule violations.
- Extend a base
DomainException - Use descriptive names that reflect business concepts
- Include meaningful error messages
- Throw from domain entities and services
File: shared/DomainException.java
package dev.sivalabs.meetup4j.shared;
public class DomainException extends RuntimeException {
public DomainException(String message) {
super(message);
}
}File: shared/ResourceNotFoundException.java
package dev.sivalabs.meetup4j.shared;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}File: events/domain/InvalidEventCreationException.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.shared.DomainException;
public class InvalidEventCreationException extends DomainException {
public InvalidEventCreationException(String message) {
super(message);
}
}File: events/domain/EventCancellationException.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.shared.DomainException;
public class EventCancellationException extends DomainException {
public EventCancellationException(String message) {
super(message);
}
}File: events/domain/EventSlotReservationException.java
package dev.sivalabs.meetup4j.events.domain;
import dev.sivalabs.meetup4j.shared.DomainException;
public class EventSlotReservationException extends DomainException {
public EventSlotReservationException(String message) {
super(message);
}
}Usage in Entity:
public boolean cancel() {
if (this.isStarted()) {
throw new EventCancellationException("Cannot cancel events that have already started");
}
// ... rest of logic
}Create a centralized exception handler that returns ProblemDetail responses.
- Use
@RestControllerAdvice - Extend
ResponseEntityExceptionHandler - Return
ProblemDetailfor RFC 7807 compliance - Map different exceptions to appropriate HTTP status codes
- Include validation errors in response
- Hide internal details in production
File: config/GlobalExceptionHandler.java
package dev.sivalabs.meetup4j.config;
import dev.sivalabs.meetup4j.shared.DomainException;
import dev.sivalabs.meetup4j.shared.ResourceNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.core.env.Environment;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNPROCESSABLE_CONTENT;
@RestControllerAdvice
class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final Environment environment;
GlobalExceptionHandler(Environment environment) {
this.environment = environment;
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
log.error("Validation error", ex);
var errors = ex.getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.toList();
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(UNPROCESSABLE_CONTENT, ex.getMessage());
problemDetail.setTitle("Validation Error");
problemDetail.setProperty("errors", errors);
return ResponseEntity.status(UNPROCESSABLE_CONTENT).body(problemDetail);
}
@ExceptionHandler(DomainException.class)
public ProblemDetail handle(DomainException e) {
log.info("Bad request", e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(UNPROCESSABLE_CONTENT, e.getMessage());
problemDetail.setTitle("Bad Request");
problemDetail.setProperty("errors", List.of(e.getMessage()));
return problemDetail;
}
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handle(ResourceNotFoundException e) {
log.error("Resource not found", e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(NOT_FOUND, e.getMessage());
problemDetail.setTitle("Resource Not Found");
problemDetail.setProperty("errors", List.of(e.getMessage()));
return problemDetail;
}
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception e) {
logger.error("Unexpected exception occurred", e);
// Don't expose internal details in production
String message = "An unexpected error occurred";
if (isDevelopmentMode()) {
message = e.getMessage();
}
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, message);
problemDetail.setProperty("timestamp", Instant.now());
return problemDetail;
}
private boolean isDevelopmentMode() {
List<String> profiles = Arrays.asList(environment.getActiveProfiles());
return profiles.contains("dev") || profiles.contains("local");
}
}Validation Error (422):
{
"type": "about:blank",
"title": "Validation Error",
"status": 422,
"detail": "Validation failed for argument...",
"errors": [
"Title is required",
"Email must be valid"
]
}Domain Exception (422):
{
"type": "about:blank",
"title": "Bad Request",
"status": 422,
"detail": "Cannot cancel events that have already started",
"errors": [
"Cannot cancel events that have already started"
]
}Resource Not Found (404):
{
"type": "about:blank",
"title": "Resource Not Found",
"status": 404,
"detail": "Event not found with code: ABC123",
"errors": [
"Event not found with code: ABC123"
]
}Internal Server Error (500):
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred",
"timestamp": "2024-01-15T10:30:00Z"
}This document demonstrates Spring Boot best practices using real examples from the meetup4j-modulith-tomato project:
- Package Structure: Domain-driven modular organization
- Value Objects: Immutable records with validation and Jackson support
- Entities: Rich domain models with TSID IDs, embedded value objects, and business logic
- Repositories: Type-safe interfaces with meaningful queries
- Services: Separate write and read services (CQRS)
- REST API: Controllers with converters and proper HTTP semantics
- Module API: Public facades for inter-module communication
- Exceptions: Domain-specific exception hierarchy
- Error Handling: Centralized handler with ProblemDetail responses
These patterns promote clean architecture, type safety, and maintainability in Spring Boot applications.