Three Type Abstractions and Why the Distinction Matters

Java provides three primary mechanisms for defining common types: interface, abstract class, and sealed class. While they may appear syntactically similar, their design objectives differ significantly. Misusing these abstractions—such as using an interface where an abstract class is more appropriate or failing to use a sealed class when exhaustion checks are required—leads to fragile designs. This often results in logic errors within switch branches that could have been caught at compile time.

The sealed class was officially introduced in Java 17 (JEP 409) following previews in Java 15 (JEP 360). When combined with switch pattern matching (Java 21, JEP 441), it allows the compiler to verify the exhaustiveness of type hierarchies.

This article examines the characteristics and constraints of each abstraction with production-oriented examples to guide appropriate selection.


interface: Defining the Contract

Basic Characteristics

An interface defines a contract specifying “what an implementation can do.” Since Java 8, interfaces can include default implementations via the default keyword, but they cannot maintain instance state. Only constants (static final) are permitted.

public interface Notifiable {
	// Abstract method: must be implemented by concrete classes
	String getRecipientId();

	void notify(String message);

	// Default implementation: used unless overridden (Java 8+)
	default void notifyWithPrefix(String prefix, String message) {
		notify("[" + prefix + "] " + message);
	}
}

Combining Roles via Multiple Implementation

The primary advantage of interfaces is the ability for a single class to implement multiple roles.

public interface Auditable {
	long getCreatedAt();

	String auditLog();
}

public interface Cacheable {
	String getCacheKey();

	boolean isCacheExpired(long nowMs);
}

// UserSession implements multiple roles: Notifiable, Auditable, and Cacheable
public class UserSession implements Notifiable, Auditable, Cacheable {
	private final String recipientId;
	private final long createdAt;
	private final long ttlMs; // Session TTL in milliseconds

	public UserSession(String recipientId, long createdAt, long ttlMs) {
		this.recipientId = recipientId;
		this.createdAt = createdAt;
		this.ttlMs = ttlMs;
	}

	@Override
	public String getRecipientId() {
		return recipientId;
	}

	@Override
	public void notify(String message) {
		System.out.println("Push to " + recipientId + ": " + message);
	}

	@Override
	public long getCreatedAt() {
		return createdAt;
	}

	@Override
	public String auditLog() {
		return "Session created at " + createdAt + " for " + recipientId;
	}

	@Override
	public String getCacheKey() {
		return "session:" + recipientId;
	}

	@Override
	public boolean isCacheExpired(long nowMs) {
		return (nowMs - createdAt) > ttlMs;
	}
}

Stateless Constraint

Interfaces do not store instance state. While default methods provide common logic, state management remains the responsibility of the implementation.

public interface Describable {
	String getName();

	String getCode();

	// The interface provides calculation logic but is stateless
	default String displayName() {
		return "[" + getCode() + "] " + getName();
	}
}

public class ProductCategory implements Describable {
	private final String name;
	private final String code;

	public ProductCategory(String name, String code) {
		this.name = name;
		this.code = code;
	}

	@Override
	public String getName() {
		return name;
	}

	@Override
	public String getCode() {
		return code;
	}
}

Testability Benefits

Interface boundaries simplify the replacement of external systems (e.g., payment gateways or message queues) with test doubles.

// Fixed result hierarchy using a sealed interface
public sealed interface ChargeResult permits ChargeResult.Success, ChargeResult.Failure {
	record Success(String transactionId) implements ChargeResult {
	}

	record Failure(String reason) implements ChargeResult {
	}
}

public interface PaymentGateway {
	ChargeResult charge(String orderId, long amount);
}

// Production implementation: communicates with external API
public class TossPaymentGateway implements PaymentGateway {
	@Override
	public ChargeResult charge(String orderId, long amount) {
		// Implementation for external API call
		return new ChargeResult.Success("txn-" + orderId);
	}
}

// Test double: returns success without external calls
public class FakePaymentGateway implements PaymentGateway {
	@Override
	public ChargeResult charge(String orderId, long amount) {
		return new ChargeResult.Success("fake-txn-" + orderId);
	}
}


abstract class: Sharing Implementation

Basic Characteristics

Abstract classes maintain instance fields and share common logic with subclasses. They cannot be instantiated directly and must be extended.

public abstract class BaseRepository<T> {
	protected final String tableName; // Shared field for subclasses

	protected BaseRepository(String tableName) {
		this.tableName = tableName;
	}

	// Common implementation shared unless overridden
	public List<T> findAll() {
		System.out.println("SELECT * FROM " + tableName);
		return Collections.emptyList();
	}

	// Abstract method: implementation specifics delegated to subclasses
	public abstract Optional<T> findById(long id);

	// Template method: defines the sequence (validate -> persist)
	public T save(T entity) {
		validate(entity);
		return persist(entity);
	}

