André Laugks

Webhook Hub with Spring Boot: Strategy Pattern, Sub-Strategies and Collection Injection

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 in type.
  • 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 (strategyName) {
    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:

  1. Extensibility: A new platform should be a new class, not a change to existing code.
  2. Two levels of branching: First by platform, then by event within the platform.
  3. Multiple follow-up actions per event: An event can be stored, sent to a CRM and forwarded at the same time.
  4. 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.

Request flow through the webhook hub: controller, StrategyFactory, platform strategy, SubStrategyFactory and the matching sub-strategies, with the injected lists of strategies and sub-strategies.

The first level: Strategies

The interface

The strategy answers two questions: "Am I responsible?" and "What do I do with the payload?".

public interface StrategyInterface {

    boolean supported(String strategyKey);

    void handle(String payload, HttpHeaders headers);

    ResponseEntity<String> getResponse();
}

Source: StrategyInterface

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 StrategyAbstract implements StrategyInterface {

    private final String strategyName;

    protected StrategyAbstract(String strategyName) {
        this.strategyName = strategyName;
    }

    @Override
    public boolean supported(String strategyKey) {
        return strategyKey.equalsIgnoreCase(this.strategyName);
    }

    @Override
    public ResponseEntity<String> getResponse() {
        return ResponseEntity.status(HttpStatus.OK).build();
    }
}

Source: StrategyAbstract

The concrete strategies

A platform is a small class:

@Component
public class ZoomStrategy extends StrategyAbstract {

    public static final String NAME = "zoom";

    public ZoomStrategy() {
        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 StrategyFactory {

    private final List<StrategyInterface> strategies;

    public StrategyFactory(List<StrategyInterface> strategies) {
        this.strategies = strategies;
    }

    public StrategyInterface getStrategy(String strategyName) {
        for (StrategyInterface strategy : strategies) {
            if (strategy.supported(strategyName)) {
                return strategy;
            }
        }
        return null;
    }
}

Source: StrategyFactory

Spring fills strategies at startup with all beans that implement StrategyInterface. 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 WebHookStrategyController {

    private final StrategyFactory strategyFactory;

    public WebHookStrategyController(StrategyFactory strategyFactory) {
        this.strategyFactory = strategyFactory;
    }

    @PostMapping("/{strategyName}")
    public ResponseEntity<String> save(
        @PathVariable String strategyName,
        @RequestBody(required = false) String requestBody,
        @RequestHeader HttpHeaders headers
    ) {
        StrategyInterface strategy = strategyFactory.getStrategy(strategyName);

        if (strategy == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .contentType(MediaType.APPLICATION_JSON)
                .body(String.format("{\"error\":\"Strategy [%s] not found\"}", strategyName));
        }

        try {
            strategy.handle(requestBody, headers);
        } catch (JacksonException e) {
            return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .contentType(MediaType.APPLICATION_JSON)
                .body(String.format("{\"error\":\"Strategy [%s] with JSON error\"}", strategyName));
        } catch (Exception e) {
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .contentType(MediaType.APPLICATION_JSON)
                .body(String.format("{\"error\":\"Strategy [%s] handle failed\"}", strategyName));
        }

        return strategy.getResponse();
    }
}

Source: WebHookStrategyController

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 ZoomStrategy, the same pattern is repeated one level deeper:

public interface SubStrategyInterface {

    boolean supported(String strategyName, String eventName);

    void handle(String payload);
}

Source: SubStrategyInterface

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 strategyName, String eventName) {
        this.supported = Map.of(strategyName, Set.of(eventName));
    }

    // Multiple platforms and/or events
    protected SubStrategyAbstract(Map<String, Set<String>> supported) {
        this.supported = supported;
    }

    @Override
    public boolean supported(String strategyName, String eventName) {
        return supported.getOrDefault(strategyName, 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 ZoomSubMeetingEndedStrategy extends SubStrategyAbstract {

    public static final String STRATEGY = "zoom";
    public static final String EVENT = "meeting.ended";

    public ZoomSubMeetingEndedStrategy() {
        super(STRATEGY, EVENT);
    }

    @Override
    public void handle(String payload) {
        // follow-up action for this event
    }
}

Source: ZoomSubMeetingEndedStrategy

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 strategyName, String eventName) {
        return strategies.stream()
            .filter(strategy -> strategy.supported(strategyName, 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 uniform getEvent(). The difference between the platforms is encapsulated in exactly one place.

The platform strategy stores and calls the sub-strategies

@Component
public class ZoomStrategy extends StrategyAbstract {

    public static final String NAME = "zoom";

    private final ObjectMapper objectMapper;
    private final SubStrategyFactory subStrategyFactory;
    private final RawDataSaveService rawDataSaveService;

    public ZoomStrategy(
            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: ZoomStrategy

The GoHighLevelStrategy 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 ZoomSubMeetingEndedStrategy, another action should be executed for meeting.ended (and meeting.started), for example forwarding to Foundry. This is a separate class. ZoomStrategy and ZoomSubMeetingEndedStrategy 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 ZoomStrategy calls each one:

ZoomStrategy.handle(payload)
   ├─ rawDataSaveService.save("zoom", "meeting.ended", payload)
   └─ getAllSubStrategies("zoom", "meeting.ended")
        ├─► ZoomSubMeetingEndedStrategy.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 ZoomSubMeetingEndedStrategy strategy = new ZoomSubMeetingEndedStrategy();

    @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 ZoomStrategyTest {

    @Mock
    private SubStrategyFactory subStrategyFactoryMock;

    @Mock
    private RawDataSaveService rawDataSaveServiceMock;

    @Mock
    private SubStrategyInterface subStrategy1;

    @Mock
    private SubStrategyInterface subStrategy2;

    private ZoomStrategy strategy;

    @BeforeEach
    void beforeEach() {
        this.strategy = new ZoomStrategy(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 StrategyFactoryTest {

    private StrategyFactory strategyFactory;

    @BeforeEach
    void beforeEach() {
        this.strategyFactory = new StrategyFactory(List.of(new TestStrategyDummy()));
    }

    @Test
    void test_getStrategy() {
        assertInstanceOf(TestStrategyDummy.class, this.strategyFactory.getStrategy("foobar"));
    }

    @Test
    void test_getStrategy_not_found() {
        assertNull(this.strategyFactory.getStrategy("dummy"));
    }
}

For the WebHookStrategyControllerTest 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 StrategyAbstract: 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.

The strategy name comes from outside

The path parameter {strategyName} is user input. It is only compared with equalsIgnoreCase and never used in file paths or SQL. This is not a problem here. As soon as something is derived from the name (tables, cache keys, paths), it has to be validated.

The names of the strategies are fixed, but the path parameter itself is not restricted. An unknown name only leads to a 404. Security concerns are therefore hardly relevant here.

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<StrategyInterface>) 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