Domain WHOIS lookups and availability checking for Java. Queries registries over port 43 or RDAP, ships a server list covering almost every extension, and decides availability with a rule chain you can inspect, extend, or replace.
Zero runtime dependencies. Java 17+. A proper JPMS module.
This is the Java sibling of monovm/whois-php; its verdicts are pinned to that package's behaviour by recorded parity tests.
Maven:
<dependency>
<groupId>com.monovm</groupId>
<artifactId>monovm-whois-java</artifactId>
<version>1.0.0</version>
</dependency>Gradle:
implementation("com.monovm:monovm-whois-java:1.0.0")Module path:
requires com.monovm.whois;try (WhoisClient client = WhoisClient.create()) {
WhoisResult result = client.lookup("monovm.com");
result.availability(); // UNAVAILABLE
result.isAvailable(); // false
result.message(); // the registry's answer, verbatim
}A bare name is checked across the popular extensions:
Map<String, DomainAvailability> statuses = client.check("monovm");
// {monovm.com=unavailable, monovm.net=unavailable, monovm.org=unavailable, monovm.info=unavailable}Several names at once, looked up in parallel:
List<WhoisResult> results = client.lookupAll(List.of("monovm.com", "example.dev", "bbc.co.uk"));One client per application, shared: it is immutable and thread safe. Parallel lookups run on one
lazily created pool of daemon threads, which close() releases along with any transport the client
created itself.
Every verdict carries the rule that reached it and the evidence:
WhoisResult result = client.lookup("monovm.com");
result.detection().ifPresent(detection -> {
detection.decidingRule(); // registration-indicators
detection.reason(); // 6 registration fields present
});
System.out.println(client.describe(result).orElseThrow().toDisplayString());verdict: NOT_AVAILABLE (unavailability-indicators: unavailability pattern /status:\s*client/i matched)
response: 1043 chars
registry-available-phrase -> Abstain (no matching signal)
blank-response -> Abstain (no matching signal)
server-error -> Abstain (no matching signal)
unavailability-indicators -> Registered (unavailability pattern /status:\s*client/i matched)
...
An unknown extension or an unreachable server is a result, not an exception, so one broken registry cannot abort a check of a hundred names:
client.lookup("example.nosuchtld").availability(); // INVALID
client.lookup("monovm.com").availability(); // ERROR if the server is unreachableUse lookupOrThrow where a failure should propagate — it throws UnsupportedTldException,
WhoisServerUnavailableException or WhoisTransportException according to what actually went wrong.
Both facades from the PHP package exist, with the same names and semantics:
Checker.whois("monovm.com"); // {monovm.com=unavailable}
Checker.whois("monovm", "google.com", "bing"); // one entry per resolved domain
Checker.whois(List.of("monovm"), List.of(Tld.of("info"))); // custom popularTLDs
WhoisHandler handler = WhoisHandler.whois("monovm.com");
handler.isAvailable();
handler.isValid();
handler.whoisMessage();
handler.tld();
handler.sld();
handler.availabilityDetails(); // the getAvailabilityDetails() field namesDomainAvailability.code() returns the same lowercase strings PHP returns
(available, unavailable, premium, invalid, error), so output from the two libraries can be
diffed directly.
| whois-php | whois-java | |
|---|---|---|
| Empty response | reported available |
reported error — see below |
| WHOIS text | htmlentities() + nl2br() applied |
returned exactly as received |
www.monovm.com |
read as www under .monovm.com, so unresolvable |
reduced to monovm.com |
bbc.co.uk |
correct by luck of splitting at the first dot | resolved against known suffixes |
Missing available phrase in the server list |
matches everything, so every domain looks free | never matches |
| Bulk checks | sequential | parallel, bounded by bulkConcurrency |
| Unicode input | passed through | Punycode encoded and validated |
The first one is the important one. A server that accepts a connection and then closes it without
sending anything has told us nothing — but an empty response also contains no registration fields, and
the last rule in the chain reads "no registration fields" as availability. Reporting a taken domain as
free is the most expensive mistake this library could make, so BlankResponseRule refuses it.
For byte-for-byte PHP behaviour, drop that one rule:
WhoisClient.builder()
.detector(RuleBasedAvailabilityDetector.of(AvailabilityRules.phpCompatible()))
.build();The remaining differences are either bug fixes or presentation concerns that do not belong in a library.
The pattern tables inherited from the PHP package contain a handful of entries that demonstrably read a
response wrongly. They are kept in defaults() because parity is the point of the default chain, and
changing them silently would move existing callers' verdicts. AvailabilityRules.strict() repairs them:
WhoisClient client = WhoisClient.builder()
.detector(RuleBasedAvailabilityDetector.of(AvailabilityRules.strict()))
.build();| Response | defaults() |
strict() |
The inherited mistake |
|---|---|---|---|
Registrant Phone: +1.4045551234 |
available | unavailable | /404/, meant for RDAP status codes, matches inside a phone number |
aeDA record with Registrant Contact Name: |
available | unavailable | only one field name matches the table, and "few fields" is read as "free" |
Contact our freephone line |
available | unavailable | free matched as a bare substring |
.fr record saying "data is available at…" |
available | unavailable | two dozen extensions list the single word available |
.uk: "has not been registered" |
unavailable | available | .uk lists a bare /registered/i, which matches inside a negation |
.io: response opening "Domain not found" |
unavailable | available | ---domain not found is filed as evidence the name is taken |
The first four are the direction that costs money, so if you are showing results to a customer, use
strict(). Its trade is that an unrecognised registry may report a free name as taken, which loses a
sale rather than promising one that cannot be delivered. Both chains use the same rule names, so
diagnostic reports stay comparable, and StrictDetectionPatterns derives its tables from the base ones
so a phrase added for a new registry is picked up by both.
Four collaborators, each an interface, each replaceable on its own:
"www.MonoVM.com"
│
▼
DomainParser ──────────► monovm + .com split input into name and extension
│
▼
TldRegistry ───────────► socket://whois.crsnic.net which server serves .com
│
▼
WhoisTransport ────────► "Domain Name: MONOVM.COM…" ask it, over port 43 or RDAP
│
▼
AvailabilityDetector ──► NOT_AVAILABLE + why read the answer
Behaviour is changed by composing different pieces rather than by editing the pipeline.
Cross-cutting concerns are decorators, so each is one small class and they stack in any order:
WhoisTransport transport =
CachingWhoisTransport.of( // a repeated question costs nothing
RetryingWhoisTransport.of( // registries drop connections under load
RateLimitingWhoisTransport.of( // and ban clients that ask too fast
RoutingWhoisTransport.standard(Duration.ofSeconds(10)),
Duration.ofMillis(500)),
3),
Duration.ofMinutes(10));
WhoisClient client = WhoisClient.builder().transport(transport).build();The order is meaningful: caching outside retrying means a cached answer costs nothing even for a
domain whose first lookup needed three attempts. The usual stack is built for you from
WhoisOptions — reach for this only when you want something unusual.
ReferralFollowingWhoisTransport additionally follows a thin registry's pointer to the registrar's own
server, which is what turns Verisign's four-line stub into a full record.
WHOIS has no schema. Every registry words "this name is free" differently, several word it in a way that also appears in their legal boilerplate, and a few answer a question you did not ask. So detection is many small tests applied in a fixed order, the first one to take a position winning.
The ordering principle: every check that can establish registration runs before every check that can establish availability. A loosely worded availability pattern must never be able to overrule a registration record.
| # | Rule | Concludes |
|---|---|---|
| 1 | registry-available-phrase |
the phrase curated for this registry beats any heuristic |
| 2 | blank-response |
silence is not an answer |
| 3 | server-error |
a refusal to answer is not an empty registry |
| 4 | unavailability-indicators |
an explicit "registered" beats an inference |
| 5 | rdap-registered |
a structured RDAP record is conclusive |
| 6 | registration-indicators |
three record fields outweigh any wording |
| 7 | availability-keywords |
registration ruled out, now read the words |
| 8 | no-match-patterns |
the same statements, tolerant of odd whitespace |
| 9 | tld-specific-patterns |
per-registry wording, loose enough to need everything above first |
| 10 | short-response |
too small to be a record — reporting only, never votes |
| 11 | domain-status-indicators |
an explicit status field, or no record fields at all |
When every rule abstains the answer is "not available", which is the conservative default.
Rule 11's second half — "no record fields at all" — is the weakest signal in the chain, and the one
strict() switches off.
Teaching it about a registry takes a rule, not a fork:
AvailabilityRule myRegistry = new AvailabilityRule() {
public String name() { return "example-registry"; }
public RuleOutcome evaluate(DetectionContext context) {
return context.contains("% no entries for this name")
? RuleOutcome.available("registry-specific phrase")
: RuleOutcome.abstain();
}
};
WhoisClient client = WhoisClient.builder()
.detector(RuleBasedAvailabilityDetector.builder().addFirst(myRegistry).build())
.build();The builder also supports addLast, addBefore, replace and remove, so a rule you disagree with
can be dropped without touching the rest.
890 extension declarations across 285 registries, bundled in the jar as
com/monovm/whois/registry/dist.whois.json — the same file the PHP package ships.
Override individual extensions without forking by putting your own whois.json at that classpath
location; it is layered on top. Or supply a registry outright:
TldRegistry registry = CompositeTldRegistry.of(
TldRegistries.bundled(),
TldRegistries.fromPath(Path.of("/etc/whois-overrides.json")));
WhoisClient client = WhoisClient.builder().registry(registry).build();WhoisOptions holds the plain settings; the client builder wires up collaborators. Keeping the two
apart is what stops the constructor becoming a twenty-argument list.
WhoisOptions options = WhoisOptions.builder()
.timeout(Duration.ofSeconds(5)) // connect and read
.maxAttempts(3) // retry with exponential backoff
.retryBackoff(Duration.ofMillis(250))
.minimumHostInterval(Duration.ofMillis(400)) // per WHOIS host, not global
.cacheTimeToLive(Duration.ofMinutes(15))
.followReferrals(true)
.bulkConcurrency(16)
.popularTlds("com", "net", "dev")
.charset(StandardCharsets.UTF_8)
.build();
WhoisClient client = WhoisClient.create(options);| Setting | Default | Notes |
|---|---|---|
connectTimeout / readTimeout |
10s | timeout(…) sets both |
maxAttempts |
2 | 1 disables retrying |
retryBackoff |
250ms | doubles per attempt |
minimumHostInterval |
off | throttling is opt-in |
cacheTimeToLive |
off | caching is opt-in |
followReferrals |
false |
costs an extra round trip |
bulkConcurrency |
8 | parallel lookups per batch |
popularTlds |
com, net, org, info | used for bare names |
charset |
UTF-8 | invalid bytes fall back to ISO-8859-1 rather than becoming ? |
No logging framework is imposed. Register a listener:
WhoisClient client = WhoisClient.builder()
.listener(new WhoisEventListener() {
@Override public void onLookupCompleted(WhoisResult result) {
metrics.timer("whois." + result.availability().code()).record(result.elapsed());
}
})
.build();An exception from a listener is swallowed rather than allowed to fail the lookup.
The jar is runnable:
$ java -jar monovm-whois-java-1.0.0.jar monovm.com example.dev
monovm.com unavailable 412 ms
example.dev unavailable 233 ms
$ java -jar monovm-whois-java-1.0.0.jar --json --details monovm.com
$ java -jar monovm-whois-java-1.0.0.jar --tlds com,net,dev monovm
$ java -jar monovm-whois-java-1.0.0.jar --helpExit status is 0 when every name got a real answer, 1 when a lookup failed, 2 for a usage error,
so it drops into a shell script without output parsing.
WhoisTransport is a single-method interface, so no network is needed to test code that uses this
library:
WhoisClient client = WhoisClient.builder()
.registry(InMemoryTldRegistry.builder()
.add(WhoisServerDefinition.builder()
.uri("socket://whois.example.test")
.availableMatch("No match for")
.extensions(Tld.of("com"))
.build())
.build())
.transport(request -> new WhoisResponse(request.domain(), request.endpoint(),
"No match for EXAMPLE.COM", Duration.ZERO))
.build();
assertThat(client.lookup("example.com").isAvailable()).isTrue();src/test/resources/fixtures/ holds 36 recorded registry responses covering the awkward cases: Verisign
thin records, DENIC's Status: connect, IRNIC banners, RDAP objects and RDAP 404s, throttling notices,
wrong-server replies, reserved-name refusals, an empty response, and the registries whose field names
sit just outside the tables — aeDA, NASK, registro.br, IIS, TCI, CNNIC, auDA.
fixtures/php-parity.json records what monovm/whois-php decides for each of them — generated by
running the PHP implementation itself, not by reading its source:
php tools/generate-php-parity.php /path/to/whois-phpPhpParityTest then asserts, per fixture, that this library reaches the same verdict, and that its
diagnostic detail map matches PHP's field for field. WhoisClientTest.EndToEndParity does the same for
the end-to-end status, premium detection included.
mvn test # 356 tests, no network needed
mvn -Dwhois.liveTests=true test # also queries real registriesFor anyone extending this, the shape is deliberate:
- Value objects.
Tld,DomainName,DomainQuery,WhoisEndpoint,WhoisRequest,WhoisResponse,DetectionResultare immutable and normalise on construction, soTld.of("COM")andTld.of(".com")are the same value and an invalid name cannot be built at all. - Strategy for every decision that reasonably varies:
DomainParser,TldRegistry,WhoisTransport,AvailabilityDetector,AvailabilityRule. - Chain of responsibility for detection, which is the only structure that copes with a hundred registries wording the same fact differently.
- Decorator for retrying, caching, throttling and referral following — each one class, composable in any order, none of them touching the protocol code.
- Template method in
AbstractWhoisTransport, which fixes timing and charset handling so no transport can get them subtly wrong. - Builder wherever a type has more than three optional fields.
- Composite for layering registries.
- Facade in
WhoisClient,CheckerandWhoisHandler. - Null object for the event listener, so the lookup path has no null checks.
- Sealed hierarchies for
WhoisEndpointandRuleOutcome, so handling code cannot silently miss a case.
Nothing in com.monovm.whois.internal is exported or covered by the compatibility promise.
The release profile attaches sources and javadoc, signs everything, and deploys through the Sonatype
Central Portal:
mvn -Prelease clean deployIt needs a GPG key and a Central Portal token in ~/.m2/settings.xml under the server id central,
and the com.monovm namespace verified — which is done by publishing a DNS TXT record on
monovm.com. autoPublish is off, so a deployment waits for a manual release in the portal.
New extension, new registry wording, or a bug: pull requests welcome. Please add a fixture for the response that motivated the change — a pattern without a recorded response it explains is a pattern nobody can safely alter later. See CONTRIBUTING.md.