En esta guía (49 elementos)
Java Code Style
Java Code Style
MANDATORY FOR ALL AI AGENTS: When generating Java code for this project, you MUST follow every convention listed below. Java code MUST maintain the same hexagonal architecture boundaries as Kotlin code. Domain and application layers MUST remain framework-free.
This document defines concrete conventions for Java code in this repository. Where not specified, follow the official Google Java Style Guide and Spring Boot recommendations. This guide mirrors the Kotlin Code Style with Java-specific idioms.
Targets
- Readability first: prefer clarity over brevity.
- Domain purity: domain and use cases are free from framework dependencies.
- Deterministic formatting: enforced by tooling (recommend Spotless with google-java-format or Checkstyle).
- Kotlin interop: Java code must integrate seamlessly with existing Kotlin code across module boundaries.
Formatting
- Max line length: 120. Break method chains and parameter lists sensibly.
- Indentation: 4 spaces. No tabs.
- Braces: always use braces for control flow, even single-line bodies.
- Imports: no wildcard imports (
import java.util.*is forbidden). Keep imports sorted:java.*,jakarta.*, then third-party, then project. - Imports:
javax.*is forbidden (except JDK packages such asjavax.sql,javax.crypto); usejakarta.*. - Annotations: one per line when parameters are present; otherwise inline is acceptable.
- Blank lines: one blank line between methods, two blank lines between major sections (fields, constructors, methods).
// GOOD - always use bracesif (user.isEnabled()) { return user;}
// BAD - no bracesif (user.isEnabled()) return user;Kotlin equivalent:
// Same rule applies in Kotlinif (user.isEnabled()) { return user}Naming
- Packages: all lowercase, no underscores (e.g.,
com.example.platform.domain). - Classes/Interfaces: PascalCase (
CreateUserUseCase,UserRepositoryAdapter). - Methods/Variables: lowerCamelCase (
createUser,contactEmail). - Constants:
UPPER_SNAKE_CASEinstatic finalfields or enum values. - Suffixes follow the hexagonal architecture boundaries:
Request/Responsefor REST/GraphQL DTOs.Entityfor persistence models.Mapperfor mapping classes.Adapterfor infrastructure adapters.Portfor port interfaces.UseCasefor application use case implementations.Commandfor use case input objects.
record vs class
When to use record
Use record for DTOs, value objects, commands, and simple immutable data carriers. Records provide automatic equals(), hashCode(), toString(), and accessor methods.
// DTO - REST requestpublic record CreateUserRequest( @NotBlank(message = "Username is required") @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") String username,
@NotBlank(message = "Email is required") @Email(message = "Email must be valid") String email,
@NotBlank(message = "First name is required") String firstName,
@NotBlank(message = "Last name is required") String lastName,
@NotEmpty(message = "At least one role is required") List<String> roles,
boolean enabled) {}
// Command - application layerpublic record CreateUserCommand( UUID organizationId, String username, String email, String firstName, String lastName, List<String> roles, boolean enabled) {}
// Value object - domain layerpublic record ContactInfo( String email, String phone) { // Compact constructor for validation public ContactInfo { if (email == null || email.isBlank()) { throw new IllegalArgumentException("Email must not be blank"); } }}
// Response DTOpublic record CreatedUserResponse( UUID id, LocalDateTime createdAt) {}Kotlin equivalent:
// In Kotlin, use data class for the same purposedata class CreateUserCommand( val organizationId: UUID, val username: String, val email: String, val firstName: String, val lastName: String, val roles: List<String>, val enabled: Boolean,)When to use class
Use a regular class for rich domain models with behavior, entities with identity, and objects requiring explicit control over equality, mutability, or lifecycle.
// Domain entity with behaviorpublic class Organization {
private final UUID id; private final String name; private final String contactName; private final String contactLastName; private final String contactEmail; private final String contactPhone; private final OrganizationStatus status; private final String logoUrl; private final String websiteUrl; private final LocalDateTime createdAt; private final String createdBy; private final LocalDateTime updatedAt; private final String updatedBy;
public Organization(UUID id, String name, String contactName, String contactLastName, String contactEmail, String contactPhone, OrganizationStatus status, String logoUrl, String websiteUrl, LocalDateTime createdAt, String createdBy, LocalDateTime updatedAt, String updatedBy) { this.id = id; this.name = name; this.contactName = contactName; this.contactLastName = contactLastName; this.contactEmail = contactEmail; this.contactPhone = contactPhone; this.status = status; this.logoUrl = logoUrl; this.websiteUrl = websiteUrl; this.createdAt = createdAt; this.createdBy = createdBy; this.updatedAt = updatedAt; this.updatedBy = updatedBy; }
// Business behavior — returns a new instance, enforces transition rules. // NEVER expose `setStatus`. Every status change goes through this method. public Organization transitionTo(OrganizationStatus target) { if (!status.canTransitionTo(target)) { throw new IllegalStateException( "Illegal transition: " + status + " → " + target); } return new Organization( this.id, this.name, this.contactName, this.contactLastName, this.contactEmail, this.contactPhone, target, this.logoUrl, this.websiteUrl, this.createdAt, this.createdBy, this.updatedAt, this.updatedBy ); }
public boolean isActive() { return status == OrganizationStatus.ACTIVE; }
// Explicit getters only (no setters) public UUID getId() { return id; } public String getName() { return name; } public OrganizationStatus getStatus() { return status; } // ... remaining getters}Kotlin equivalent:
// Aggregate root — regular class (NOT data class). data class exposes copy(...)// which lets callers bypass `transitionTo` and the canTransitionTo guard.class Organization private constructor( val id: UUID, val name: String, val contactName: String, val status: OrganizationStatus, // ...) { fun transitionTo(target: OrganizationStatus): Organization { check(status.canTransitionTo(target)) { "Illegal transition: $status → $target" } return Organization(id, name, contactName, target /*, ... */) }
fun isActive(): Boolean = status == OrganizationStatus.ACTIVE}Decision matrix
Use record when… |
Use class when… |
|---|---|
| Pure data carrier (DTO, command, event) | Rich domain model with business behavior |
| No mutable state needed | Explicit control over equality/identity |
| Auto-generated equals/hashCode is fine | Need builder pattern for many optional fields |
| Fewer than ~10 fields | Entity with lifecycle (creation, state changes) |
| Cross-layer mapping input/output | Need inheritance or abstract behavior |
Immutability
Immutability is a core principle. All domain objects, commands, and DTOs must be immutable.
- All fields
final. No setters. - Use
recordfor simple cases (automatically final). - Use builder pattern for complex objects with many optional fields.
- Use
List.of(),Map.of(),Set.of()orCollections.unmodifiableList()for collections.
The next example is a builder pattern demo for assembling immutable objects with many fields, not a domain-entity template. As-shown,
Useris anemic. For a real aggregate root: keep the builder, but add behavior (activate(),assignRole(...),disable(...)), enforce invariants inbuild(), and refuse to publish a setter.
// GOOD - immutable container assembled via builderpublic class User {
private final UUID id; private final UUID organizationId; private final String username; private final String email; private final String firstName; private final String lastName; private final List<String> roles; private final boolean enabled;
private User(Builder builder) { this.id = builder.id; this.organizationId = builder.organizationId; this.username = builder.username; this.email = builder.email; this.firstName = builder.firstName; this.lastName = builder.lastName; this.roles = List.copyOf(builder.roles); // defensive copy this.enabled = builder.enabled; }
public static Builder builder() { return new Builder(); }
// Only getters - no setters public UUID getId() { return id; } public List<String> getRoles() { return roles; } // already unmodifiable
public static class Builder { private UUID id; private UUID organizationId; private String username; private String email; private String firstName; private String lastName; private List<String> roles = List.of(); private boolean enabled = true;
public Builder id(UUID id) { this.id = id; return this; } public Builder organizationId(UUID id) { this.organizationId = id; return this; } public Builder username(String username) { this.username = username; return this; } public Builder email(String email) { this.email = email; return this; } public Builder firstName(String fn) { this.firstName = fn; return this; } public Builder lastName(String ln) { this.lastName = ln; return this; } public Builder roles(List<String> roles) { this.roles = roles; return this; } public Builder enabled(boolean enabled) { this.enabled = enabled; return this; }
public User build() { return new User(this); } }}// GOOD - immutable collectionsvar users = List.of(user1, user2, user3);var config = Map.of("key1", "value1", "key2", "value2");var tags = Set.of("admin", "active");
// BAD - mutable collections leakingpublic List<String> getRoles() { return this.roles; // if roles is an ArrayList, caller can modify it}
// GOOD - defensive copy or unmodifiablepublic List<String> getRoles() { return Collections.unmodifiableList(this.roles);}Kotlin equivalent:
// Kotlin achieves immutability naturally with val. Use `data class` ONLY if this is a// DTO/payload (e.g. `UserPayload`). For a domain aggregate root, prefer a regular class// with private ctor + factory + behavior (`activate`, `assignRole`, etc.) — see the// "data class vs regular class" rules at the top of this file's companion in// kotlin-code-style.md.data class UserPayload( val id: UUID, val organizationId: UUID, val username: String, val email: String, val firstName: String, val lastName: String, val roles: List<String> = listOf(), val enabled: Boolean = true,)Null Safety
Java lacks built-in null safety, so discipline and annotations are critical.
Rules
- Use
@NonNulland@Nullableannotations fromorg.jspecify.annotationson public APIs (JSpecify, the standard adopted by Spring 7 / Boot 3.4+;org.springframework.langis deprecated). - Use
Optional<T>as a return type only. Never useOptionalas a method parameter, field, or collection element. - Never return
nullfor collections. ReturnList.of(),Set.of(), orMap.of()instead. - Never return
nullfor error signaling. Throw domain-specific exceptions or returnOptional. - Validate constructor arguments with
Objects.requireNonNull()for non-nullable fields.
import org.jspecify.annotations.NonNull;import org.jspecify.annotations.Nullable;
public class UserRepositoryAdapter implements UserRepositoryPort {
// GOOD - Optional for return type when value may be absent @Override public Optional<User> findById(@NonNull UUID id) { Objects.requireNonNull(id, "User ID must not be null"); return jdbcRepository.findById(id) .map(EntityMapper::toDomain); }
// GOOD - never return null for collections @Override @NonNull public List<User> findByOrganizationId(@NonNull UUID organizationId) { return jdbcRepository.findByOrganizationId(organizationId) .stream() .map(EntityMapper::toDomain) .toList(); // returns empty list if none found, never null }
// BAD - returning null instead of Optional or empty collection public User findById(UUID id) { return null; // caller must null-check -- error-prone }}// GOOD - constructor validationpublic class Organization {
private final UUID id; private final String name;
public Organization(@NonNull UUID id, @NonNull String name) { this.id = Objects.requireNonNull(id, "Organization ID must not be null"); this.name = Objects.requireNonNull(name, "Organization name must not be null"); }}Kotlin equivalent:
// Kotlin's type system handles nullability nativelyfun findById(id: UUID): User? // nullable returnfun findByOrganizationId(id: UUID): List<User> // non-null, may be empty@FunctionalInterface
Use @FunctionalInterface for single-method contracts. This is the Java equivalent of Kotlin’s fun interface.
// Application layer - transaction port@FunctionalInterfacepublic interface TransactionPort<R> { R run(TransactionCallbackPort<R> callback);}
@FunctionalInterfacepublic interface TransactionCallbackPort<R> { R call();}// Infrastructure layer - implementation@Componentpublic class JdbcTransaction<R> implements TransactionPort<R> {
@Override @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) public R run(TransactionCallbackPort<R> callback) { return callback.call(); }}// Use case usage - lambda syntax works because of @FunctionalInterfacepublic class CreateUserUseCase implements CreateUserPort {
private final TransactionPort<User> transaction; private final UserRepositoryPort userRepository; private final UserPublisherPort userPublisher;
public CreateUserUseCase(TransactionPort<User> transaction, UserRepositoryPort userRepository, UserPublisherPort userPublisher) { this.transaction = transaction; this.userRepository = userRepository; this.userPublisher = userPublisher; }
@Override public User execute(CreateUserCommand command) { return transaction.run(() -> { User user = userRepository.save(CommandMapper.toUser(command)); log.info("Created user with ID: {}", user.getId()); userPublisher.publishCreated(user); return user; }); }}Kotlin equivalent:
// Kotlin uses fun interfacefun interface TransactionPort<R> { fun run(callback: TransactionCallbackPort<R>): R}
// Usage with lambdaval result = transaction.run { repository.save(entity) }When to use @FunctionalInterface vs regular interface
Use @FunctionalInterface when… |
Use regular interface when… |
|---|---|
| Single abstract method | Multiple methods needed |
| Lambda usage desired (callbacks, strategies) | Complex contract with default methods |
| Transaction, event, or callback boundaries | Repository ports with multiple operations |
Mapper Pattern
Java lacks extension functions, so use static methods in dedicated mapper classes. Each architectural boundary has its own mapper class.
CommandMapper (Application Layer)
Maps commands to domain objects.
public final class CommandMapper {
private CommandMapper() { // Utility class - prevent instantiation }
public static User toUser(CreateUserCommand command) { return User.builder() .id(UUID.randomUUID()) .organizationId(command.organizationId()) .username(command.username()) .email(command.email()) .firstName(command.firstName()) .lastName(command.lastName()) .roles(List.of()) .enabled(command.enabled()) .build(); }}Kotlin equivalent:
// Kotlin uses extension functions -- more fluentfun CreateUserCommand.toUser(): User = User( id = UUID.randomUUID(), organizationId = this.organizationId, username = this.username, email = this.email, firstName = this.firstName, lastName = this.lastName, roles = listOf(), enabled = this.enabled,)EntityMapper (Infrastructure Layer)
Maps between domain objects and persistence entities.
public final class EntityMapper {
private EntityMapper() { // Utility class - prevent instantiation }
// For aggregate roots, prefer a dedicated `User.rehydrate(...)` static factory // over `User.builder()`. The builder is open-ended and lets infrastructure code // reconstruct arbitrary states; `rehydrate(...)` is the named, intentional escape // hatch reserved for the persistence boundary and validates structural invariants. public static User toDomain(UserEntity entity) { return User.rehydrate( entity.getId(), entity.getOrganizationId(), entity.getUsername(), new Email(entity.getEmail()), entity.getFirstName(), entity.getLastName(), entity.getRoles() != null ? List.copyOf(entity.getRoles()) : List.of(), entity.isEnabled(), entity.getVersion() ); }
public static UserEntity toEntityForInsert(User user) { var entity = new UserEntity(); entity.setId(user.getId()); entity.setOrganizationId(user.getOrganizationId()); entity.setUsername(user.getUsername()); entity.setEmail(user.getEmail().value()); entity.setFirstName(user.getFirstName()); entity.setLastName(user.getLastName()); entity.setNew(true); // For Spring Data JDBC Persistable return entity; }}Kotlin equivalent:
// Kotlin uses extension functions. Aggregate roots have private ctors,// so the mapper calls a dedicated `rehydrate(...)` factory on the companion.fun UserEntity.toDomain(): User = User.rehydrate( id = this.id!!, organizationId = this.organizationId, username = this.username, email = Email(this.email), firstName = this.firstName, lastName = this.lastName, roles = this.roles.orEmpty().toList(), enabled = this.enabled, version = this.version,)
fun User.toEntityForInsert(): UserEntity = UserEntity().apply { this.id = this@toEntityForInsert.id this.username = this@toEntityForInsert.username this.email = this@toEntityForInsert.email.value this.isNew = true}RestMapper (Entry Point Layer)
Maps between REST DTOs and application commands / domain objects.
public final class UserRestMapper {
private UserRestMapper() { // Utility class - prevent instantiation }
public static CreateUserCommand toCreateUserCommand( CreateUserRequest request, UUID organizationId) { return new CreateUserCommand( organizationId, request.username(), request.email(), request.firstName(), request.lastName(), request.roles(), request.enabled() ); }
public static CreatedUserResponse toCreatedUserResponse(User user) { return new CreatedUserResponse(user.getId(), user.getCreatedAt()); }
public static UserResponse toUserResponse(User user) { return new UserResponse( user.getId(), user.getOrganizationId(), user.getUsername(), user.getEmail(), user.getFirstName(), user.getLastName(), List.of(), user.isEnabled() ); }}Kotlin equivalent:
// Kotlin uses extension functions for fluent mappingfun CreateUserRequest.toCreateUserCommand(organizationId: UUID): CreateUserCommand = CreateUserCommand( organizationId = organizationId, username = this.username, email = this.email, // ... )
fun User.toCreatedUserResponse(): CreatedUserResponse = CreatedUserResponse(id = this.id, createdAt = this.createdAt)Mapper rules
- Mappers are pure functions: no side effects, no business logic, no I/O.
- One mapper class per boundary (CommandMapper, EntityMapper, RestMapper).
- All methods are
public static. The class isfinalwith a private constructor. - Mapper classes live in a
mappersub-package at each layer. - Never place mapper logic in domain classes, use cases, or controllers.
Error Handling
Domain-specific exceptions
Define exceptions in the domain layer. Never use checked exceptions across layer boundaries.
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) { super(message); }
protected DomainException(String message, Throwable cause) { super(message, cause); }}
// domain/src/.../domain/exception/OrganizationNotFoundException.javapublic class OrganizationNotFoundException extends DomainException {
public OrganizationNotFoundException(UUID id) { super("Organization not found with ID: " + id); }}
// domain/src/.../domain/exception/UserAlreadyExistsException.javapublic class UserAlreadyExistsException extends DomainException {
public UserAlreadyExistsException(String email) { super("User already exists with email: " + email); }}Layer rules
| Layer | Rule |
|---|---|
| Domain | Throw RuntimeException subclasses. No checked exceptions. |
| Application | Catch infrastructure failures; translate or propagate domain exceptions. |
| Infrastructure | Wrap low-level exceptions into domain/application exceptions before throwing. |
| Entry points | Translate exceptions to HTTP status codes in @ControllerAdvice. |
// Infrastructure adapter - wrap low-level exceptions@Componentpublic class UserRepositoryAdapter implements UserRepositoryPort {
@Override public User save(User user) { try { var entity = EntityMapper.toEntityForInsert(user); var saved = jdbcRepository.save(entity); return EntityMapper.toDomain(saved); } catch (DataIntegrityViolationException ex) { throw new UserAlreadyExistsException(user.getEmail()); } }}// Entry point - translate to HTTP@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(OrganizationNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(OrganizationNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse(ex.getMessage())); }
@ExceptionHandler(UserAlreadyExistsException.class) public ResponseEntity<ErrorResponse> handleConflict(UserAlreadyExistsException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErrorResponse(ex.getMessage())); }}Kotlin equivalent:
// Same pattern applies -- RuntimeException subclasses in domainclass OrganizationNotFoundException(id: UUID) : DomainException("Organization not found with ID: $id")Logging
Use SLF4J logger per class. Log at boundaries with structured messages.
import org.slf4j.Logger;import org.slf4j.LoggerFactory;
public class CreateUserUseCase implements CreateUserPort {
private static final Logger log = LoggerFactory.getLogger(CreateUserUseCase.class);
@Override public User execute(CreateUserCommand command) { log.debug("Creating user with username: {}", command.username()); return transaction.run(() -> { User user = userRepository.save(CommandMapper.toUser(command)); log.info("Created user with ID: {}", user.getId());
userPublisher.publishCreated(user); log.info("Published event for user creation: userId={}", user.getId()); return user; }); }}Kotlin equivalent:
companion object { private val log = LoggerFactory.getLogger(CreateUserUseCase::class.java)}Logging rules
- Declare logger as
private static final Logger log(notlogger, notLOG). - Log at layer boundaries: entry points (request/response), use cases (operations), adapters (I/O).
- Never log sensitive data (passwords, tokens, PII).
- Use parameterized messages (
{}placeholders), never string concatenation. - Levels:
DEBUG- detailed flow, method entry/exit.INFO- lifecycle events, successful operations.WARN- recoverable issues, fallback paths.ERROR- failures that bubble up, unrecoverable errors. Always include the exception.
- Include correlation IDs when available.
Validation
Entry points: Bean Validation
Use Jakarta Bean Validation annotations on request DTOs. Validate before mapping to domain types.
// Request DTO with validationpublic record CreateOrganizationRequest( @NotBlank(message = "Name is required") @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") String name,
@NotBlank(message = "Contact email is required") @Email(message = "Contact email must be valid") String contactEmail,
@NotBlank(message = "Contact phone is required") @Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Phone must be a valid number") String contactPhone) {}
// Controller uses @Valid@RestController@RequestMapping("/api/v1/organizations")public class OrganizationController {
@PostMapping public ResponseEntity<CreatedOrganizationResponse> create( @Valid @RequestBody CreateOrganizationRequest request) { var command = OrganizationRestMapper.toCommand(request); var organization = createOrganizationPort.execute(command); return ResponseEntity.status(HttpStatus.CREATED) .body(OrganizationRestMapper.toCreatedResponse(organization)); }}Domain: constructor invariants
Validate business rules inside domain constructors or factory methods. Domain validation must not depend on framework annotations.
// Domain validation in constructorpublic class Organization {
public Organization(UUID id, String name, String contactEmail, OrganizationStatus status) { this.id = Objects.requireNonNull(id, "ID must not be null"); if (name == null || name.isBlank()) { throw new IllegalArgumentException("Organization name must not be blank"); } if (name.length() > 100) { throw new IllegalArgumentException( "Organization name must not exceed 100 characters"); } this.name = name; this.contactEmail = Objects.requireNonNull(contactEmail, "Contact email is required"); this.status = Objects.requireNonNull(status, "Status is required"); }}Kotlin equivalent:
// Aggregate root — regular class. `data class` would expose copy(...) that skips// state-transition guards; use it only for DTOs/value objects.class Organization private constructor( val id: UUID, val name: String, val contactEmail: String, val status: OrganizationStatus,) { init { require(name.isNotBlank()) { "Organization name must not be blank" } require(name.length <= 100) { "Organization name must not exceed 100 characters" } }
companion object { fun create(id: UUID, name: String, contactEmail: String): Organization = Organization(id, name, contactEmail, OrganizationStatus.PENDING_APPROVAL) }}JSON Serialization
Rules
- ALWAYS use
JsonMapper.builder()(Jackson 3.x) — it produces an immutable, thread-safe mapper. NEVER useObjectMapper(deprecated in Jackson 3.x). - Register modules at build time only (
JavaTimeModule). No runtime.registerModule()calls. - Standard config:
WRITE_DATES_AS_TIMESTAMPS=false,FAIL_ON_UNKNOWN_PROPERTIES=false.
JsonMapper jsonMapper = JsonMapper.builder() .addModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build();Anti-Patterns to Avoid
1. Mutable DTOs with setters
// BAD - mutable DTOpublic class CreateUserRequest { private String username; private String email;
public void setUsername(String username) { this.username = username; } public void setEmail(String email) { this.email = email; } public String getUsername() { return username; } public String getEmail() { return email; }}// GOOD - immutable recordpublic record CreateUserRequest( @NotBlank String username, @NotBlank @Email String email) {}Kotlin equivalent:
// Kotlin data class is immutable by default with valdata class CreateUserRequest( val username: String, val email: String,)2. Anemic domain models
// BAD - anemic model, behavior lives in servicespublic class Organization { private UUID id; private String name; private OrganizationStatus status;
// Only getters and setters, no behavior public void setStatus(OrganizationStatus status) { this.status = status; } public OrganizationStatus getStatus() { return status; }}
// Business logic incorrectly placed in a servicepublic class OrganizationService { public void activate(Organization org) { if (org.getStatus() != OrganizationStatus.INACTIVE) { throw new IllegalStateException("..."); } org.setStatus(OrganizationStatus.ACTIVE); // mutating externally }}// GOOD - rich domain model, behavior inside the entitypublic class Organization {
private final UUID id; private final String name; private final OrganizationStatus status;
// Constructor...
public Organization activate() { if (this.status != OrganizationStatus.INACTIVE) { throw new IllegalStateException("Only inactive organizations can be activated"); } return new Organization(this.id, this.name, OrganizationStatus.ACTIVE); }
public boolean isActive() { return status == OrganizationStatus.ACTIVE; }}3. Checked exceptions in domain
// BAD - checked exception forces callers to handle or declarepublic class Organization { public Organization activate() throws InvalidStatusException { // forces every caller to catch or throw }}// GOOD - unchecked (RuntimeException) domain exceptionpublic class Organization { public Organization activate() { if (this.status != OrganizationStatus.INACTIVE) { throw new InvalidStatusException("Only inactive organizations can be activated"); } return new Organization(this.id, this.name, OrganizationStatus.ACTIVE); }}
// InvalidStatusException extends RuntimeExceptionpublic class InvalidStatusException extends DomainException { public InvalidStatusException(String message) { super(message); }}4. Static utility classes with business logic
// BAD - business logic in a static utility classpublic class OrganizationUtils { public static boolean canActivate(Organization org) { return org.getStatus() == OrganizationStatus.INACTIVE; } public static Organization activate(Organization org) { // Business logic should not live here }}// GOOD - behavior belongs in the domain model itselfpublic class Organization { public boolean canActivate() { return this.status == OrganizationStatus.INACTIVE; } public Organization activate() { if (!canActivate()) { throw new InvalidStatusException("Cannot activate organization"); } return new Organization(this.id, this.name, OrganizationStatus.ACTIVE); }}Note: Static mapper utility classes (like CommandMapper, EntityMapper) are acceptable because they only perform structural mapping, not business logic.
5. Field injection with @Autowired
// BAD - field injection hides dependencies, prevents immutability, breaks testing@Componentpublic class UserRepositoryAdapter implements UserRepositoryPort {
@Autowired private UserJdbcRepository jdbcRepository; // hidden dependency
@Autowired private EntityMapper mapper; // hidden dependency}// GOOD - constructor injection, explicit dependencies, immutable@Componentpublic class UserRepositoryAdapter implements UserRepositoryPort {
private final UserJdbcRepository jdbcRepository;
public UserRepositoryAdapter(UserJdbcRepository jdbcRepository) { this.jdbcRepository = jdbcRepository; }}Kotlin equivalent:
// Kotlin constructor injection is concise by nature@Componentclass UserRepositoryAdapter( private val jdbcRepository: UserJdbcRepository,) : UserRepositoryPort { ... }6. Raw types without generics
// BAD - raw types lose type safetyList users = repository.findAll();Map config = loadConfig();TransactionPort transaction; // raw generic// GOOD - always parameterize genericsList<User> users = repository.findAll();Map<String, String> config = loadConfig();TransactionPort<User> transaction;7. Returning null instead of Optional or empty collections
// BAD - returning nullpublic User findById(UUID id) { // returns null if not found -- caller may forget to check return null;}
public List<User> findByOrganization(UUID orgId) { if (noResults) { return null; // forces null checks on callers }}// GOOD - Optional for single values, empty collection for listspublic Optional<User> findById(UUID id) { return jdbcRepository.findById(id).map(EntityMapper::toDomain);}
public List<User> findByOrganization(UUID orgId) { // always returns a list, possibly empty -- never null return jdbcRepository.findByOrganizationId(orgId) .stream() .map(EntityMapper::toDomain) .toList();}Kotlin equivalent:
// Kotlin uses nullable types for single valuesfun findById(id: UUID): User? // explicit nullablefun findByOrganization(orgId: UUID): List<User> // non-null, possibly empty8. Using Object as type parameter
// BAD - loses type safety, requires castingpublic class GenericRepository { public Object save(Object entity) { // what type is this? no compile-time safety return entity; }}
Map<String, Object> response = new HashMap<>(); // untyped values// GOOD - use proper genericspublic interface RepositoryPort<T> { T save(T entity); Optional<T> findById(UUID id);}
// GOOD - use typed responsespublic record ApiResponse<T>( T data, String message, int status) {}Sealed Classes (Java 17+)
Java 17+ supports sealed classes. Use them for closed hierarchies, equivalent to Kotlin sealed classes.
// Domain event hierarchypublic sealed interface DomainEvent permits UserCreatedEvent, UserEnabledEvent { UUID eventId(); LocalDateTime occurredAt();}
public record UserCreatedEvent( UUID eventId, LocalDateTime occurredAt, UUID userId, String email) implements DomainEvent {}
public record UserEnabledEvent( UUID eventId, LocalDateTime occurredAt, UUID userId) implements DomainEvent {}
// Exhaustive pattern matching (Java 17+)public String describeEvent(DomainEvent event) { return switch (event) { case UserCreatedEvent e -> "User created: " + e.userId(); case UserEnabledEvent e -> "User enabled: " + e.userId(); };}Kotlin equivalent:
sealed interface DomainEvent { val eventId: UUID val occurredAt: LocalDateTime}
data class UserCreatedEvent( override val eventId: UUID, override val occurredAt: LocalDateTime, val userId: UUID, val email: String,) : DomainEvent
// Exhaustive whenfun describeEvent(event: DomainEvent): String = when (event) { is UserCreatedEvent -> "User created: ${event.userId}" is UserEnabledEvent -> "User enabled: ${event.userId}"}Verification Checklist
Before submitting Java code, verify every item:
Formatting and structure
- Max 120 characters per line
- 4-space indentation, no tabs
- Braces on all control flow blocks
- No wildcard imports; imports sorted
- Annotations on separate lines when parameterized
Naming
- Packages all lowercase
- Classes PascalCase with correct suffixes (Request, Response, Entity, Mapper, Adapter, Port, UseCase, Command)
- Methods and variables lowerCamelCase
- Constants UPPER_SNAKE_CASE in static final fields
Types and immutability
-
recordused for DTOs, commands, value objects, responses -
classused for rich domain models with behavior - All fields
final(or record components) - No setters on domain objects or DTOs
- Collections returned as unmodifiable (
List.of(),List.copyOf(),Collections.unmodifiableList())
Null safety
-
@NonNull/@Nullableannotations on public API boundaries -
Optionalused only as return type, never as parameter or field - Collections never null – always empty when no elements
- Constructor arguments validated with
Objects.requireNonNull()
Functional interfaces
-
@FunctionalInterfaceon single-method interfaces (TransactionPort, callbacks) - Regular interfaces for multi-method contracts (repository ports)
Mappers
- Static methods in final utility classes with private constructors
- One mapper class per boundary (CommandMapper, EntityMapper, RestMapper)
- No business logic in mappers
- Mappers live in
mappersub-packages
Error handling
- Domain exceptions extend
RuntimeException(unchecked) - No checked exceptions crossing layer boundaries
- Infrastructure exceptions wrapped into domain exceptions
- Entry points translate exceptions to HTTP responses via
@ControllerAdvice
Logging
- SLF4J logger per class:
private static final Logger log - Parameterized messages with
{}placeholders - No sensitive data logged
- Logging at layer boundaries
Validation
- Bean Validation annotations on entry-point DTOs
- Domain invariants validated in constructors or factory methods
- No framework annotations in domain or application layers
Architecture boundaries
- Domain layer: no Spring, no framework dependencies
- Application layer: no framework annotations, depends only on domain
- Infrastructure: implements ports, depends on application and domain
- Bootstrap: wires beans, no business logic
- Constructor injection everywhere (no
@Autowiredfield injection)
Anti-patterns avoided
- No mutable DTOs with setters
- No anemic domain models
- No checked exceptions in domain
- No static utility classes with business logic (mappers are structural only)
- No field injection with
@Autowired - No raw types without generics
- No null returns for Optional/collection scenarios
- No
Objectas type parameter where generics apply