Sooner or later, an enterprise application has to talk to another system. Company X returns JSON with camelCase fields, Company Y sends something that clearly comes from another era in XML, and a third company asks for a session token before it tells you anything. None of this is your business logic — but without the right boundaries, all of it can end up inside your business logic.

The goal is simple to say but easy to lose: the core of the application should know one model, and only that model. No matter what format the outside world uses, the translation should happen at the edge.

In this article, I will look at two patterns that work together in Spring Boot: Adapter to hide external differences, and Strategy to decide which adapter should run.

I use a menu service in the examples because the payloads stay short. Replace “menu” with a payment provider, KYC company, shipping service, or SMS gateway; the structure stays the same.

The shape of the problem

graph LR
    C[Controller] --> UC["Use case"]
    UC -->|MenuResponse| P{{MenuAdapter}}
    P --> X[XFirmMenuAdapter]
    P --> Y[YFirmMenuAdapter]
    X -->|"X company JSON"| XA[(X API)]
    Y -->|"Y company XML"| YA[(Y API)]

Everything on the left side of MenuAdapter speaks your language. Everything on the right speaks the external company’s language. The interface is the boundary, and the adapters are the translators.

Adapter: agreeing on one model

Start from the inside and move out. First define the model your application actually needs, then define the port that produces it:

// The only menu model known inside the system
public record MenuResponse(String menuId, List<String> items) {}

// The port that every adapter must implement
public interface MenuAdapter {
    MenuResponse fetchMenu();
}

There are two important things to notice here, because they are basically the whole point:

  • MenuResponse does not contain a single field just because Company X sends it. X might return forty fields, but if you only need two of them, the record has two fields.
  • The interface lives next to the domain, not next to the integration. The adapters depend on your abstraction, not the other way around.

The adapter itself is just a small and boring class whose only job is translation:

@Service
public class XFirmMenuAdapter implements MenuAdapter {

    @Override
    public MenuResponse fetchMenu() {
        return new MenuResponse("X-123", List.of("Kebap", "Ayran"));
    }
}

In a real integration, the body would contain an HTTP call and some mapping. It should still stay boring. That is actually a good thing — when Company X changes its contract, only one file should need to change.

Strategy: which adapter should run?

This is where the real question starts. You have two adapters and one interface, so something needs to decide which one to use. The right answer depends on how your application is deployed. There are two very different cases.

Single-tenant

Each customer has their own instance — a separate server, container, or configuration.

The choice stays the same for the lifetime of the process, so it is made at startup.

Multi-tenant

One deployment serves all customers, and two requests arriving at the same time can belong to different companies.

The choice is made per request, so it happens at runtime.

Scenario A — single-tenant, choose at startup

If the deployment belongs to one customer, choosing an adapter at runtime is unnecessary work. Use @ConditionalOnProperty so Spring only creates the adapter that this instance needs:

// Created only when application.yml contains "integration.firm: X"
@Service
@ConditionalOnProperty(name = "integration.firm", havingValue = "X")
public class XFirmMenuAdapter implements MenuAdapter {

    @Override
    public MenuResponse fetchMenu() {
        return new MenuResponse("X-123", List.of("Kebap", "Ayran"));
    }
}

// Created only when application.yml contains "integration.firm: Y"
@Service
@ConditionalOnProperty(name = "integration.firm", havingValue = "Y")
public class YFirmMenuAdapter implements MenuAdapter {

    @Override
    public MenuResponse fetchMenu() {
        return new MenuResponse("Y-999", List.of("Pizza", "Kola"));
    }
}

The configuration is one line, and this is the only place where the company name appears:

integration:
  firm: X
integration:
  firm: Y
java -jar menu-service.jar --integration.firm=Y

The controller does not know which adapter it received:

@RestController
public class MenuController {

    private final MenuAdapter menuAdapter;

    public MenuController(MenuAdapter menuAdapter) {
        this.menuAdapter = menuAdapter;
    }

    // ... endpoint methods
}

This is exactly where the pattern does its job. MenuController works without knowing that Company X or Company Y even exists.

Two error cases to know before going to production

  • If integration.firm is missing or has an unknown value, no MenuAdapter bean is created and the context fails to start with NoSuchBeanDefinitionException. This is a loud and early failure, which is exactly what you want — as long as you find it in CI instead of at 3 AM.

  • If two conditions can be true at the same time, you get NoUniqueBeanDefinitionException. Keep the property single-valued or use @Primary.

Scenario B — multi-tenant, choose per request

Now let’s move to the SaaS case. One deployment serves many customers, and the company information comes with the request: a header, a JWT claim, or a subdomain.

Here @ConditionalOnProperty does not help. All adapters need to exist in the context, and the correct one must be selected for every request.

First, give the port an identifier:

public interface MenuAdapter {
    String getFirmName();      // which company this adapter represents
    MenuResponse fetchMenu();
}

Then register the adapters without any conditions:

@Service
public class XFirmMenuAdapter implements MenuAdapter {
    @Override public String getFirmName() { return "X"; }
    @Override public MenuResponse fetchMenu() { return new MenuResponse("X-123", List.of("Kebap")); }
}

