Spring Beans With Auto-Generated Implementations: How-To
Introduction
Quite a few frameworks implement the automatic creation of components from Java interfaces. The most popular one is probably Spring Data, which is a whole family of data access frameworks. Spring Data JPA, for instance, helps us run JPA queries. Here's a quick example:
public interface ClientRepository extends JpaRepository<Client, Long> {
List<Client> findByNameLikeAndLastActiveBefore(String name, Date date);
}
Without going into detail, Spring Data JPA will create an implementation class from that interface at runtime and register a bean of type ClientRepository
in the application context. The query string will be derived from the method name. The method will run the following JPA query:
select x from Client x where x.name like ?1 and x.lastActive < ?2
That is a nifty declarative API in which method declarations themselves define what parameters to run the framework's underlying functionality with. And we don't have to write any implementation code.
It's a convenient way to integrate libraries and hide boilerplate that they require us to write. As a developer, I'd like to have the ability to build custom APIs that follow such patterns. This would let me build tools that are convenient in use, easily extensible, and specific to my project's business domain.
What We Will Build
In this tutorial, we will build a sample framework that implements the automatic creation of Spring beans from interfaces. The framework's domain will be e-mail and usage will be as follows:
@EmailService
public interface RegistrationEmailService {
void sendConfirmation(@Param("name") String name, @Param("link") String link, @To String email);
void sendWelcome(@Param("client") @To Client client);
}
The conventions are:
@EmailService
marks the interface for the framework to be able to distinguish it from regular interfaces.- What goes after "send" in a method name defines the email subject and body template. In the case of "sendWelcome", for instance, the template will be "welcome.html" and the subject will be "email.subject.welcome" in the message bundle.
@Param
specifies a template parameter to bind a method parameter to.@To
indicates a recipient.
NOTICE: This article as well as the source code on GitHub does not contain any examples of sending emails with Java. The purpose of this tutorial is to provide a sample framework implementation to illustrate the approach.
This tutorial is accompanied by a working code example on GitHub.
How-To
At a higher level, the framework we're about to build is to do three things:
- Find all interfaces marked with
@EmailService
. - Create an implementation class for each of these interfaces.
- Register a Spring-managed bean for each of the interfaces.
We will use a library spring-icomponent as a base (disclosure: I am the author of this library). It covers steps 1 and 3, and partially step 2. In step 2, implementations created by the library are simply proxies that route method invocations to the user-defined handlers. All we have to do as developers is build a handler and bind it to the methods.
Let's start by adding spring-icomponent to a project. The library supports Spring 5.2.0 or newer.
Gradle:
dependencies {
implementation 'org.thepavel:spring-icomponent:1.0.8'
}
Maven:
<dependency>
<groupId>org.thepavel</groupId>
<artifactId>spring-icomponent</artifactId>
<version>1.0.8</version>
</dependency>
To set it up, add the @InterfaceComponentScan
annotation to a java-config class:
@Configuration
@ComponentScan
@InterfaceComponentScan
public class AppConfiguration {
}
This will trigger package scanning. Usage is similar to @ComponentScan
. This configuration will scan from the package of AppConfiguration
. You can also specify basePackages
or basePackageClasses
to define specific packages to scan.
By default, the scanner will search for interfaces annotated with @Component
. The @EmailService
annotation will be meta-annotated with @Component
, so it will be picked up by the scanner with the default settings.
Building a Method Handler
A method handler is to implement the desired functionality, in our case, sending emails. It must implement the MethodHandler
interface:
@Component
public class EmailServiceMethodHandler implements MethodHandler {
@Override
public Object handle(Object[] arguments, MethodMetadata methodMetadata) {
return null;
}
}
The handle
method is supplied with invocation arguments as well as method metadata so that we can derive extra execution parameters from the signature of the method being called.
Recalling the method declaration conventions we've established earlier, a method name is "send" + email type. Email type defines the email subject and body template:
// Get the method name and cut off "send"
// Eg: "sendWelcome" -> "Welcome"
String emailType = methodMetadata.getSourceMethod().getName().substring(4);
// "Welcome" -> "welcome"
String templateName = Introspector.decapitalize(emailType);
// "welcome" -> "email.subject.welcome"
String subjectKeyInMessageBundle = "email.subject." + templateName;
In order to collect the template parameters and recipient email addresses from the invocation arguments we have to iterate through the method parameters:
// Get metadata of the method parameters
List<ParameterMetadata> parametersMetadata = methodMetadata.getParametersMetadata();
// Collections to populate
Map<String, Object> templateParameters = new HashMap<>();
Set<String> emails = new HashSet<>();
for (int i = 0; i < parametersMetadata.size(); i++) {
// Get annotations of the i-th method parameter
MergedAnnotations parameterAnnotations = parametersMetadata.get(i).getAnnotations();
Object parameterValue = arguments[i];
// TODO: Populate templateParameters
// TODO: Populate emails
}
MergedAnnotations
is the Spring's class that represents a view of all annotations and meta-annotations declared on an element, in this case, method parameter.
If a method parameter is annotated with @Param
then it's a template parameter. Here's the source code of @Param
:
@Retention(RUNTIME)
@Target(PARAMETER)
public @interface Param {
String value();
}
Its value
attribute specifies a name of a template parameter to bind the annotated method parameter to. We should populate the templateParameters
map inside the loop:
for (int i = 0; i < parametersMetadata.size(); i++) {
// Get annotations of the i-th method parameter
MergedAnnotations parameterAnnotations = parametersMetadata.get(i).getAnnotations();
Object parameterValue = arguments[i];
if (parameterAnnotations.isPresent(Param.class)) {
// Get the Param annotation then get the value of its `value` attribute as String
String templateParameterName = parameterAnnotations.get(Param.class).getString("value");
templateParameters.put(templateParameterName, parameterValue);
}
// TODO: Populate emails
}
If a method parameter is annotated with @To
then it represents a recipient. Here's the source code of @To
:
@Retention(RUNTIME)
@Target(PARAMETER)
public @interface To {
}
A recipient may either be a plain string or a Client
object:
if (parameterAnnotations.isPresent(To.class)) {
if (parameterValue instanceof String) {
emails.add((String) parameterValue);
} else if (parameterValue instanceof Client) {
Client client = (Client) parameterValue;
emails.add(client.getEmail());
}
}
Now, as we've got all the parameters necessary to send the email, we can send it:
String subject = getMessage(subjectKeyInMessageBundle);
String body = buildFromTemplate(templateName, templateParameters);
emailSender.send(subject, body, emails);
I'm leaving the helper methods out just to keep it brief. You can find them in this tutorial's source code on GitHub. To sum it up, here are all the code snippets put together:
@Component
public class EmailServiceMethodHandler implements MethodHandler {
@Autowired
EmailSender emailSender;
@Override
public Object handle(Object[] arguments, MethodMetadata methodMetadata) {
// Cut off "send"
String emailType = methodMetadata.getSourceMethod().getName().substring(4);
String templateName = Introspector.decapitalize(emailType);
String subjectKeyInMessageBundle = "email.subject." + templateName;
List<ParameterMetadata> parametersMetadata = methodMetadata.getParametersMetadata();
Map<String, Object> templateParameters = new HashMap<>();
Set<String> emails = new HashSet<>();
for (int i = 0; i < parametersMetadata.size(); i++) {
MergedAnnotations parameterAnnotations = parametersMetadata.get(i).getAnnotations();
Object parameterValue = arguments[i];
if (parameterAnnotations.isPresent(Param.class)) {
String templateParameterName = parameterAnnotations.get(Param.class).getString("value");
templateParameters.put(templateParameterName, parameterValue);
}
if (parameterAnnotations.isPresent(To.class)) {
if (parameterValue instanceof String) {
emails.add((String) parameterValue);
} else if (parameterValue instanceof Client) {
Client client = (Client) parameterValue;
emails.add(client.getEmail());
}
}
}
String subject = getMessage(subjectKeyInMessageBundle);
String body = buildFromTemplate(templateName, templateParameters);
emailSender.send(subject, body, emails);
return null;
}
// helper methods omitted
}
The method handler is now complete. In the next section, we will bind this method handler to the methods in the RegistrationEmailService
interface.
Building a Marker Annotation
It's as simple as this:
@Retention(RUNTIME)
@Target(TYPE)
@Service
@Handler("emailServiceMethodHandler")
public @interface EmailService {
}
Two important things here:
@Service
is what makes it a marker. I've previously mentioned that the framework will search for interfaces annotated with@Component
. As you may already know,@Service
is meta-annotated with@Component
, so@EmailService
is also transitively annotated with@Component
.@Handler
specifies aMethodHandler
bean. "emailServiceMethodHandler" is the name of the bean. When@Handler
is declared on an interface, the specified method handler will handle invocations of all methods in the interface.
By decorating an interface with @EmailService
we tell the framework to route invocations of all methods in the interface to the EmailServiceMethodHandler
bean:
@EmailService
public interface RegistrationEmailService {
void sendConfirmation(@Param("name") String name, @Param("link") String link, @To String email);
void sendWelcome(@Param("client") @To Client client);
}
Now, RegistrationEmailService
can be injected into other beans:
@Service
public class RegistrationServiceBean implements RegistrationService {
@Autowired
RegistrationEmailService emailService;
//...
@Override
public void register(String name, String email) {
Client client = createClient(name, email);
persist(client);
emailService.sendWelcome(client);
}
//...
}
Adding New Methods
This tutorial's demo is a Spring Boot project with the Thymleaf dependency included. You can check out the demo project and use it as a playground to follow the steps described in this section.
Let's say the system must email clients about security policy updates and we have to build a component responsible for that. The component's API could be as follows:
void sendSecurityPolicyUpdate(Client client, String policyLink);
The email text will be a template with two variables: client name and link. Here's a snippet of the email template built with Thymeleaf:
<p th:text="'Hello, ' + ${client.name} + '!'"/>
<p>
Our security policy has been updated. Please follow the link for more info:
<a th:href="${link}" th:text="${link}"/>
</p>
If we pick a method name "sendSecurityPolicyUpdate" then, by convention, the template name must be "securityPolicyUpdate". In a Spring Boot project with the default Thymeleaf starter configuration, the corresponding template file must be located at "resources/templates/securityPolicyUpdate.html". So we have to create the file and copy and paste the template text into it.
In order to define the email subject, we have to add a message to the message bundle with a key "email.subject.securityPolicyUpdate":
email.subject.confirmation = Confirm your e-mail address
email.subject.welcome = Welcome to the app
email.subject.securityPolicyUpdate = Security policy updated
The message bundle is located at "resources/messages.properties" in Spring Boot projects by default.
The setup is complete so we can now create a new component for the policy emails:
@EmailService
public interface PolicyEmailService {
void sendSecurityPolicyUpdate(@Param("client") @To Client client, @Param("link") String policyLink);
}
Here's a usage example for the new component:
String link = policyService.getPolicyLink();
clientRepository
.getAllClientsAsStream()
.forEach(client -> policyEmailService.sendSecurityPolicyUpdate(client, link));
Conclusion
Pretty quickly, we've built a small and simple email sending tool with business domain-specific, easily extensible API. This is just a sample intended to illustrate the approach that could help in reducing boilerplate code but, on the other hand, has some extra code overhead. So it's "this much code vs that much code" decision. When you, once again, are considering building a wrapper around some client API to simplify its usage in certain patterns, this is when this approach may potentially be beneficial.
This tutorial's source code is available on GitHub.