Java / Advanced / Lesson 18
API architecture, records, and evolution
Combine domain invariants, immutable result types, dependency boundaries, version compatibility, and observable failure into a small maintainable Java design.
On this page
Lesson goals
- Separate domain decisions from I/O adapters.
- Use Java 17 records and sealed types with clear limits.
- Evolve an API with compatibility and operational evidence.
Organize around policy and external effects
An application boundary receives untrusted input, authenticates and authorizes, parses values, invokes domain policy, and maps the result to an external response. The domain calculation should not need to know how an HTTP request or database driver encodes its data. Ports describe needed capabilities; adapters implement them for files, databases, or other systems. This separation makes important behavior testable without real services.
Avoid turning every class into a framework abstraction. Add a boundary where an external dependency, replaceable policy, or lifecycle responsibility genuinely exists. Keep transaction ownership, retries, and resource limits explicit across adapters. A clean package diagram does not ensure correctness if an order is saved twice after an uncertain commit or if an authorization check occurs only in one caller.
Represent a closed set of domain outcomes
Java 17 supports records and sealed types as standard features. A sealed interface can restrict allowed implementations, and records provide concise value carriers with generated accessors, equality, hashCode, and toString. The example models Accepted and Rejected outcomes without using null or a magic negative amount. Pattern matching for instanceof is standard here; pattern matching in switch is not used as Java 17 standard syntax.
Records are shallowly immutable. A record component referencing a mutable list still needs copying or a documented ownership policy. Constructor validation remains necessary, and generated toString can reveal sensitive components if logged blindly. A closed hierarchy is useful when the domain owns all variants; an open plugin API may need a different extensibility contract.
public class Main {
sealed interface Result permits Accepted,Rejected {}
record Accepted(int total) implements Result {}
record Rejected(String reason) implements Result {}
static Result price(int quantity,int unit) {
if(quantity<=0 || unit<0) return new Rejected("invalid input");
return new Accepted(Math.multiplyExact(quantity,unit));
}
public static void main(String[] args) throws Exception {
Result result=price(3,4);
if(result instanceof Accepted accepted) {
System.out.println(accepted.total());
} else { System.out.println("rejected"); }
}
}
12
Positive quantity and unit price produce an Accepted total of twelve. A domain rejection is a distinct type rather than a nullable number.
Inject a capability and test policy in isolation
The checkout example depends on a Catalog capability, not on a real database or HTTP client. A synthetic adapter supplies a unit price of 10, and the domain service calculates 20 for two units. The constructor establishes the collaborator and the method validates quantity. Tests can supply another adapter to expose missing products or operational failure without connecting to a configured service.
A fake is useful only when its contract matches the real capability. Keep adapter integration tests for serialization, query binding, transaction behavior, and error translation. Do not let a fake silently return data for every identifier when the real catalog can reject missing items. Distinguish a domain absence from an unavailable dependency so the application can choose a correct response.
public class Main {
interface Catalog { int unitPrice(String code); }
static final class Checkout {
private final Catalog catalog;
Checkout(Catalog catalog) { this.catalog=java.util.Objects.requireNonNull(catalog); }
int total(String code,int quantity) {
if(quantity<=0) throw new IllegalArgumentException("quantity");
int unit=catalog.unitPrice(code);
if(unit<0) throw new IllegalStateException("negative catalog price");
return Math.multiplyExact(unit,quantity);
}
}
public static void main(String[] args) throws Exception {
Catalog catalog=code->10;
Checkout checkout=new Checkout(catalog);
System.out.println(checkout.total("BOOK",2));
}
}
20
The adapter supplies a synthetic unit price. The service performs the quantity calculation without external I/O.
Review source, binary, and behavioral compatibility
An API change can be source-compatible yet behaviorally disruptive, or binary-incompatible for already compiled consumers. Removing a method, changing a descriptor, strengthening input preconditions, altering ordering, or changing exception policy affects callers differently. Version a public contract with representative consumer checks, not only compilation of the producer. Keep deprecation and migration guidance concrete.
Language and platform versions are another axis. Java 25 compact source files and instance main methods do not retroactively change a Java 17 application's launch contract. Libraries intended for 17 should compile with the appropriate release and avoid newer-only APIs. A module export or reflective access change also affects consumers even when business method names remain the same.
Verify the whole boundary without hiding uncertainty
A small order workflow should validate a positive quantity, authorize the selected account, calculate exact bounded amounts, persist through a transaction-owning adapter, and use a durable operation key for retry-sensitive work. External notifications need a coordinated design such as an outbox and idempotent delivery handling. Java exceptions do not undo an already completed remote action.
Test valid and invalid domain inputs, adapter failure, duplicate operation identity, cancellation, and shutdown. Log a stable operation identifier with restricted causes, not secrets or complete personal payloads. The resulting architecture should explain where state lives, who owns resources, how results and failures travel, and what evidence distinguishes confirmed failure from uncertain completion. That explanation is more valuable than adding more layers without a contract.
Common errors and fixes
A record exposed mutable internal data
- Why it happens
- Shallow immutability was mistaken for a deep ownership guarantee.
- How to fix it
- Copy mutable components or choose immutable representations.
- Prevent it next time
- Test caller mutation after construction.
NoSuchMethodError after a library update
- Why it happens
- Compiled consumers and runtime library binaries disagree.
- How to fix it
- Deploy a compatible artifact set or rebuild consumers against the supported API.
- Prevent it next time
- Test the packaged consumer and dependency set.
A retry duplicated an external notification
- Why it happens
- The domain operation and remote side effect lacked a durable coordination contract.
- How to fix it
- Use operation identity and an outbox or equivalent reviewed design.
- Prevent it next time
- Test interruption between delivery and acknowledgement.
Check your understanding
1. A record stores List<String> items from a caller. What protects it from later list mutation?
Show worked solution
Apply a suitable defensive copy such as List.copyOf in the constructor, with its null policy, or document shared ownership explicitly. The record's final component reference alone does not freeze the caller's list.
2. A catalog lookup can return no product or fail because its server is unavailable. Should both become zero price?
Show worked solution
No. A zero price is a valid domain value only if explicitly allowed. Represent not-found separately from operational failure, and preserve the cause at the boundary. Tests must distinguish these outcomes so checkout never creates a free order from a failed lookup.
Key takeaways
- Architecture should expose policy, ownership, and failure contracts.
- Records and sealed types are standard tools in Java 17 with explicit limits.
- Compatibility includes behavior and deployment artifacts.