Definition
- Same named module – permitted subtypes may be in different packages of that module, because the module boundary provides strong encapsulation.
- Classpath (unnamed module) – all permitted subtypes must be in the same package as the sealed type, because the package is the only encapsulation unit.
- Cross-module – never allowed; a sealed hierarchy cannot span two different modules.
- Compile-time and load-time – the rule is enforced by the compiler and re-validated by the JVM's
PermittedSubclassesattribute check.
Imagine National Bank of India's head office declares the closed list of account products. If the whole bank operates as one unified organisation (a module), head office may place the approved products in different departments (packages) across that organisation. But if the bank is just a loose collection of branches with no central organisation (the classpath), head office can only approve products kept in the very same department as the master policy document. And an approved product can never belong to a completely different bank (a different module).
Understanding the Concepts
What is the universal rule for sealed-type location?
All permitted subtypes must be in the same module as the sealed type. A sealed hierarchy is a closed, author-controlled design, and allowing it to cross module boundaries would let another module insert itself into your closed family. The compiler never permits a subtype from a different module.
// module-info.java for com.bank.accounts
module com.bank.accounts {
exports com.bank.accounts.api;
// com.bank.accounts.products is NOT exported (internal)
}
How do packages work within a named module?
Within a named module, permitted subtypes may be in different packages. The module is a strong encapsulation boundary, so the author controls everything inside it.
// In package com.bank.accounts.api (exported)
package com.bank.accounts.api;
import java.math.BigDecimal;
public sealed interface Account
permits com.bank.accounts.products.SavingsAccount,
com.bank.accounts.products.CurrentAccount,
com.bank.accounts.products.LoanAccount {
String id();
BigDecimal balance();
}
// In package com.bank.accounts.products (internal, not exported)
package com.bank.accounts.products;
import com.bank.accounts.api.Account;
import java.math.BigDecimal;
public final class SavingsAccount implements Account {
private final String id;
private final BigDecimal balance;
public SavingsAccount(String id, BigDecimal balance) {
this.id = id;
this.balance = balance;
}
@Override public String id() { return id; }
@Override public BigDecimal balance() { return balance; }
}
The sealed interface is exported for callers, but the concrete products are internal. This is a clean architectural pattern.
What happens on the classpath (unnamed module)?
On the classpath, all permitted subtypes must be in the same package as the sealed type. Without a module boundary, the package is the only encapsulation unit the compiler can rely on.
// All in the same package: com.bank.model
package com.bank.model;
import java.math.BigDecimal;
public sealed interface Account
permits SavingsAccount, CurrentAccount {
BigDecimal balance();
}
final class SavingsAccount implements Account {
@Override
public BigDecimal balance() {
return new BigDecimal("500000");
}
}
final class CurrentAccount implements Account {
@Override
public BigDecimal balance() {
return new BigDecimal("250000");
}
}
// WRONG: different package on classpath
// package com.bank.products;
// final class LoanAccount implements com.bank.model.Account { }
// COMPILE ERROR: class is in a different package from its sealed supertype
Why are cross-module sealed hierarchies forbidden?
If a sealed hierarchy could span modules, an external vendor module could inject a new permitted subtype into your closed set, defeating the entire purpose. The compiler and JVM both enforce same-module containment.
// module com.vendor.extensions
// CANNOT add a subtype of com.bank.accounts.api.Account
// because Account is sealed in module com.bank.accounts
// final class VendorProduct implements Account { }
// COMPILE ERROR: not in the same module as the sealed type
How does the JVM validate the rule at load time?
The compiler records the permitted subtypes in the PermittedSubclasses class-file attribute. The JVM checks at class loading that each subtype actually extends the sealed type and is in the same module. This prevents bytecode tricks from smuggling in cross-module subtypes.
package com.bank.model;
import java.util.Arrays;
public class ModuleValidationDemo {
sealed interface Account permits Savings, Current {}
final record Savings() implements Account {}
final record Current() implements Account {}
public static void main(String[] args) {
System.out.println("Sealed: " + Account.class.isSealed());
System.out.println("Module: " + Account.class.getModule());
Arrays.stream(Account.class.getPermittedSubclasses())
.forEach(c -> System.out.println(
" " + c.getSimpleName() + " in " + c.getModule()));
}
}
What are the practical consequences?
Modularising your application unlocks cleaner package organisation for sealed hierarchies. A library author shipping a modular JAR can spread a sealed type and its subtypes across several packages for readability, exporting only the API package. A non-modular application on the classpath must keep the whole family in one package.
| Environment | Package Rule | Cross-Module |
|---|---|---|
| Named module | Different packages OK (same module) | Never allowed |
| Classpath | Same package required | Never allowed |
When should you modularise for sealed hierarchies?
When you have a large sealed domain model (many account products, transaction types, or regulatory states), modularising lets you organise the subtypes across clean packages while maintaining the closed-set guarantee. This is exactly what production banking systems need.
// Module layout:
// com.bank.accounts/
// com.bank.accounts.api/ (exported)
// Account.java (sealed interface)
// com.bank.accounts.products/ (internal)
// SavingsAccount.java (final)
// CurrentAccount.java (final)
// LoanAccount.java (sealed)
// com.bank.accounts.loans/ (internal)
// HomeLoan.java (final)
// CarLoan.java (final)
What should a senior engineer know?
The same-module rule is enforced both at compile time and at load time through the PermittedSubclasses attribute. You cannot smuggle in a cross-module subtype through bytecode manipulation. This means sealed hierarchies have genuine integrity within the module boundary. The module system and sealed types reinforce each other: modules provide encapsulation, and sealing provides a closed type family within that encapsulation.
Visual Overview
┌─────────────────────────────────────────────┐ │ Sealed Hierarchy Package Rules │ ├─────────────────────────────────────────────┤ │ │ │ Named Module (com.bank.accounts) │ │ ┌──────────────────────────────────┐ │ │ │ pkg: api/ → Account (sealed)│ │ │ │ pkg: products/ → Savings (final) │ │ │ │ pkg: products/ → Current (final) │ │ │ │ pkg: loans/ → HomeLoan (final)│ │ │ │ Different packages: OK ✓ │ │ │ └──────────────────────────────────┘ │ │ │ │ Classpath (unnamed module) │ │ ┌──────────────────────────────────┐ │ │ │ pkg: com.bank.model/ │ │ │ │ Account (sealed) │ │ │ │ Savings (final) │ │ │ │ Current (final) │ │ │ │ Same package required ✓ │ │ │ └──────────────────────────────────┘ │ │ │ │ Different Module (com.vendor) │ │ ┌──────────────────────────────────┐ │ │ │ VendorProduct extends Account? │ │ │ │ NEVER allowed ✗ │ │ │ └──────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────┘
Key Takeaway
The answer depends on modules. The universal rule is that all permitted subtypes must live in the same module as the sealed type. A sealed hierarchy can never span two different modules, because that would let an external module insert itself into a closed family.
Within a single named module, permitted subtypes may be in different packages. The module is a strong encapsulation boundary, so the author controls everything inside it. In a banking project at National Bank of India, Pankaj puts the sealed Account interface in an api package and the concrete products in a products package. Both are in the same com.bank.accounts module, so the cross-package permits compiles cleanly. He exports only the api package, keeping the products internal.
On the plain classpath (unnamed module), the rule is stricter: all permitted subtypes must be in the same package as the sealed type. Without a module boundary, the package is the only encapsulation unit the compiler can trust. Trying to permit a subtype from another package gives a compile error.
The practical lesson is that modularising unlocks a cleaner package layout for sealed hierarchies while still keeping them strongly closed. The module boundary also means an outside vendor module can never inject a new permitted account type into a sealed Account, so the product set stays protected. And because the permitted set is recorded in the class file and re-validated by the JVM at load time, nobody can smuggle in a cross-module subtype through bytecode either.
Remember one sentence: same module, different packages is allowed; classpath requires the same package; different modules is never allowed.
---