The flag nobody should have to remember
Address verification APIs bill per lookup. So do identity screening, document extraction, and most
other useful third-party services. They also tend to offer a sandbox mode that returns plausible
data for free, which is what you want everywhere except production.
The obvious way to wire that up is a check where the call happens:
don't do this
if (properties.isSimulate()) {
return client.verify(request.withSandbox(true));
}
return client.verify(request);
It works. It also means the flag is now part of your business logic, and every future call site is
one more place to forget it. The place that forgets is the one that bills you from a CI run.
A bean that wraps its own interface
Put the flag in a bean instead:
SandboxAddressClient.java
@Primary
@Component
@ConditionalOnBooleanProperty("address.simulate")
class SandboxAddressClient implements AddressClient {
private final AddressClient delegate;
SandboxAddressClient(AddressClient delegate) {
this.delegate = delegate;
}
@Override
public AddressResult verify(AddressRequest request) {
return delegate.verify(request.withSandbox(true));
}
}
1. @Primary is what keeps this invisible. Callers still ask for AddressClient and now get
the wrapper, so you don't touch a single call site and new callers inherit the behaviour without
knowing it exists.
2. @ConditionalOnBooleanProperty does more than switch behaviour off. When the property is
false the bean is never created, so there is nothing in the context to misconfigure and no branch
to trace at runtime. Either the decorator is there or it isn't.
3. The undecorated client, injected into its own decorator, which is the part that looks
impossible.
Why this is not a circular dependency
A class that implements AddressClient and injects AddressClient looks like it should blow up on
startup. It would, if it were the only implementation.
What saves it is that @Primary applies to other beans asking for the type, not to the bean's own
constructor. Spring needs an AddressClient to build SandboxAddressClient, leaves out the bean
it's currently constructing, and finds one remaining candidate. That's the real client. Without that
exclusion you'd be adding qualifiers and the two beans would need to know about each other.
The other thing worth noticing is that the decorator doesn't fake anything. The request still
serializes, authenticates, and comes back through the same error handling as production. Only the
sandbox flag changes. Swap in a mock and you skip all of it, which is usually where the bugs live.
What else this fits
Any metered API with a sandbox works the same way. So does disarming something with a real-world
effect, like notifications or webhook deliveries, where you want staging to go through the motions
without anyone receiving anything.
It also works for behaviour you only want in some deployments. Cache a read-heavy client in
production but not locally, so you see fresh data while developing. Mask personal data in
environments where a lot of people have access, and leave it alone where the access is legitimate.
The pattern holds as long as the decision comes from where the application runs. If it depends on
anything in the request, this is the wrong tool.
When the indirection stops paying
As soon as the wrapper starts deciding things per request, which lookups get sandboxed or which
users get masked, put the flag in the request and be explicit about it. Request-dependent logic
hidden behind @Primary is the kind of cleverness people curse at later.
There is a genuine cost even in the simple case. Someone debugging a call site sees an interface and
gets no hint that a decorator is in the way. That's a fine trade when the wrapper does one
environment-shaped thing you can explain in a sentence. It stops being fine once the decorator has
business rules of its own, because nobody reading the caller has any reason to look for it.
You can stack these, but only one bean can be @Primary. Past that you're ordering a chain by hand,
and at that point a configuration class that builds the stack in plain sight beats spreading it
across annotations.
Top comments (0)