@Service
public class YFirmMenuAdapter implements MenuAdapter {
    @Override public String getFirmName() { return "Y"; }
    @Override public MenuResponse fetchMenu() { return new MenuResponse("Y-999", List.of("Pizza")); }
}

The useful part here is a feature of Spring that is easy to forget: List<MenuAdapter> asks Spring to inject all implementations it finds. You can index them once in the constructor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class MenuStrategyFactory {

    private final Map<String, MenuAdapter> adapterMap = new HashMap<>();

    // Spring collects all MenuAdapter beans and gives them to us here
    public MenuStrategyFactory(List<MenuAdapter> adapters) {
        for (MenuAdapter adapter : adapters) {
            adapterMap.put(adapter.getFirmName(), adapter);
        }
    }

    public MenuAdapter getAdapter(String firmName) {
        MenuAdapter adapter = adapterMap.get(firmName);
        if (adapter == null) {
            throw new IllegalArgumentException("Unsupported company: " + firmName);
        }
        return adapter;
    }
}

The map is built once at startup and never changed after that. So lookups are very cheap, and there is nothing that needs to be synchronized.

The one improvement I would make in this constructor

HashMap.put silently replaces the old value when the same key is used. If someone copies an adapter and forgets to change getFirmName(), one of the adapters disappears and you may only notice it in production.

Collectors.toMap without a merge function throws an exception when the same key appears. This turns a silent bug into an application that fails at startup:

public MenuStrategyFactory(List<MenuAdapter> adapters) {
    this.adapterMap = adapters.stream()
            .collect(Collectors.toUnmodifiableMap(
                    MenuAdapter::getFirmName,
                    Function.identity()));
}

The idea is the same as with NoSuchBeanDefinitionException above: fail at startup, not when a request arrives.

The controller reads the tenant and asks the factory for the right adapter:

@RestController
@RequestMapping("/api/menu")
public class MenuController {

    private final MenuStrategyFactory strategyFactory;

    public MenuController(MenuStrategyFactory strategyFactory) {
        this.strategyFactory = strategyFactory;
    }

    @GetMapping
    public MenuResponse getMenu(@RequestHeader("X-Tenant-ID") String tenantId) {
        // If the incoming id is "X", use X adapter; if it is "Y", use Y adapter
        MenuAdapter adapter = strategyFactory.getAdapter(tenantId);
        return adapter.fetchMenu();
    }
}

An end-to-end request looks like this:

sequenceDiagram
    participant C as Client
    participant Ctl as MenuController
    participant F as MenuStrategyFactory
    participant A as YFirmMenuAdapter
    C->>Ctl: GET /api/menu (X-Tenant-ID: Y)
    Ctl->>F: getAdapter("Y")
    F-->>Ctl: YFirmMenuAdapter
    Ctl->>A: fetchMenu()
    A-->>Ctl: MenuResponse
    Ctl-->>C: 200 + MenuResponse

Reading the tenant directly from a header keeps the example short. In a real service, this information usually comes from a verified claim and is stored in a request-scoped TenantContext — otherwise anyone who changes the header could choose any tenant they want.

Adding a third company

You can really see whether a design is good when the system grows. Company Z signed the contract on Monday:

  1. Write the adapter

    ZFirmMenuAdapter implements MenuAdapter translates Z’s payload into MenuResponse. One new file.

  2. Register it

    For multi-tenant, use @Service; for single-tenant, use @Service plus @ConditionalOnProperty(havingValue = "Z").

  3. Do nothing else

    The factory finds it automatically through the injected List. No switch, no registry to update, and no if in the controller.

  4. Do not touch the domain

    MenuResponse, the use cases, and the controller stay unchanged. If one of them has to change, your abstraction is leaking.

Decision matrix

Architectural needSolutionAdvantage
Isolated deployment / single-tenant — a dedicated server or pod is used for each customer@ConditionalOnProperty (choose at startup)Keeps the context clean; unused classes are never created as beans
SaaS / multi-tenant — one application serves all customersStrategy factory (choose at runtime)Allows request-based strategy selection using a header, claim, or subdomain

Both options use the same adapters. This is intentional. If you start with single-tenant and later move to multi-tenant, you only need to change the annotation and add the factory; the integration code stays the same.

What this means for tests

This part is easy to miss. Because the use case only depends on MenuAdapter, you can test it without HTTP, WireMock, or a network connection:

MenuAdapter fake = () -> new MenuResponse("TEST-1", List.of("Kebap"));

A single-method interface can also be used as a lambda. The adapters themselves still deserve integration tests against the real provider, but the important part — the business logic — can be tested in milliseconds.

The rule underneath all of this

Your controllers and use cases should not know that companies X, Y, or Z exist. The only thing they should know is MenuResponse.

Everything above exists to support this one rule. When you are not sure whether a piece of code belongs in an adapter or in the domain, ask yourself: if the provider changed tomorrow, which side would need to change? The answer usually shows you the right boundary.