Dynamic Wizard
Online insurance businesses face the enormous task of converting established products that were previously sold by a broker or sales agent into viable online products sold directly to consumers. Viability in this scenario means that a user can configure and understand an insurance offer without the help of a professional. So for one of our customers, our team set out to build a website selling insurance online.
During user tests, we determined a wizard would best serve this purpose. According to Wikipedia, “A software wizard or setup assistant is a user interface type that presents a user with a sequence of dialog boxes that lead the user through a series of well-defined steps.”
What It Is
On the website we built, a wizard walks the user through each step, and once the user completes all the necessary sections, they’re shown an overview and a price. That overview allows users to include and exclude certain types of coverage and adjust insurance sums and performance limits.
At this point, the user might purchase the offer outright. It’s also possible for them to save it as a PDF, which can be signed and sent in by mail, taken to a salesperson, or used for comparing offers from other companies. In addition, the user receives an email containing a link to edit the offer if necessary.
The Implementation
The base of this insurance calculator is a backend that’s designed to be similar to tools insurance salespeople use. This backend provides the calculation, validation, and definition of the insurance product itself and therefore is already doing the lion’s share of the work.
The insurance is sold to small and medium enterprises, and this presents our first challenge. In Switzerland, businesses specify their field of work with a so-called NOGA code, which — in our case — defines the coverage available to the business. When it comes to configuring the offer for the user, the available coverage determines what information has to be provided.
Due to these many variables, the set of questions in the wizard is dynamic. To mount this technical challenge, we make heavy use of Spring Beans and cglib proxies. We built our React frontend and Spring backend in a very modular way to make it maintainable for developers.
To gather the information required to show the user a premium, and to create an accurate offering, the user is asked a set of questions determined by the coverage available for a given NOGA code. This is where we chose to implement our backend as modularly as possible, dividing all the components into separate classes.
In addition to questions, we categorized our logic into defaults, answers, offer components, and attributes.
Before explaining these components in more detail, it helps to understand the structure of the offer being configured. The offer is essentially a big JSON object with a recurring pattern.
The image above is an illustration of this kind of recurring object. The array of successors holds 0..n
objects that follow the same structure as shown in the image. An object like this can represent a coverage or a hierarchical category in the offer, where the leaf usually represents a coverage.
Attributes are the typical way of “configuring the offer,” where the level in the tree defines the type of configuration — for example, a root node is a broad configuration for the whole offer, while a leaf node configures coverage details. The attribute and its values allow for a dynamic implementation, since the boundaries of a value are well-defined and interpretable.
Now, let’s look at an overview of the components maintained in our code.
Question
As the name suggests, this represents something we ask the user to gather information. On the business level, a question consists of a title, an explanation, an icon, and an input. The title is the question itself — for example, “How many people are currently employed at your company?” The explanation gives context as to why we’re asking this question, as it might not be clear to a user unfamiliar with the insurance trade.
On the technical side, the question inherits from an interface along these lines:
interface IQuestion {
boolean show();
Question produceQuestion();
Update recommend();
Update delta();
}
show()
contains the logic of whether a question has to be displayed to a user.
produceQuestion()
returns an object containing the necessary information to render the question (text keys, icon references, input definition).
recommend()
is called after the user went through all the questions to determine how the offer has to be changed.
delta()
is similar to recommend()
, and it’s used to generate the update statements for when the user navigates back to change previously given answers.
All of these methods incorporate custom logic that dictate how a question behaves — for example, by preventing redundancy in questions. They also all allow for injection of the other components to accommodate the logical parts.
Defaults
Defaults are general updates our team deemed necessary based on a user’s scenario. For example, consider someone who owns a bakery, employs five people, and expresses interest in liability insurance. Based on thee factors, their estimate would automatically include specific types of coverage that a different kind of company with a different amount of employees and different insurance interests might have. These defaults can also be determined by custom logic.
Answers
The answers given by the user can also be injected to customize the logic in the questions. In other words, sometimes the logic in a question regarding an update depends on the answer to another question.
Offer Components
These components each represent an object from the offer JSON, which provides information about the range limits and allowed values for attributes, as well as information as to whether or not a component is visible, selectable, or editable.
Attributes
Just like offer components, attributes are mainly for convenience. Attributes are part of a component and are located on an offer component.
Our User Journey
Putting all of this together allows us to create clean services that orchestrate the main workload. This is how a user journey might look:
- User enters basic information
- Application spins up a basic offering
- Questions are generated
- User answers questions
- All the answers are passed back to the backend
- The offer gets updated to reflect the user’s information and our suggestion
To elaborate further on these steps and also show some code, let’s first look closer at the second step, which is that of the application showing the offer. The offer initially has a selection of coverages included, but most aren’t active. Since the offer is presented as a tree, rather than simply deactivate branches and leaves that aren’t active, the product engine removes them from the tree. That leads to the application having no idea about the children of deactivated nodes.
For that reason, we enable every single node in the offer recursively to get the total available nodes for a given offer. This will serve as the base for generating questions. Since the children of disabled nodes are unknown, this step takes multiple requests, which is of course a performance downside.
Next, the questions are generated, and thanks to Lombok and Springs dependency injection, that service looks amazingly simple:
@Service
@RequiredArgsConstructor
public class QuestionService {
private final List<IQuestion> questions;
public Flux<Question> getQuestions() {
return Flux.fromIterable(questions)
.filter(IQuestion::show)
.map(IQuestion::produceQuestion);
}
}
The component, representing a question, will look like this:
@AQuestion
public class Q1 extends IQuestion {
@HandledMethod
protected boolean show(ABC abc, Q2 question2, Inventory inventory) {
return abc.isAttributeVisible(AttributeName.GLASS_BREAKAGE) && !question2.show(inventory);
}
@HandledMethod
protected Question produceQuestion(Building building) {
return QuestionPreset.builder()
.id(this)
.valueDefinition(building.getValueDefinition())
.textKey("text_myQuestion_title")
.imageRef("myImage")
.build();
}
@HandledMethod
protected Update recommend(Building building, Q1Answer q1Answer, Q3Answer q3Answer, Q3 question3) {
var customValue = q3Answer.isNull() ? q1Answer.getValue() : q3Answer.getValue();
return Update.builder()
.componentId(building.getId())
.value(customValue)
.dependency(question3)
.build();
}
}
The show method here accepts an offer component, a question, and a second offer component. The logic here is pretty basic. Like most of the questions, this one relates to the GLAS_BREAKAGE
of the BUILDING
component. Visibility is also the most common requirement for a question to show up. This question is deemed showable if the attribute is visible and the second question isn’t shown.
There are two things to note on the produceQuestion
method: First, the ID is set to this
. It’ll become clearer when looking at the dependency property in the update. The second interesting thing is that the valueDefinitions
, in most cases, are taken directly from the offer component, since no modifications are needed.
Since every update has a reference to the class producing the update in the recommend function, we can pass a reference to the class as a dependency. With all the updates combined in a list, we process them all with no dependency, keep track of updates processed, and postpone all the updates with a dependency not yet processed.
Finally, there’s the recommend method. Here, the answers of two questions are checked to see what to update. As mentioned before, the dependency here lets the service know which updates to prioritize.
As you might have noted, these signatures make no sense. How can the QuestionService
call .filter(IQuestion::show)
but each question can have 0..n
arguments, depending on the question’s logic?
The simple answer is proxies. The secret lies in the two annotations @Question
and @HandledMethod
. Since it isn’t perfectly clear from the code itself, as it has none of the typical bean annotations (@Component
, @Service
, or @Controller
), all the questions are beans. All the components that we handle are beans, and most are scoped as prototypes
, with a few being singletons
.
So How Does It Work?
Using Spring’s ClassPathScanningCandidateComponentProvider
, all the custom components are found, wrapped by the proxy factory, and given over to Spring in the form of BeanDefinitions
. The class path scanner returns a list of BeanDefinitions
that are modified and given back to Spring for instantiation. Spring offers BeanDefinitionRegistryPostProcessor
to support modification of bean definitions prior to their instantiation:
@Configuration
@RequiredArgsConstructor
public class QuestionComponentRegister implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
provider.findCandidateComponents("ch.aaap.insurance").forEach(beanDefinition -> {
try {
assert (beanDefinition.getBeanClassName() != null);
var clazz = Class.forName(beanDefinition.getBeanClassName());
var proxyBeanDefinition = new GenericBeanDefinition(beanDefinition);
var args = new ConstructorArgumentValues();
args.addGenericArgumentValue(clazz);
args.addGenericArgumentValue(new QuestionInvocationHandler(clazz));
proxyBeanDefinition.setConstructorArgumentValues(args);
proxyBeanDefinition.setFactoryBeanName("InvocationHandlerProxyBeanFactory");
proxyBeanDefinition.setFactoryMethodName("createInvocationHandlerProxy");
registry.registerBeanDefinition(beanDefinition.getBeanClassName(), proxyBeanDefinition);
} catch (ClassNotFoundException e) {
log.error("Failed to find Class", e);
}
});
}
}
With proxyBeanDefinition.setFactoryBeanName
and setFactoryMethodName
, we define the proxy wrapping all the questions. The proxy itself is a cglib Enhancer
that allows us to intercept method calls to questions and inject them into the questions with the arguments needed:
@RequiredArgsConstructor
public class QuestionInvocationHandler implements InvocationHandler {
private final Map<String, CustomMethod> methodMapping;
public QuestionInvocationHandler(final Class<?> clazz) {
methodMapping = MethodIntrospector.selectMethods(clazz, this::filterMethods).keySet().stream()
.map(CustomMethod::new)
.collect(Collectors.toMap(CustomMethod::getMethodName, Function.identity()))
;
}
private String filterMethods(Method method) {
HandledMethod mergedAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HandledMethod.class);
return mergedAnnotation != null ? method.getName() : null;
}
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
var handledMethod = methodMapping.get(method.getName());
if (handledMethod == null || handledMethod.getMethod().equals(method)) {
return methodProxy.invokeSuper(o, args);
}
Assert.isInstanceOf(ParameterTypeResolver.class, args[0]);
ParameterTypeResolver resolver = (ParameterTypeResolver) args[0];
handledMethod.getMethod().trySetAccessible();
return handledMethod.getMethod().invoke(o, resolver.resolveArguments(handledMethod.getMethodParameters()));
}
}
This is the InvocationHandler
intercepting the method calls, ignoring methods that aren’t annotated with @HandledMethod
, and resolving the custom arguments if they are. The resolver is passed as the only method argument to HandledMethod
. It’s created on requests that need access to the components, and it receives a reference to all the components that can be resolved. It’s then added to the reactor context. Here’s the real interface:
interface IQuestion {
boolean show(ParameterTypeResolver resolver);
Question produceQuestion(ParameterTypeResolver resolver);
Update recommend(ParameterTypeResolver resolver);
Update delta(ParameterTypeResolver resolver);
}
Why Are Some Components Scoped as Prototypes?
Some components are scoped as prototypes so that we can have the same injection as with the singletons, using Spring dependency injection. It also allows us to let Spring deal with the instantiation of those classes without knowing about all of them ourselves. As it would result in a new instance for every injection done with a prototyped bean, we make sure that this happens only once per request. Our application runs in a reactive servlet context, and therefore, we don’t have ThreadLocals
available. Alternatively, we use the ContextView
from WebFlux. We’ve set up a filter that initializes a HandlerAdapter
, which is the main object in the context. The adapter maintains a list of handlers, each having its own initialization logic. Part of the interface for the handlers includes two methods called List<IComponent> produceComponents()
and Map<Class, MethodMapping> produceMethodMappings()
. The purpose of these methods is to create a resolver that will handle our custom dependency injection:
protected AbstractMap.SimpleEntry<SmeComponent, Map<String, HandlerMethod>> produceMethodMappings(Component component) {
return new AbstractMap.SimpleEntry<>(component,
MethodIntrospector.selectMethods(component.getClass(), this::filterMethods).keySet().stream()
.map(method -> new HandlerMethod(component, method))
.collect(Collectors.toMap(HandlerMethod::getMethodName, Function.identity()))
);
}
private String filterMethods(Method method) {
HandledMethod mergedAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HandledMethod.class);
return mergedAnnotation != null ? method.getName() : null;
}
Conclusion
Taking these measurements helped us increase the readability of question logic drastically. It also helped us reduce the amount of side effects the logic in a question can have on other questions. We started out with a huge switch case statement, where side effects were a constant problem, so since then, we’ve come a long way. Future features will include question groups, where a bunch of questions that ask the same thing can be asked once but updated in multiple destinations.