	protected abstract void validate(T entity);

	protected abstract T persist(T entity);
}

Encapsulating Common State and Logic

Abstract classes are effective for sharing state or logic across implementations. The goal is to centralize common execution flows while delegating specific behaviors to subclasses.

public abstract class DiscountPolicy {
	protected final long minAmount;

	protected DiscountPolicy(long minAmount) {
		this.minAmount = minAmount;
	}

	// Centralized flow: minimum amount check is fixed in the base class
	public final long apply(long amount) {
		if (amount < minAmount) {
			return amount;
		}
		return calculate(amount);
	}

	protected abstract long calculate(long amount);
}

public class FixedDiscountPolicy extends DiscountPolicy {
	private final long discountAmount;

	public FixedDiscountPolicy(long minAmount, long discountAmount) {
		super(minAmount);
		this.discountAmount = discountAmount;
	}

	@Override
	protected long calculate(long amount) {
		return Math.max(0, amount - discountAmount);
	}
}

Combined Patterns

A common pattern involves an abstract class implementing an interface to provide shared boilerplate, while concrete subclasses handle implementation-specific logic.

public interface EventHandler<T> {
	boolean canHandle(Object event);

	void handle(T event);
}

// Abstract class handles shared logging and flow control
public abstract class BaseEventHandler<T> implements EventHandler<T> {
	private final String handlerName;

	protected BaseEventHandler(String handlerName) {
		this.handlerName = handlerName;
	}

	// final: prevents subclasses from modifying the core flow (logging -> doHandle)
	@Override
	public final void handle(T event) {
		System.out.println(handlerName + " processing started");
		doHandle(event);
		System.out.println(handlerName + " processing completed");
	}

	protected abstract void doHandle(T event);
}

// record: concise immutable data carrier (Java 16+)
public record OrderCreatedEvent(String orderId, long amount) {
}

public class OrderCreatedHandler extends BaseEventHandler<OrderCreatedEvent> {
	public OrderCreatedHandler() {
		super("OrderCreationHandler");
	}

	// Pattern matching for instanceof (Java 16+)
	@Override
	public boolean canHandle(Object event) {
		return event instanceof OrderCreatedEvent;
	}

	@Override
	protected void doHandle(OrderCreatedEvent event) {
		System.out.printf("Processing order: id=%s, amount=%d%n", event.orderId(), event.amount());
	}
}


sealed class/interface: Constraining Hierarchies

Core Characteristics (Java 17+)

A sealed class or sealed interface restricts which other classes or interfaces may extend or implement them. Subtypes are explicitly listed using the permits keyword (which can be omitted if subtypes are defined in the same file). Subtypes must be declared as final, sealed, or non-sealed.

// Explicitly permitted subtypes
public sealed class ApiResponse permits ApiResponse.Ok, ApiResponse.Error {
	public static final class Ok extends ApiResponse {
		public final String body;

		public Ok(String body) {
			this.body = body;
		}
	}

	public static final class Error extends ApiResponse {
		public final int statusCode;
		public final String message;

		public Error(int statusCode, String message) {
			this.statusCode = statusCode;
			this.message = message;
		}
	}
}

switch Pattern Matching: Eliminating the default Case (Java 21+)

Combining sealed types with switch pattern matching allows for compile-time exhaustiveness checks. If a new subtype is added but not handled in a switch expression, the compiler generates an error.

// Java 21+: exhaustive switch without a default clause
String describe(ApiResponse response) {
	return switch (response) {
		case ApiResponse.Ok ok -> "Success: " + ok.body;
		case ApiResponse.Error error -> "Error " + error.statusCode + ": " + error.message;
	};
}

Adding a Redirect subtype to ApiResponse would immediately trigger a compilation failure in the describe method, surfacing missing logic during build time rather than runtime.

Subtype Constraints

Subtypes specified in permits must use one of the following modifiers:

Modifier Meaning
final Prevents further extension (leaf node).
sealed Restricts further hierarchy via its own permits list.
non-sealed Re-opens the hierarchy for arbitrary extension.

Permitted subtypes must reside in the same package (unnamed module) or the same module (named module) as the sealed type.

// Sealed interface combined with records
public sealed interface PaymentEvent
	permits PaymentEvent.Approved, PaymentEvent.Failed, PaymentEvent.Pending {

	record Approved(String transactionId, long amount) implements PaymentEvent {
	}

	record Failed(String reason, boolean retryable) implements PaymentEvent {
	}

	record Pending() implements PaymentEvent {
	}
}

// Java 21+: exhaustive type pattern matching
String describeEvent(PaymentEvent event) {
	return switch (event) {
		case PaymentEvent.Approved a -> "Approved: txn=" + a.transactionId();
		case PaymentEvent.Failed f -> "Failed (retry=" + f.retryable() + "): " + f.reason();
		case PaymentEvent.Pending p -> "Pending";
	};
}

