I built a Spring Boot 3.2+ open-source library that lets you declare and manage external API channels entirely from YAML.
It started in the travel industry
The thing is, every OTA has different API specs. One pattern that shows up often is when the lookup host and the booking/payment host are separated.
-
https://api-search.someota.com→ product lookup, price check -
https://api-booking.someota.com→ actual booking and payment
Same OTA, but two endpoints — different auth tokens, different timeout policies.
Once you have 10 or 20 such OTAs, the problem starts in earnest.
Similar-looking @Bean declarations grow with the channel count — and double again for any channel with dual endpoints — honestly piling up.
The limits of RestClient + @bean
My first attempt was the obvious one: register RestClient (introduced in Spring Boot 3.2) as @Beans. It was boilerplate hell.
@Configuration
public class OtaClientConfig {
@Bean("abcOtaSearchClient")
public RestClient abcOtaSearchClient() {
return buildClient(
"https://api-search.someota.com",
System.getenv("ABC_SEARCH_TOKEN"),
Duration.ofSeconds(30),
Duration.ofSeconds(5)
);
}
@Bean("abcOtaBookingClient")
public RestClient abcOtaBookingClient() {
return buildClient(
"https://api-booking.someota.com",
System.getenv("ABC_BOOKING_TOKEN"),
Duration.ofSeconds(60),
Duration.ofSeconds(5)
);
}
// Two more beans for XYZ OTA
// Two more beans for DEF OTA
// ... 10 channels means 20 beans
private RestClient buildClient(String baseUrl, String token,
Duration readTimeout, Duration connectTimeout) {
// factory, interceptor, logging setup...
}
}
Sure, you can extract a buildClient helper. But the more channels you add, the more @Bean methods pile up.
Why not OpenFeign?
OpenFeign was on the table, of course. Clean abstraction — just interfaces and annotations. But when I actually tried it, things kept getting in the way.
1. It doesn't sit on top of Spring Boot 3.2's RestClient.
OpenFeign runs on Feign internally. If your org wants a unified HTTP client policy built on RestClient, that consistency breaks the moment you adopt Feign.
2. Mapping two endpoints to one service is awkward.
The dual-host OTA pattern — lookup and booking on different URLs — forces you to split into two Feign interfaces. "It's the same OTA, why am I splitting it into two classes?" That nagging feeling never goes away.
3. Customization is shallow.
Shaping request/response logging into a specific format (URL + body + response time + status) means bringing in a plugin or reconfiguring Logger.Level. Details like gzip compression or decompression-bomb defense? You write those yourself. I spent days just matching the log format ops required.
4. The code grows back anyway.
Interfaces, fallbacks, configuration classes, ErrorDecoder, RequestInterceptor — what the annotations seemed to save up front, you end up paying back elsewhere.
What I wanted was simple: "if I look at one YAML, I can see exactly how this system talks to the outside world." OpenFeign doesn't get you there.
So I built it myself — mido-client
I searched existing open-source options for a while. A few came close in concept, but none of them ticked every box. So I built it myself. Its name is mido-client.
- GitHub: https://github.com/skaca8/mido-client
- Maven Central: https://central.sonatype.com/artifact/io.github.skaca8/mido-client
The core idea is simple.
Declare external API channels in YAML. Let code focus on business logic.
Comparison
| RestClient (vanilla) | OpenFeign | mido-client | |
|---|---|---|---|
| Configuration style | Java @Bean
|
Interface + annotations | YAML only |
| Multi-channel setup | Manual per bean | Manual per interface | Built-in |
| Dual endpoint per service | Manual | Not supported | Built-in (primary / secondary) |
| Request/response logging | DIY | Plugin required | Built-in (4 levels) |
| Client caching | Manual | Managed by framework | Built-in (ConcurrentHashMap) |
| Based on | Spring Boot 3.2+ | Feign | Spring Boot 3.2+ RestClient |
How the code shrinks
Before — 4 beans + a config class.
After — one block in application.yml.
mido-client:
enabled: true
channels:
abcOta:
title: "ABC OTA"
charset: UTF-8
primary: # lookup endpoint
url: https://api-search.someota.com
read-timeout-seconds: 30
connect-timeout-seconds: 5
authorization:
type: bearer
token: ${ABC_SEARCH_TOKEN}
log: console
secondary: # booking endpoint
url: https://api-booking.someota.com
read-timeout-seconds: 60
authorization:
type: bearer
token: ${ABC_BOOKING_TOKEN}
log: all # console + file
xyzOta:
primary:
url: https://api.xyzota.com
authorization:
type: bearer
token: ${XYZ_TOKEN}
The service code looks like this:
@Service
public class AbcOtaService extends BaseExternalApi {
private final RestClient searchClient;
private final RestClient bookingClient;
public AbcOtaService(MidoClientFactory midoClientFactory) {
this.searchClient = midoClientFactory.getOrCreateClient("abcOta");
this.bookingClient = midoClientFactory.getOrCreateClient("abcOta", EndpointType.SECOND);
}
@Override
protected String getChannelName() {
return "abcOta";
}
public HotelList searchHotels(SearchCondition cond) {
return withDefaultChannelAction("searchHotels", () ->
searchClient.post()
.uri("/hotels/search")
.body(cond)
.retrieve()
.body(HotelList.class)
);
}
public BookingResult book(BookingRequest req) {
return withDefaultChannelAction("book", () ->
bookingClient.post()
.uri("/bookings")
.body(req)
.retrieve()
.body(BookingResult.class)
);
}
}
No @Bean methods. No factory classes. No proliferating interfaces. Want to add another channel? Add one more block in YAML.
Details worth showing off
1. Four-level logging — ops will thank you
log: off # off
log: console # console only
log: file # file only (MidoClientFileLog logger)
log: all # console + file simultaneously
Each log line includes the request URL, method, request/response body, response time, and status. Whenever ops asks "trace this OTA request and tell me where it came from," you no longer need to touch the logging code.
2. ChannelContext + MDC — distributed log tracing
A single withDefaultChannelAction("methodName", ...) call automatically writes channelAction into SLF4J MDC.
<pattern>%d [%X{channelAction}] %-5level %msg%n</pattern>
With a single log line, you can immediately see which channel and which action it broke at. Even when business logic throws an exception, BaseExternalApi handles it so the context is automatically cleared in finally.
Why not ScopedValue?
ScopedValue(JEP 446 / JDK 21+) is the modern way to do context propagation, but mido-client targets Java 17 as its minimum — so we went withThreadLocalfor now. I personally run Java 25, but that's too bleeding-edge to force on library users. We plan to migrate toScopedValuewhen we bump the library's minimum Java version.
3. Gzip — compression + decompression-bomb defense
gzip:
request: true
response: true
min-size: 1024 # skip compression for bodies < 1KB
max-decompressed-size: 10485760 # IOException if response > 10MB after decompression
Request compression, automatic response decompression, and a decompression-bomb defense cap baked in. Some OTAs occasionally return abnormally inflated responses, so having a memory cap is genuinely reassuring.
4. Custom interceptors registered in YAML
Want to plug in Resilience4j? Just put the class name in interceptors:.
interceptors:
- "com.yourapp.PaymentResilienceInterceptor"
mido-client deliberately does NOT bundle a resilience layer. Each team's preferred tool (Resilience4j, Sentinel, Spring Retry, Failsafe...) plugs in via this hook. The README ships with a full Resilience4j integration recipe.
5. Fail-fast config validation
@Validated validates @ConfigurationProperties. If a URL is blank, a timeout is negative, or a required primary endpoint is missing, it throws a runtime error.
6. JSON / XML channel type
If you have a legacy SOAP OTA still hanging around — yes, those happen — just declare type: xml on the channel and Content-Type: application/xml is attached automatically.
channels:
legacySoap:
type: xml
primary:
url: https://soap.example.com
7. Multi-header support
You can register multiple static headers per endpoint. Values that need to ride along on every request — auth tokens, tracing IDs, API versions — can all be collected in one place in YAML.
headers:
- name: X-AUTH-TOKEN
value: ${AUTH_TOKEN}
- name: X-API-Version
value: v1
Getting started
dependencies {
implementation 'io.github.skaca8:mido-client:1.0.8'
}
mido-client:
enabled: true
channels:
yourChannel:
first:
url: https://api.example.com
These two blocks are all you need. In addition to Maven Central, JitPack-based GitHub dependency is also supported.
Closing
- Code shouldn't grow just because channels grow.
- Open
application.ymlonce, and you can see — at a glance — where and how this service talks to the outside world.
Honestly, I built this library to scratch my own itch, so I hope it helps others working in similar environments. If you've ever watched your bean count balloon as you added one more channel, or thought "OpenFeign feels a bit heavy for what I need," give it a try. Feedback, issues, and PRs are all welcome.
- GitHub: https://github.com/skaca8/mido-client
- Maven Central: https://central.sonatype.com/artifact/io.github.skaca8/mido-client
- License: Apache 2.0
P.S. The library's name mido is the name of my Maltese, who has been living with me for 13 years.

Top comments (0)