Skip to content
My Guidelines
Guides
In this guide (49 items)

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 as javax.sql, javax.crypto); use jakarta.*.
  • 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 braces
if (user.isEnabled()) {
return user;
}
// BAD - no braces
if (user.isEnabled()) return user;

Kotlin equivalent:

// Same rule applies in Kotlin
if (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_CASE in static final fields or enum values.
  • Suffixes follow the hexagonal architecture boundaries:
    • Request / Response for REST/GraphQL DTOs.
    • Entity for persistence models.
    • Mapper for mapping classes.
    • Adapter for infrastructure adapters.
    • Port for port interfaces.
    • UseCase for application use case implementations.
    • Command for 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 request
public 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 layer
public record CreateUserCommand(
UUID organizationId,
String username,
String email,
String firstName,
String lastName,
List<String> roles,
boolean enabled
) {}
// Value object - domain layer
public 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 DTO
public record CreatedUserResponse(
UUID id,
LocalDateTime createdAt
) {}

Kotlin equivalent:

// In Kotlin, use data class for the same purpose
data 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 behavior
public 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 record for simple cases (automatically final).
  • Use builder pattern for complex objects with many optional fields.
  • Use List.of(), Map.of(), Set.of() or Collections.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, User is anemic. For a real aggregate root: keep the builder, but add behavior (activate(), assignRole(...), disable(...)), enforce invariants in build(), and refuse to publish a setter.

// GOOD - immutable container assembled via builder
public 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 collections
var users = List.of(user1, user2, user3);
var config = Map.of("key1", "value1", "key2", "value2");
var tags = Set.of("admin", "active");
// BAD - mutable collections leaking
public List<String> getRoles() {
return this.roles; // if roles is an ArrayList, caller can modify it
}
// GOOD - defensive copy or unmodifiable
public 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

  1. Use @NonNull and @Nullable annotations from org.jspecify.annotations on public APIs (JSpecify, the standard adopted by Spring 7 / Boot 3.4+; org.springframework.lang is deprecated).
  2. Use Optional<T> as a return type only. Never use Optional as a method parameter, field, or collection element.
  3. Never return null for collections. Return List.of(), Set.of(), or Map.of() instead.
  4. Never return null for error signaling. Throw domain-specific exceptions or return Optional.
  5. 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 validation
public 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 natively
fun findById(id: UUID): User? // nullable return
fun 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
@FunctionalInterface
public interface TransactionPort<R> {
R run(TransactionCallbackPort<R> callback);
}
@FunctionalInterface
public interface TransactionCallbackPort<R> {
R call();
}
// Infrastructure layer - implementation
@Component
public 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 @FunctionalInterface
public 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 interface
fun interface TransactionPort<R> {
fun run(callback: TransactionCallbackPort<R>): R
}
// Usage with lambda
val 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.

application/src/.../usecase/user/command/mapper/CommandMapper.java
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 fluent
fun 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.

infrastructure/.../jdbc/repository/mapper/EntityMapper.java
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.

infrastructure/.../rest/controller/user/dto/mapper/UserRestMapper.java
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 mapping
fun 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 is final with a private constructor.
  • Mapper classes live in a mapper sub-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.

domain/src/.../domain/exception/DomainException.java
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.java
public class OrganizationNotFoundException extends DomainException {
public OrganizationNotFoundException(UUID id) {
super("Organization not found with ID: " + id);
}
}
// domain/src/.../domain/exception/UserAlreadyExistsException.java
public 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
@Component
public 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
@RestControllerAdvice
public 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 domain
class 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 (not logger, not LOG).
  • 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 validation
public 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 constructor
public 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

  1. ALWAYS use JsonMapper.builder() (Jackson 3.x) — it produces an immutable, thread-safe mapper. NEVER use ObjectMapper (deprecated in Jackson 3.x).
  2. Register modules at build time only (JavaTimeModule). No runtime .registerModule() calls.
  3. 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 DTO
public 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 record
public record CreateUserRequest(
@NotBlank String username,
@NotBlank @Email String email
) {}

Kotlin equivalent:

// Kotlin data class is immutable by default with val
data class CreateUserRequest(
val username: String,
val email: String,
)

2. Anemic domain models

// BAD - anemic model, behavior lives in services
public 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 service
public 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 entity
public 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 declare
public class Organization {
public Organization activate() throws InvalidStatusException {
// forces every caller to catch or throw
}
}
// GOOD - unchecked (RuntimeException) domain exception
public 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 RuntimeException
public class InvalidStatusException extends DomainException {
public InvalidStatusException(String message) {
super(message);
}
}

4. Static utility classes with business logic

// BAD - business logic in a static utility class
public 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 itself
public 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
@Component
public class UserRepositoryAdapter implements UserRepositoryPort {
@Autowired
private UserJdbcRepository jdbcRepository; // hidden dependency
@Autowired
private EntityMapper mapper; // hidden dependency
}
// GOOD - constructor injection, explicit dependencies, immutable
@Component
public class UserRepositoryAdapter implements UserRepositoryPort {
private final UserJdbcRepository jdbcRepository;
public UserRepositoryAdapter(UserJdbcRepository jdbcRepository) {
this.jdbcRepository = jdbcRepository;
}
}

Kotlin equivalent:

// Kotlin constructor injection is concise by nature
@Component
class UserRepositoryAdapter(
private val jdbcRepository: UserJdbcRepository,
) : UserRepositoryPort { ... }

6. Raw types without generics

// BAD - raw types lose type safety
List users = repository.findAll();
Map config = loadConfig();
TransactionPort transaction; // raw generic
// GOOD - always parameterize generics
List<User> users = repository.findAll();
Map<String, String> config = loadConfig();
TransactionPort<User> transaction;

7. Returning null instead of Optional or empty collections

// BAD - returning null
public 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 lists
public 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 values
fun findById(id: UUID): User? // explicit nullable
fun findByOrganization(orgId: UUID): List<User> // non-null, possibly empty

8. Using Object as type parameter

// BAD - loses type safety, requires casting
public 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 generics
public interface RepositoryPort<T> {
T save(T entity);
Optional<T> findById(UUID id);
}
// GOOD - use typed responses
public 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 hierarchy
public 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 when
fun 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

  • record used for DTOs, commands, value objects, responses
  • class used 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 / @Nullable annotations on public API boundaries
  • Optional used 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

  • @FunctionalInterface on 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 mapper sub-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 @Autowired field 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 Object as type parameter where generics apply