Using sealed interfaces with record types provides a concise way to model sum types while benefiting from boilerplate-free data structures.


Comparative Analysis

Feature Matrix

Feature interface abstract class sealed class
Instantiation No No Conditional (concrete sealed classes only)
Instance Fields No (Constants only) Yes Yes
Inheritance Multiple implementation Single inheritance Single inheritance (Multiple for sealed interfaces)
Constructor No Yes Yes
Hierarchy Control Open Open Restricted via permits
switch Exhaustion No (Yes for sealed interface) No Yes (Java 21+)
Default Logic Yes (default methods) Yes Yes
Primary Use Case Role/Contract definition Implementation sharing Sum types, State machines


Practical Comparison

Consider modeling payment results using each abstraction.

Using an interface: The hierarchy is open, and switch blocks require a default case or manual checks.

public interface PaymentResult {
	boolean isSuccess();

	String describe();
}

public class ApprovedResult implements PaymentResult {
	private final String transactionId;

	public ApprovedResult(String transactionId) {
		this.transactionId = transactionId;
	}

	@Override
	public boolean isSuccess() {
		return true;
	}

	@Override
	public String describe() {
		return "Approved: " + transactionId;
	}
}

public class FailedResult implements PaymentResult {
	private final String reason;

	public FailedResult(String reason) {
		this.reason = reason;
	}

	@Override
	public boolean isSuccess() {
		return false;
	}

	@Override
	public String describe() {
		return "Failed: " + reason;
	}
}

// Compiler cannot verify if all implementations are handled
String process(PaymentResult result) {
	if (result.isSuccess())
		return "Done";
	return "Error"; // New implementations may lead to unhandled logic
}

Using an abstract class: Shared state is centralized, but hierarchy remains open and not exhaustive.

public abstract class PaymentResultBase {
	public final String requestId;
	public final long transactionFee; // Shared state

	protected PaymentResultBase(String requestId, long transactionFee) {
		this.requestId = requestId;
		this.transactionFee = transactionFee;
	}

	public abstract boolean isSuccess();

	public abstract String describe();

	public String summary() {
		return "id=" + requestId + ", fee=" + transactionFee + ", success=" + isSuccess();
	}
}

Using a sealed type: Hierarchies are closed and exhaustive (Java 21+).

public sealed interface PaymentOutcome
	permits PaymentOutcome.Approved, PaymentOutcome.Failed, PaymentOutcome.Pending {

	record Approved(String requestId, String transactionId, long transactionFee) implements PaymentOutcome {
	}

	record Failed(String requestId, String reason, boolean retryable) implements PaymentOutcome {
	}

	record Pending(String requestId) implements PaymentOutcome {
	}
}

// Exhaustive switch: compiler flags errors if 'Pending' is omitted
String handleOutcome(PaymentOutcome outcome) {
	return switch (outcome) {
		case PaymentOutcome.Approved a -> "Approved: " + a.transactionId() + " (Fee: " + a.transactionFee() + ")";
		case PaymentOutcome.Failed f -> "Failed (Retry=" + f.retryable() + "): " + f.reason();
		case PaymentOutcome.Pending p -> "Processing: " + p.requestId();
	};
}


Selection Criteria

modeling Domain States and Events (Java 17+)

For types where the set of possible values is fixed—such as API responses, domain states, or event types—sealed hierarchies are the preferred choice. Combined with Java 21’s switch pattern matching, they provide stronger safety guarantees.

  • Use sealed class when sharing state or implementation is required.
  • Use sealed interface for better flexibility (e.g., when combined with record types).

Sharing State or Logic

If multiple subtypes must share instance fields or common execution flows (e.g., Template Method Pattern) while remaining open for extension, choose an abstract class.

Defining Roles and Contracts

When the goal is to define a behavioral contract (“what a class can do”) or create boundaries for dependency injection and testing, use an interface.

Architectural Synergy

These abstractions often work together. An interface might define a high-level role, an abstract class provides a base implementation, and a sealed type represents the resulting domain objects.

// Role definition
public interface OrderProcessor {
	ProcessResult process(String orderId);
}

// Domain result (Closed set)
public sealed interface ProcessResult permits ProcessResult.Done, ProcessResult.Rejected {
	record Done(long confirmedAt) implements ProcessResult {
	}

	record Rejected(String reason) implements ProcessResult {
	}
}

// Base implementation for shared logic
public abstract class BaseOrderProcessor implements OrderProcessor {
	@Override
	public final ProcessResult process(String orderId) {
		System.out.println("Processing order: " + orderId);
		return doProcess(orderId);
	}

	protected abstract ProcessResult doProcess(String orderId);
}


References