Skip to content
← All insights
Java13 min read

Java records and sealed types for honest domain models

How records, sealed interfaces, Optional and exhaustive decisions can replace nullable fields, string states and inheritance-heavy Java models.

Model the states the application actually permits

Java domain models often begin as mutable classes with nullable fields and a status string. That shape accepts combinations the business never intended: a declined payment with a settlement reference, or an active account with a closure reason.

Records are concise immutable data carriers. Sealed interfaces define a closed family of alternatives. Together they provide product types and sum types: values containing several fields, and values that must be exactly one of several cases.

Give each result only the data it can containjava
public sealed interface PaymentResult
    permits Accepted, Declined, TimedOut {}

public record Accepted(
    PaymentId id,
    SettlementReference reference
) implements PaymentResult {}

public record Declined(
    PaymentId id,
    DeclineReason reason
) implements PaymentResult {}

public record TimedOut(PaymentId id) implements PaymentResult {}

Records are values, not automatic domain objects

A record supplies final components, accessors, a canonical constructor and value-based equals, hashCode and toString. It does not validate meaning automatically. A public canonical constructor can still accept an empty identifier or negative amount.

Use a compact constructor for local invariants or a named factory when construction can fail with useful detail. Keep validation that depends on repositories or external services outside the record; construction should not conceal I/O.

Protect a local invariant at constructionjava
public record AccountName(String value) {
  public AccountName {
    value = value == null ? "" : value.trim();
    if (value.isEmpty()) {
      throw new IllegalArgumentException("Account name is required");
    }
  }
}

public record Money(BigDecimal amount, Currency currency) {
  public Money {
    Objects.requireNonNull(amount);
    Objects.requireNonNull(currency);
  }
}

Closed alternatives make decisions reviewable

A sealed hierarchy allows only declared implementations. A switch over that hierarchy can make every case visible, so adding a new result prompts the compiler to identify decisions that need revisiting.

Avoid a default branch on a hierarchy the application owns. It suppresses the benefit of exhaustivity and can silently assign future cases the wrong behaviour. A default remains appropriate when the input is deliberately open-ended.

Translate every known result explicitlyjava
public PaymentResponse render(PaymentResult result) {
  return switch (result) {
    case Accepted accepted ->
        PaymentResponse.accepted(accepted.reference());
    case Declined declined ->
        PaymentResponse.declined(declined.reason());
    case TimedOut ignored ->
        PaymentResponse.retryLater();
  };
}

Optional is best at return boundaries

Optional<T> communicates that a method may return no value and gives callers operations such as map, flatMap, orElse and orElseThrow. It is usually clearer than returning null from a repository lookup.

Using Optional for every record component, collection element or method parameter can spread wrapping through the model. A command should normally state whether a field is required; use separate request shapes or overloads when absence has a particular meaning. Never store null inside Optional.

Turn absence into a decision at the service boundaryjava
public Either<FindAccountError, Account> find(AccountId id) {
  return repository.find(id)
      .<Either<FindAccountError, Account>>map(Either::right)
      .orElseGet(() -> Either.left(
          new FindAccountError.NotFound(id)
      ));
}

// If no Either type is used, a service-specific result hierarchy
// can express the same decision.

Keep transport and persistence contracts deliberate

Serialising a record directly is convenient, but its component names and structure can become an external API. Renaming a domain component may then change JSON, database mappings and internal behaviour in one edit.

Reuse a record across layers when its contract and lifecycle genuinely match. Introduce request, response or persistence records when compatibility requirements differ. Mapping code is useful when it identifies a real boundary rather than satisfying a folder structure.

Make the HTTP representation explicitjava
public record AccountResponse(
    String id,
    String displayName,
    BigDecimal balance
) {
  public static AccountResponse from(Account account) {
    return new AccountResponse(
        account.id().value(),
        account.name().value(),
        account.balance().amount()
    );
  }
}

Prefer the smallest model that rules out real mistakes

Records and sealed types are useful when they expose a business distinction to the compiler. They do not require every string to become a wrapper or every Boolean to become a hierarchy.

Review the invalid states the current model permits, the callers that construct it and the boundaries that serialise it. Add types where they remove ambiguity or make an important decision exhaustive; leave ordinary data ordinary where the extra ceremony has no leverage.

  • Can a value be constructed in a state the domain rejects?
  • Does a string or Boolean conceal several meaningful states?
  • Will a default switch branch hide a newly added case?
  • Is Optional expressing absence or spreading uncertainty?
  • Would changing a record component unintentionally change JSON or persistence?
  • Does each wrapper enforce or communicate something useful?