
There are many guides on achieving the right architectural balance and multiple thoughts about the ‘right’ way. In this article, I want to distil some of this thinking, apply it to Java API design, and use modern Java features. Historical patterns like AWT or RMI often influence older Java API design thinking, and it’s time to remind readers that modern Java features offer new, more powerful, and frequently safer ways to design APIs.
History
We’ve seen many approaches to API design ever since the first computer. At their heart is the desire to create an architecture that balances simplicity over adaptability and future enhancement. As a Java developer, you’ll often hear references to some standard terms:
Separation of Concerns: This fundamental design principle advocates separating code into distinct sections, each addressing a separate concern.
Strategy Pattern: In design pattern terminology, particularly from the “Gang of Four” book, the Strategy pattern encapsulates different strategies in separate classes and allows them to be swapped easily without affecting the context
Inversion of Control (IoC): While typically associated with dependency management, IoC is also about decoupling task execution. It creates a hard barrier between code, what the application does, and what the framework provides.
Command Pattern: This pattern separates the action’s requester from the object that executes the action. Command objects encapsulate a request as an object, letting you parameterise clients with different requests, queue or log requests, and support undoable operations.
Delegation Pattern: This pattern involves two objects where one object handles a request by delegating to a second object (the delegate).
There are many other patterns. This is a rich vein of discussion, and there are many opinions.
Lines in the Sand
All the great concepts above have tremendous value, but where those architectural lines begin to melt away is when we talk about user ‘participation’. Any design requires thinking about how the user interacts with the API. How do we draw hard lines and create firm boundaries when code can be malleable and developers are inventive?
Often, our design requires or invites participation in unexpected ways. Ways we didn’t intend or expect.
Some programming languages make creating firm boundaries challenging, but Java has many features that can be used to create boundaries that are more than lines in the sand.
In this article, I want to extract an underlying principle or two and look at what we have available to create robust designs. Designs that allow just the right sort of participation without too much opportunity to directly or inadvertently compromise our API.
Classes and Interfaces
Whatever design pattern you follow or the framework you use, you still write code.
The Java API design toolkit we use essentially consists of Classes and Interfaces. We might use Annotations to signal requirements to containing systems, but, as the name suggests, they are annotations and do not have to be honoured.
As an API designer, you will consider how someone consumes the API, what constraints you must apply, and where. Many functional and nonfunctional elements, such as performance, security, observability, etc., must be considered, and you must decide how the API consumer will participate in all of these factors.
Participation – it’s not a spectator sport
Participation means assessing how much the API user is a consumer rather than a provider. Historically, the way we use Java classes and interfaces has remained unchanged.
Common approaches include
- Using the provider pattern to allow objects to be ‘magically’ instantiated. i.e. via IoC or a Java Service Provider mechanism.
- Providing an interface and Javadoc to explain expected behaviour for each method
- Providing an abstract class for others to subclass and fill-in-the-blanks.
- Providing a concrete class that someone can subclass to override behaviour
- Providing a concrete class that has a constructor with parameters
- Providing a concrete class and a selection of setter methods – the JavaBean pattern
- We might occasionally use the Builder pattern to allow the instantiation of a complex object.
Force Fitting
We assume (and occasionally even try to force) how the end user will participate in all these standard techniques.
IoC tries to force the user to be only a consumer. Builder patterns or final classes also attempt to force consumption only, albeit with some flexibility in configuration. Meanwhile, abstract classes and plain interfaces invite the user to participate in the API’s internal behaviour as both a producer and a consumer.
The JavaBean pattern is a dreadful mix of consumer and producer but highlights the other aspect of participation: understanding one’s place in the process.
Ideally, once an object is instantiated, it’s fully configured, and any other method calls are to retrieve data or transform the object’s state. What the JavaBean model does is blur configuration with use. Any setter method can be called at any time, so the implementation has to deal with the chance of its configuration being modified during use. That’s a challenge and a ready source of nasty bugs.
The API design invites a particular form of user participation. JavaBeans are bad practice, and so is using subclassing as a consumption principle. Whether an abstract or concrete class, the requirement that the user use subclassing to participate must be revised.
This approach requires the user to know much more about the API’s work; the user code is now part of the API. The user code must understand its responsibilities and its place in the process. It needs to understand what state its parent is in, when it will get called, and what it’s allowed to do when accessing the parent state.
Overriding or implementing methods as an API design choice forces the user to become both producer and consumer, encouraging them to override other methods or dive into the parent class’s internals. The tie between user and API is now strongly coupled and personal.
Let’s talk dishwashing machines.
Examining one, we can see that simplistically we have a few buttons and knobs that form the control panel, and we interact with the machine by loading salt, coupling up power, water and drainage and then regularly loading the machine with the dishwasher tablets and, of course, dishes.
We can see three sections by distilling this physical design into software principles.
Configuration: Where the machine is plumbed in and connected to a power supply.
Policy: The control panel allows the user to describe what they want the machine to do.
Process: Loading, policy execution, washing the dishes, Unloading etc.
Note how the dishwasher users’ participation is controlled and anticipated. Of course, they must remember to load dishes or tablets and not overload the dishwasher. Many of the common mistakes are discoverable by the machine. Open the door, and the machine stops. No tablets or softener, etc., so a light goes on. Depending on how clever your machine is, it is more likely to defend against common mistakes.
At no point in these activities is there any expectation or ability for the user to step outside their part of the process. There is a clear separation between what the machine does and what the user does.
The user doesn’t get involved in the actual act of dishwashing. They support the machine in fulfilling its function. The user selects a policy to tell the machine what sort of washing to do, provides dishes to wash and then extracts the clean dishes.
Configuration, Policy and Process
This model is great for API designers to follow. Thanks to recent additions to the Java language, we now have the tools to follow this pattern to deliver robust, secure APIs and control user participation.
Configuration is where the environment is discovered, and elements that affect all usage are gathered. This might be via a pre-instantiated builder class, loaded by IoC, etc., or the parameters passed into a constructor. Think of them as ‘rules of physics’ that are fixed for the API during its lifetime in the JVM.
Policy is like the washing machine control panel, the rules that this particular instance of the API will follow. Policy declares what you want the API to do, not how it does it or what data it will process. A policy is a reusable but immutable object. Think of it akin to something like a declarative contract.
Process is the well, process of executing the policy on a particular data set. The process uses information from the policy and configuration to do its job. In Java terms, the policy might contain selection criteria for data as a predicate. The process uses this predicate internally but cannot interact with any other part of the process. It’s isolated and contained.
Clean Design
The separation of configuration from policy and policy from process is an essential concept that promotes cleaner, more modular, and maintainable code. This principle contrasts with some of the older design paradigms in Java, where the lines between these elements were often blurred, leading to complicated codebases that needed to be revised to maintain and extend.
For example. older Java designs, such as those found in the Abstract Window Toolkit (AWT), encouraged a blending of policy and process. Developers often had to subclass abstract classes or override methods to integrate with an API. This design forces developers to engage deeply with the internal workings of the classes they are extending, leading to tightly coupled code that is difficult to manage and prone to errors.
Having to have special knowledge of the working of related classes – especially about method call order, expected responses , even what exceptions to throw when is not only nonoptimal it’s both fragile and restrictive. Modularity and separation of concerns become diluted and the end result is a API in name only.
Practical Implementation in Java – expanding the designer’s toolkit
I hinted a little about using predicates above, but that’s only a part of what is available in modern Java. Java gives us classes, interfaces, lambdas, streams, enums and records today. Add in inner classes, sealed classes and even sealed jars, and we have an extensive palette of tools that allows us to deliver better and cleaner APIs.
There is too much to unpack in this article alone, so let’s look at a simple worked example to give you a taste.
Scenario
The scenario is that of an API that can visit a tree structure and stream the contents. There is an implicit visitor pattern here, but it’s hidden away. The consumer’s experience is more straightforward.
Configuration
What configuration might we have for a tree streamer? What elements would we expect to be needed to be fixed in place for the lifetime of a Java process?.
In general we’re looking at non-functional aspects and default values.
A fundamental question for any API designer is how the first object is instantiated and populated. In Java frameworks it’s common to see IoC patterns or service-loader approaches where a default constructor is called. At load time, these IoC or service loaded classes will have to discover their detailed configuration. That might come from system properties, envvars or other system configuration methods.
That means that the separation between configuration and policy is already blured but even so , it’s important to be clear about what is fixed and what is flexible. It may be that rather than using IoC or service loaders to instantiate API objects, its feasible to instead instantiate and API builder.
For or example , configuration will cover default values (such as rate limiting) and expensive discovered system configuration that should only be done once. It might be that our tree streamer has preferences to , say, the HTML parsers to use. Our code might be smart enough to detect what is available on the classpath and decide accordingly. That is certainly something to decide at configuration time. Whether this decision is flexible or not becomes a policy question.
Policy
Let’s define the policy for navigating and traversing a tree structure. We need to know how to interpret a node in the tree as a parent and decide which nodes to visit. Beyond that, there might be particular types of trees we’re going to visit that will have other policy requirements. If the tree is a view over a website., where we’re streaming the URLs we discover, we’ll want to add rate limiting and possibly a depth limit. If we’re streaming a file system, we might want to keep the depth limit, but rate limiting is probably not useful.
Builder Pattern – Part of the API
Since this scenario is complex, our policy will be created via a builder pattern. Unless the policy is simple, using a builder gives us more flexibility to provide an interface that is easier to police for misconfiguration and easier for the consumer to understand.
import static NavigatorPolicyBuilder.builder;
NavigatorPolicy shortPolicy = builder()
.rateLimit(100, Duration.ofMinutes(1)) // play nice
.defaultReader(new HTMLRefNavigator())
.maxDepth(2)
.build();
In this example, we’re setting the policy on how to navigate a website. We’ve created a policy that we can reuse. The builder pattern (using fluent style ) is easy to understand and validate, resulting in a reusable but immutable policy object. The builder is part of the API but is not a process object.
Secret Sauce
In this scenario, NavigatorPolicy is a Java Interface. Until recently, that would allow anyone to implement it and subvert any restrictions we might put in place via the builder. With the advent of Java 17 and the arrival of the ‘sealed class’ feature, we now have control over who can implement the interface.
This is important because we want the NavigatorPolicy object to be read-only and remove any public ways of changing its data. Therefore, we must separate the mutator methods from the interface and hide them.
Sealed classes let us define which classes classes can implement an interface and which can subclass it. Only those on the permitted list can be implementors or subclasses.
The code for the Navigator Policy interface looks like this.
public sealed interface NavigatorPolicy permits AbstractNavigatorPolicy {
LinkReader handler(Connection.Response r);
}
This code indicates that only the AbstractNavigatorPolicy class can implement it. The AbstractNavigatorPolicy class definition includes
public abstract sealed class AbstractNavigatorPolicy implements NavigatorPolicy permits NavigatorPolicyBuilder.MyUriPolicy {
This shows that it implements the sealed interface and is, in turn, itself sealed. Allowing only an inner class of the builder to subclass.
MyUriPolicy looks like this below. Thats it. Note the final setting and private constructor.
public static final class MyUriPolicy extends AbstractNavigatorPolicy {
private MyUriPolicy() {
}
}
The MyUriPolicy is an inner class inside the builder NavigatorPolicyBuilder. Thus only the builder can instantiate this particular policy class since it alone has access to the private constructor. Making the class final ensures that there is no opportunity for the behaviour of the policy object to be subverted though subclassing.
What might have escaped your notice is that the MyUriPolicy class is hollow. It has no functionality. The AbstractNavigationPolicy class implements the behaviour. This pattern allows us to keep control over who can implement and instantiate our API classes while allowing us to keep the functionality in a separate class file (AbstractNavigatorPolicy.java) and not clutter up the builder with long, complicated inner classes.
Class Relationships
This code pattern of a sealed interface + sealed abstract class + inner class concrete (hollow) class provides good code separation and strong controls on how they interact and can be used.
The API consumer has a NavigationPolicy that can be interrogated but not overridden or replaced. Without using reflection, the consumer cannot discover policy internals.
From the API designer’s POV, the builder is part of the API, which is evident in its intention, yet the implementation details are hidden away and can not be changed. The builder might return MyUriPolicy this time, but maybe a change in the future means that the next time build() is called, it returns YourUriPolicy.
The consumer is not affected, and the API is rigorous yet evolvable. Builders, especially using a fluent approach become a new form of API. One that is more open to change than traditional Java APIs expressed in interfaces and classes directly. Let’s see that in action.
Process Time – STREAMS and things
Here’s a related code set for using the API to do work.
‘base’ is the URI the API is going to visit. The policy object ‘shortPolicy’ comes from above.
URITreeSteamVisitorBuilder.newInstance(base)
.policy(shortPolicy)
.select()
.on(Link.class)
.consume(this::handleLink)
.visit();
The first thing to note is that we have a reasonable DSL-like interface using a fluent style with judicious inner classes. Next, the consumer’s involvement with the process is controlled. The consumer is effectively on the end of a stream of data.
In this case, the API allows some inline filtering for architecture and performance reasons. Still, it could have been written to return a stream and have the filtering and processing to be done entirely by the consumer.
We did not need to ask the consumer to provide a custom class other than the callback for received data.
Let’s expand our use of this builder to see how much we can do with this style of API design.
URITreeSteamVisitorBuilder.newInstance(base)
.policy(shortPolicy)
.select()
.on(Link.class)
.consume(this::handleLink)
.on(Meta.class)
.consume(this::handleMeta)
.otherwise()
.consume(o -> {System.out.println("other:"+o);})
.visit();
Remember this is all valid Java code. Now, we have an additional on() clause and an otherwise() method. Hopefully, you can see how this mirrors a select expression. Again, the API provides ways for the consumer to participate in the process, but as an consumer, The internals are kept hidden away. The API is not just a simple class or interface. The API here is a fluent style, DSL-like mechanism that is flexible and extensible while allowing the API designer control over behaviour and interaction.
A bonus is that this style works well with IDEs; the same code seen in the IDE looks like this.

Note all the additional info about the intermediate classes of this DSL-like construction. This really helps with understanding the available options as the DSL is used and gives you a hint of how the inner classes are stiched together internally.
DSL-Like is your new API
From an API design POV, using sealed classes to add extra controls on who can do what with your code is compelling. You could stop there, having already reduced the chances of your API being deliberately or accidentally compromised.
Including the builder approach (with the same sort of DSL-like structure) takes the API design into new territory and transforms it from a static Java interface or class into something more dynamic.
Now, your API can be a sophisticated builder that can be extended in ways that a simple Java interface or class would struggle to match.
Conclusions
I hope I’ve given you a taste of what can be achieved using Java’s features.
Using Inner classes, Sealed Classes, Lambdas, Streams, Enums, etc., and by thinking again about how we separate configuration and policy from the process, it’s possible to create APIs that provide clear, easy-to-use yet enforceable designs that are hard to compromise but can be extended and enhanced.

Want to Dive Deeper?
Steve Poole is speaker at JCON!
This article covers the topic of his JCON session. If you can’t attend live, the video of the session will be available after the conference – it’s worth checking out!