Introduction
When webhooks are received from third-party systems, technical challenges arise quickly. This article shows how to solve them with the Strategy Pattern in Spring Boot and how a second Strategy Pattern ("Sub-Strategy") represents the event level.
The code in this article comes from a real project and is reduced to the essentials. I left out configuration, error handling, the actual persistence and further details to focus on the integration of the pattern. The starting point is a production hub that receives webhooks from around 20 platforms with more than 25 million requests per month.
The starting point
The hub has a simple task: receive webhooks from third-party systems, validate and store them and, depending on the event, forward them to other systems or execute actions. The platforms differ in many ways:
-
Payload: Every platform has its own JSON format. One puts the event name in
event, another intype. - Verification: Some sign the body, some send a token, some require a challenge response when the URL is registered.
-
Events: A platform often knows many event types (
meeting.ended,recording.completed,ticket.created). Each one has different follow-up actions. - Follow-up actions: Some events are only stored, others trigger a download, a call to a CRM or forwarding to a data platform such as Foundry.
In addition, I had my own requirement for the architecture. It should be possible to add new platforms (strategies) and new events (sub-strategies) without changing existing code. The tests should stay simple and clear. Conversely, a strategy or sub-strategy must be removable without affecting the rest of the application.
The challenge
The obvious first approach is a controller with a switch:
switch (platformName) {
case "zoom" -> zoomHandler.handle(body, headers);
case "gohighlevel" -> goHighLevelHandler.handle(body, headers);
// ... 18 more
}
This works for two platforms. With twenty platforms, it becomes a class that knows every platform and has to be changed with every modification. Within a platform, the next branch by event is added, followed by the branch by follow-up action. The result is nested conditions that are hard to understand and hard to test.
Even if every platform is in its own class, like zoomHandler above, the switch remains and still knows every platform. With the second level (event), its complexity would be very high. For this reason I decided against a switch.
The actual challenges were:
- Extensibility: A new platform should be a new class, not a change to existing code.
- Two levels of branching: First by platform, then by event within the platform.
- Multiple follow-up actions per event: An event can be stored, sent to a CRM and forwarded at the same time.
- Testability: Every platform and every follow-up action must be testable on its own.
The idea
The solution consists of two levels that use the same mechanism.
Level 1: Strategy per platform. Every platform is a strategy. The strategy stores the received raw data via a RawDataSaveService. The controller only knows an interface and a factory.
Level 2: Sub-Strategy per event. Inside the platform strategy there is a second group of strategies that react to platform and event. The sub-strategies take care of further processing: they often fetch dependent data and store it, for example in Foundry. They are not exclusive: several sub-strategies can match the same event and all of them are executed.
The connecting element: Collection Injection. Spring injects all beans of an interface type as a List<T> into the constructor. The factory therefore does not know any concrete strategy. A new class with @Component is all that is needed.
The first level: Strategies
The interface
The strategy answers two questions: "Am I responsible?" and "What do I do with the payload?".
public interface PlatformStrategyInterface {
boolean supported(String platformName);
void handle(String payload, HttpHeaders headers);
ResponseEntity<String> getResponse();
}
Source: PlatformStrategyInterface
supported is the key. It replaces switch/case and moves the decision into the strategy itself.
The base class
Shared behavior moves into an abstract class. The concrete strategy only passes on its name.
public abstract class PlatformStrategyAbstract implements PlatformStrategyInterface {
private final String platformName;
protected PlatformStrategyAbstract(String platformName) {
this.platformName = platformName;
}
@Override
public boolean supported(String platformName) {
return this.platformName.equals(platformName);
}
@Override
public ResponseEntity<String> getResponse() {
return ResponseEntity.status(HttpStatus.OK).build();
}
}
Source: PlatformStrategyAbstract
The concrete strategies
A platform is a small class:
@Component
public class ZoomPlatformStrategy extends PlatformStrategyAbstract {
public static final String NAME = "zoom";
public ZoomPlatformStrategy() {
super(NAME);
}
@Override
public void handle(String payload, HttpHeaders headers) {
// platform-specific processing
}
}
A new platform is another class of this form. There is no central place where it has to be registered.
The factory with Collection Injection
@Component
public class PlatformStrategyFactory {
private final List<PlatformStrategyInterface> strategies;
public PlatformStrategyFactory(List<PlatformStrategyInterface> strategies) {
this.strategies = strategies;
}
public PlatformStrategyInterface getPlatformStrategy(String platformName) {
for (PlatformStrategyInterface strategy : strategies) {
if (strategy.supported(platformName)) {
return strategy;
}
}
return null;
}
}
Source: PlatformStrategyFactory
Spring fills strategies at startup with all beans that implement PlatformStrategyInterface. The factory does not have to be changed again.
The controller
The controller is deliberately slim. It knows neither Zoom nor any other platform:
@RestController
public class WebhookPlatformStrategyController {
private final PlatformStrategyFactory platformStrategyFactory;
public WebhookPlatformStrategyController(PlatformStrategyFactory platformStrategyFactory) {
this.platformStrategyFactory = platformStrategyFactory;
}
@PostMapping("/{platformName:[a-z]{1,16}}")
public ResponseEntity<String> save(
@PathVariable String platformName,
@RequestBody(required = false) String requestBody,
@RequestHeader HttpHeaders headers
) {
PlatformStrategyInterface strategy = platformStrategyFactory.getPlatformStrategy(platformName);
if (strategy == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_JSON)
.body(String.format("{\"error\":\"PlatformStrategy [%s] not found\"}", platformName));
}
try {
strategy.handle(requestBody, headers);
} catch (JacksonException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(String.format("{\"error\":\"PlatformStrategy [%s] with JSON error\"}", platformName));
} catch (Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(String.format("{\"error\":\"PlatformStrategy [%s] handle failed\"}", platformName));
}
return strategy.getResponse();
}
}
Source: WebhookPlatformStrategyController
getResponse() is intentionally part of the strategy. Some platforms expect a specific response, for example when verifying a new webhook URL with a challenge. In this case the strategy overrides the method and the controller remains unchanged.
The second level: Sub-Strategies
Up to this point it is the classic Strategy Pattern. However, the platform strategy often has to differentiate further, namely by event. Instead of adding a switch to ZoomPlatformStrategy, the same pattern is repeated one level deeper:
public interface SubStrategyInterface {
boolean supported(String platformName, String eventName);
void handle(String payload);
}
Source: SubStrategyInterface
The name stays deliberately generic: Sub only says that the class sits below a platform strategy and is called by it. The platform is not part of the type name because the technique does not depend on it. Here the binding happens through supported(platformName, eventName), and the event is part of the concrete class name (ZoomMeetingEndedSubStrategy).
Here too, the supported(...) check would be in every class. To avoid writing it again and again, there is a base class, as on the platform level. It holds a map platform -> events and answers supported(...) for all sub-strategies in the same way:
public abstract class SubStrategyAbstract implements SubStrategyInterface {
// platform -> events the sub-strategy is responsible for
private final Map<String, Set<String>> supported;
// Short form: exactly one platform and one event
protected SubStrategyAbstract(String platformName, String eventName) {
this.supported = Map.of(platformName, Set.of(eventName));
}
// Multiple platforms and/or events
protected SubStrategyAbstract(Map<String, Set<String>> supported) {
this.supported = supported;
}
@Override
public boolean supported(String platformName, String eventName) {
return supported.getOrDefault(platformName, Set.of()).contains(eventName);
}
}
Source: SubStrategyAbstract
A concrete sub-strategy only passes on to the base class what it is responsible for and implements handle(...):
@Component
public class ZoomMeetingEndedSubStrategy extends SubStrategyAbstract {
public static final String STRATEGY = "zoom";
public static final String EVENT = "meeting.ended";
public ZoomMeetingEndedSubStrategy() {
super(STRATEGY, EVENT);
}
@Override
public void handle(String payload) {
// follow-up action for this event
}
}
Source: ZoomMeetingEndedSubStrategy
The corresponding factory differs in one important point: it returns all matching strategies, not only the first.
@Component
public class SubStrategyFactory {
private final List<SubStrategyInterface> strategies;
public SubStrategyFactory(List<SubStrategyInterface> strategies) {
this.strategies = strategies;
}
// returns ALL matching strategies (not just the first)
public List<SubStrategyInterface> getAllSubStrategies(String platformName, String eventName) {
return strategies.stream()
.filter(strategy -> strategy.supported(platformName, eventName))
.toList();
}
}
Source: SubStrategyFactory
Where does the event name come from?
So that the factory finds the matching sub-strategy, the platform strategy has to read the event name from the payload. This is the actual difficulty, because every platform names it differently: Zoom sends it in event, GoHighLevel in type and for other platforms it is nested on the second or third level of the JSON.
Instead of searching the JSON manually in every strategy, each platform gets a minimal DTO that only knows the event name. A common interface makes them interchangeable:
public interface EventNameInterface {
String getEvent();
}
Source: EventNameInterface
@JsonIgnoreProperties(ignoreUnknown = true)
public class ZoomRootDto implements EventNameInterface {
@JsonProperty("event")
private String event;
@Override
public String getEvent() {
return event;
}
// setter
}
Source: ZoomRootDto
@JsonIgnoreProperties(ignoreUnknown = true)
public class GoHighLevelRootDto implements EventNameInterface {
// GoHighLevel sends the event name in "type"
@JsonProperty("type")
private String event;
@Override
public String getEvent() {
return event;
}
// setter
}
Source: GoHighLevelRootDto
Two details make this robust:
-
@JsonIgnoreProperties(ignoreUnknown = true)makes Jackson ignore all other fields. The DTO stays small and new fields of the platform do not break anything. -
@JsonProperty("type")maps the platform-specific name to the uniformgetEvent(). The difference between the platforms is encapsulated in exactly one place.
The platform strategy stores and calls the sub-strategies
@Component
public class ZoomPlatformStrategy extends PlatformStrategyAbstract {
public static final String NAME = "zoom";
private final ObjectMapper objectMapper;
private final SubStrategyFactory subStrategyFactory;
private final RawDataSaveService rawDataSaveService;
public ZoomPlatformStrategy(
ObjectMapper objectMapper,
SubStrategyFactory subStrategyFactory,
RawDataSaveService rawDataSaveService) {
super(NAME);
this.objectMapper = objectMapper;
this.subStrategyFactory = subStrategyFactory;
this.rawDataSaveService = rawDataSaveService;
}
@Override
public void handle(String payload, HttpHeaders headers) {
// Read the event name from the payload
ZoomRootDto dto = this.objectMapper.readValue(payload, ZoomRootDto.class);
// Store raw data
this.rawDataSaveService.save(NAME, dto.getEvent(), payload);
// Call all sub-strategies that support platform + event
for (SubStrategyInterface subStrategy
: this.subStrategyFactory.getAllSubStrategies(NAME, dto.getEvent())) {
subStrategy.handle(payload);
}
}
}
Source: ZoomPlatformStrategy
The GoHighLevelPlatformStrategy looks identical except for the name and the DTO. This is intended: the difference between the platforms is in supported(...) and in the DTO, not in the flow. If the pattern is this uniform in practice, it is worth checking whether the flow can be moved into the base class and the strategy only provides the DTO (Template Method).
This has a side effect that is very useful in practice: a sub-strategy can be cross-platform. It does not have to belong to one platform. The platform strategy does not know anything about it.
Example: Multiple sub-strategies for the same event
In addition to the ZoomMeetingEndedSubStrategy, another action should be executed for meeting.ended (and meeting.started), for example forwarding to Foundry. This is a separate class. ZoomPlatformStrategy and ZoomMeetingEndedSubStrategy remain unchanged. Because it covers several events, it uses the constructor with the map:
@Component
public class FoundrySubStrategy extends SubStrategyAbstract {
// platform -> events this sub-strategy is responsible for
private static final Map<String, Set<String>> SUPPORTED = Map.of(
"zoom", Set.of("meeting.ended", "meeting.started")
);
public FoundrySubStrategy() {
super(SUPPORTED);
}
@Override
public void handle(String payload) {
// action for this event
}
}
Source: FoundrySubStrategy
supported(...) is data-driven instead of hard-coded: which platforms and events are covered is defined in the map. Another platform or event is an additional entry, the check logic in the base class remains the same. Unknown platforms return false via getOrDefault.
If meeting.ended is received, both sub-strategies match. getAllSubStrategies("zoom", "meeting.ended") returns both and ZoomPlatformStrategy calls each one:
ZoomPlatformStrategy.handle(payload)
├─ rawDataSaveService.save("zoom", "meeting.ended", payload)
└─ getAllSubStrategies("zoom", "meeting.ended")
├─► ZoomMeetingEndedSubStrategy.handle(payload)
└─► FoundrySubStrategy.handle(payload)
That there is no order and a sub-strategy must not influence another is the prerequisite for this (see Pitfalls).
What worked well
New platforms are additive changes. A class is added. No existing class is changed, which reduces the risk of regressions.
Follow-up actions are decoupled. Storing, forwarding to the CRM, downloading a file: each of these is its own sub-strategy. They can be tested individually, added and removed.
Tests are small. A strategy can be instantiated without a Spring context:
class ZoomMeetingEndedStrategyTest {
private final ZoomMeetingEndedSubStrategy strategy = new ZoomMeetingEndedSubStrategy();
@Test
void test_supported() {
assertTrue(this.strategy.supported("zoom", "meeting.ended"));
assertFalse(this.strategy.supported("zoom", "meeting.started"));
assertFalse(this.strategy.supported("gohighlevel", "meeting.ended"));
}
// Further tests
}
The platform strategy is tested with a mocked SubStrategyFactory and a mocked RawDataSaveService. This also covers reading the event name, including the platform-specific property, and storing the raw data:
@ExtendWith(MockitoExtension.class)
class ZoomPlatformStrategyTest {
@Mock
private SubStrategyFactory subStrategyFactoryMock;
@Mock
private RawDataSaveService rawDataSaveServiceMock;
@Mock
private SubStrategyInterface subStrategy1;
@Mock
private SubStrategyInterface subStrategy2;
private ZoomPlatformStrategy strategy;
@BeforeEach
void beforeEach() {
this.strategy = new ZoomPlatformStrategy(new ObjectMapper(), this.subStrategyFactoryMock, this.rawDataSaveServiceMock);
}
@Test
void test_handle_calls_all_sub_strategies() {
when(this.subStrategyFactoryMock.getAllSubStrategies("zoom", "meeting.ended"))
.thenReturn(List.of(this.subStrategy1, this.subStrategy2));
String payload = "{\"event\":\"meeting.ended\",\"payload\":{}}";
this.strategy.handle(payload, new HttpHeaders());
verify(this.subStrategy1, times(1)).handle(payload);
verify(this.subStrategy2, times(1)).handle(payload);
}
@Test
void test_handle_stores_raw_data() {
when(this.subStrategyFactoryMock.getAllSubStrategies("zoom", "meeting.ended")).thenReturn(List.of());
String payload = "{\"event\":\"meeting.ended\",\"payload\":{}}";
this.strategy.handle(payload, new HttpHeaders());
verify(this.rawDataSaveServiceMock, times(1)).save("zoom", "meeting.ended", payload);
}
}
The factories are tested with a manually created list:
class PlatformStrategyFactoryTest {
private PlatformStrategyFactory strategyFactory;
@BeforeEach
void beforeEach() {
this.strategyFactory = new PlatformStrategyFactory(List.of(new TestStrategyDummy()));
}
@Test
void test_getPlatformStrategy() {
assertInstanceOf(TestStrategyDummy.class, this.strategyFactory.getPlatformStrategy("foobar"));
}
@Test
void test_getPlatformStrategy_not_found() {
assertNull(this.strategyFactory.getPlatformStrategy("dummy"));
}
}
For the WebhookPlatformStrategyControllerTest a @WebMvcTest with a mocked factory is sufficient. It only tests the HTTP behavior (200, 400, 404, 500) and whether the factory is called.
The pitfalls
The pattern is simple, but in production there were a few things that are good to know in advance.
The base class becomes a god object
In practice, more and more moved into PlatformStrategyAbstract: verification, persistence, error handling, parsing, several overloaded handle methods. As a result, concrete strategies got five or six constructor parameters that they only pass through.
Solution: Keep the base class small (name and supported). Cross-cutting concerns like verification and persistence belong in their own services or interfaces. Composition is better than inheritance here.
Order is not guaranteed
The List<T> from Collection Injection has no defined order. This is not a problem as long as strategies are independent. As soon as a sub-strategy needs the result of another, it becomes a bug.
Solution: Keep strategies independent. If an order is required, use @Order or Ordered and document it.
Errors in a sub-strategy
If three sub-strategies run one after another and the second throws an exception, the question is: Does the third still run? If the webhook is answered with a 500 and repeated by the sender, the first and third run twice.
Solution: Decide deliberately. Either each sub-strategy catches and logs its own errors, or the payload is persisted first and the follow-up actions run asynchronously via a queue with retry. In production, the latter has partly proven itself: store first, then process.
When the pattern is worthwhile, and when not
For two platforms, a switch is simpler and more straightforward. The pattern is worthwhile when
- the number of variants grows and new variants are added regularly,
- variants have different dependencies (their own HTTP client, their own repositories),
- several teams or people work on different variants,
- every variant should be testable on its own.
It is less worthwhile when there are two or three fixed variants that never change. In this case an enum map or a switch is easier to read.
Conclusion
-
Strategy Pattern replaces the central
switch: every platform decides for itself whether it is responsible (supported). -
Collection Injection (
List<PlatformStrategyInterface>) makes the factory immutable. New strategies register themselves with@Component. - Sub-Strategies repeat the pattern on the event level. Multiple matches are allowed there and every match is an independent follow-up action.
- The pitfalls are not in the pattern itself, but around it: a bloated base class, unclear order and error handling with multiple sub-strategies.
Full example on GitHub
https://github.com/alaugks/article-webhook-hub-spring-strategy-pattern

Top comments (0)