This document contains guidance for Claude (and other AI assistants) when working with the GeoIP2-java codebase. It captures architectural patterns, conventions, and lessons learned to help maintain consistency and quality.
GeoIP2-java is MaxMind's official Java client library for:
- GeoIP2/GeoLite2 Web Services: Country, City, and Insights endpoints
- GeoIP2/GeoLite2 Databases: Local MMDB file reading for various database types (City, Country, ASN, Anonymous IP, ISP, etc.)
The library provides both web service clients and database readers that return strongly-typed model objects containing geographic, ISP, anonymizer, and other IP-related data.
Key Technologies:
- Java 17+ (using modern Java features like records)
- Jackson for JSON serialization/deserialization
- MaxMind DB reader for binary database files
- Maven for build management
- JUnit 5 for testing
- WireMock for web service testing
com.maxmind.geoip2/
├── model/ # Response models (CityResponse, InsightsResponse, etc.)
├── record/ # Data records (City, Location, Traits, Anonymizer, etc.)
├── exception/ # Custom exceptions for error handling
├── DatabaseReader # Local MMDB file reader
├── WebServiceClient # HTTP client for MaxMind web services
└── DatabaseProvider/WebServiceProvider interfaces
All model and record classes use Java records for immutability and conciseness:
public record Anonymizer(
@JsonProperty("confidence")
Integer confidence,
@JsonProperty("is_anonymous")
boolean isAnonymous,
// ... more fields
) implements JsonSerializable {
// Compact canonical constructor for defaults
public Anonymizer {
// Set defaults for null values
}
}Key Points:
- Records provide automatic
equals(),hashCode(),toString(), and accessor methods - Use
@JsonPropertyfor JSON field mapping - Use
@MaxMindDbParameterfor database field mapping - Implement compact canonical constructors to set defaults for null values
Record parameters are always ordered alphabetically by field name. This maintains consistency across the codebase:
public record InsightsResponse(
Anonymizer anonymizer, // A comes first
City city, // C comes next
Continent continent, // C (alphabetically after "city")
// ... etc.
)When deprecating fields:
For record parameters (preferred for new deprecations):
public record Traits(
@Deprecated(since = "5.0.0", forRemoval = true)
@JsonProperty("is_anonymous")
boolean isAnonymous,
// ...
)This automatically marks the accessor method (isAnonymous()) as deprecated.
For JavaBeans-style getters (legacy code only):
@Deprecated(since = "5.0.0", forRemoval = true)
public String getUserType() {
return userType();
}Do NOT add deprecated getters for new fields - they're only needed for backward compatibility with existing fields that had JavaBeans-style getters before the record migration.
All record classes in src/main/java/com/maxmind/geoip2/record/ should provide a no-arg constructor that sets sensible defaults:
public Anonymizer() {
this(null, false, false, false, false, false, false, null, null);
}- Nullable fields →
null - Boolean fields →
false
Note: Model classes in src/main/java/com/maxmind/geoip2/model/ do not require default constructors as they are typically constructed from API responses.
Some record classes are only used by web services and do not need MaxMind DB support:
Web Service Only Records (no @MaxMindDbParameter or @MaxMindDbConstructor):
- Records that are exclusive to web service responses (e.g.,
Anonymizerfor Insights API) - Only need
@JsonPropertyannotations for JSON deserialization - Simpler implementation without database parsing logic
Database-Supported Records (need @MaxMindDbParameter and often @MaxMindDbConstructor):
- Records used by both web services and database files (e.g.,
Traits,Location,City) - Need both
@JsonPropertyand@MaxMindDbParameterannotations - May need
@MaxMindDbConstructorfor date parsing or other database-specific conversion
How to Determine:
- Check the JavaDoc - does it say "This is only available from the X web service"?
- Look at existing similar records in the
record/package - If in doubt, ask - adding unnecessary database support adds complexity
Tests are organized by model/class:
src/test/java/com/maxmind/geoip2/model/- Response model testssrc/test/resources/test-data/- JSON fixtures for tests
When adding new fields to responses:
- Update the JSON fixture files in
src/test/resources/test-data/ - Update the corresponding test methods in
*Test.javafiles - Update
JsonTest.javato include the new fields in round-trip tests
Example: Adding anonymizer to InsightsResponse:
{
"anonymizer": {
"confidence": 99,
"is_anonymous": true,
"network_last_seen": "2024-12-31",
"provider_name": "NordVPN"
},
// ... other fields
}Web service tests use WireMock to stub HTTP responses:
wireMock.stubFor(get(urlEqualTo("/geoip/v2.1/insights/1.1.1.1"))
.willReturn(aResponse()
.withStatus(200)
.withBody(readJsonFile("insights0"))));- Determine alphabetical position for the new field
- Add the field with proper annotations:
@JsonProperty("field_name") @MaxMindDbParameter(name = "field_name") Type fieldName,
- Update the default constructor (if in
record/package) to include the new parameter - For minor version releases: Add a deprecated constructor matching the old signature to avoid breaking changes (see "Avoiding Breaking Changes in Minor Versions" section)
- Add JavaDoc describing the field
- Update test fixtures with example data
- Add test assertions to verify the field is properly deserialized
When creating a new record class in src/main/java/com/maxmind/geoip2/record/:
- Determine if web service only or database-supported (see "Web Service Only vs Database Records" section)
- Follow the pattern from existing similar records (e.g.,
Location,Traits, orAnonymizer) - Alphabetize parameters by field name
- Add appropriate annotations:
- All records:
@JsonProperty - Database-supported only:
@MaxMindDbParameterand possibly@MaxMindDbConstructor
- All records:
- Implement
JsonSerializableinterface - Add a no-arg default constructor (see section on Default Constructors)
- Don't add deprecated getters - the record accessors are sufficient
- Provide comprehensive JavaDoc for all parameters
When deprecating fields in favor of new structures:
- Use
@Deprecatedon record parameters (not explicit methods) - Include helpful deprecation messages in JavaDoc pointing to alternatives
- Mark as
forRemoval = truewith appropriate version - Keep deprecated fields functional - don't break existing code
- Update CHANGELOG.md with deprecation notices
Example deprecation message:
* @param isAnonymous This is true if the IP address belongs to any sort of anonymous network.
* This field is deprecated. Please use the anonymizer object from the
* Insights response.Always update CHANGELOG.md for user-facing changes:
## 5.0.0 (unreleased)
* A new `Anonymizer` record has been added...
* A new `ipRiskSnapshot` field has been added...
* The anonymous IP flags have been deprecated...
* **BREAKING:** Description of breaking changes...When adding a new field to an existing record class during a minor version release (e.g., 4.x.0 → 4.y.0), you must maintain backward compatibility for users who may be programmatically constructing these records.
The Problem: Adding a field to a record changes the signature of the canonical constructor, which is a breaking change for existing code that constructs the record directly.
The Solution: Add a deprecated constructor that matches the old signature:
public record Traits(
// ... existing fields ...
String domain,
// NEW FIELD added in minor version (inserted in alphabetical position)
Double ipRiskSnapshot,
String organization
) {
// Updated default constructor with new field
public Traits() {
this(null, null, null);
}
// Deprecated constructor maintaining old signature for backward compatibility
@Deprecated(since = "4.5.0", forRemoval = true)
public Traits(
String domain,
String organization
// Note: ipRiskSnapshot is NOT in this constructor
) {
this(domain, null, organization); // New field defaults to null (in alphabetical position)
}
}Key Points:
- The deprecated constructor matches the signature before the new field was added
- It calls the new canonical constructor with
null(or appropriate default) for the new field - Mark it
@DeprecatedwithforRemoval = truefor the next major version - Document this in CHANGELOG.md as a new feature, not a breaking change
For Major Versions: You do NOT need to add the deprecated constructor - breaking changes are expected in major version bumps (e.g., 4.x.0 → 5.0.0).
Both DatabaseReader and WebServiceClient are thread-safe and should be reused across requests:
- Create once, share across threads
- Reusing clients enables connection pooling and improves performance
- Document thread-safety in JavaDoc for all client classes
Adding a new field to a record changes the canonical constructor signature, breaking existing code.
Solution: For minor version releases, add a deprecated constructor that maintains the old signature. See "Avoiding Breaking Changes in Minor Versions" section for details.
When you have two constructors with similar signatures (e.g., both ending with String), you may get "ambiguous constructor" errors.
Solution: Cast null parameters to their specific type:
this(null, false, null); // Cast if needed: (TypeName) nullAfter adding new fields to a response model, tests fail with deserialization errors.
Solution: Update all related test fixtures:
- Test JSON files (e.g.,
insights0.json,insights1.json) - In-line JSON in
JsonTest.java - Test assertions in
*ResponseTest.javafiles
mvn clean test # Run all tests
mvn test -Dtest=JsonTest # Run specific test class
mvn test -Dtest=InsightsResponseTest,JsonTest # Multiple tests- Checkstyle enforces code style (see
checkstyle.xml) - Run
mvn checkstyle:checkto verify compliance - Tests must pass checkstyle to merge
- Java 17+ required
- Uses modern Java features (records, sealed classes potential)
- Target compatibility should match current LTS Java versions
Use compact canonical constructors to set defaults and validate:
public record InsightsResponse(
Anonymizer anonymizer,
City city,
// ...
) {
public InsightsResponse {
anonymizer = anonymizer != null ? anonymizer : new Anonymizer();
city = city != null ? city : new City();
// ...
}
}Return empty objects instead of null for better API ergonomics:
public City city() {
return city; // Never null due to compact constructor
}Users can safely call response.city().name() even if city data is absent.
All models implement JsonSerializable for consistent JSON output:
public interface JsonSerializable {
default String toJson() throws IOException {
JsonMapper mapper = JsonMapper.builder()
.disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.addModule(new JavaTimeModule())
.addModule(new InetAddressModule())
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
return mapper.writeValueAsString(this);
}
}- Reads binary MMDB files using
maxmind-dblibrary - Methods return
Optional<T>or throwAddressNotFoundException - Support for multiple database types: City, Country, ASN, Anonymous IP, etc.
- Thread-safe, should be reused
- Uses Java 11+
HttpClientfor HTTP requests - Methods throw
GeoIp2Exceptionor subclasses on errors - Supports custom timeouts, locales, and proxy configuration
- Thread-safe, connection pooling via reuse
- maxmind-db: Binary MMDB database reader
- jackson-databind: JSON serialization/deserialization
- jackson-datatype-jsr310: Java 8+ date/time support
- wiremock: HTTP mocking for tests
- junit-jupiter: JUnit 5 testing framework
- API Documentation
- GeoIP2 Web Services Docs
- MaxMind DB Format
- GitHub Issues: https://github.com/maxmind/GeoIP2-java/issues
Last Updated: 2024-11-06