Home Engineering

Best Practices for Writing Common Libraries in Java

12 March 2026 · 19 min read
Table of contents

A poorly designed internal library spreads faster and lives longer than almost anything else in a codebase. Once 20 services depend on it, changing anything is painful. This post uses a single running example, an audit-client library that sends audit events to a central audit service, to explain each design decision from three angles: why it exists, what breaks without it, and the specific trade-offs involved.

Where to Start: From Zero to a First Library

Before getting into the more complex best practices, here is a practical path for writing a first library, assuming you are comfortable with Java and Gradle and you are seeing repeated code across multiple services.

Step 1: Identify the specific problem

Do not start with “I want to write a useful library.” Start with a concrete problem you are facing:

  • “Every service copy-pastes this audit event sending code.”
  • “Everyone writes HTTP clients differently; some have no retry logic.”
  • “Config parsing is duplicated in five places.”

A good library solves one specific problem, not every problem.

Step 2: Create the project

mkdir audit-client && cd audit-client
gradle init --type java-library --dsl kotlin

Or create a minimal build.gradle.kts manually:

plugins {
    `java-library`
    `maven-publish`
}

group = "com.example"
version = "0.1.0-SNAPSHOT"

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
    withSourcesJar()
}

repositories { mavenCentral() }

dependencies {
    compileOnly("org.slf4j:slf4j-api:2.0.13")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.slf4j:slf4j-simple:2.0.13")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test { useJUnitPlatform() }

publishing {
    publications {
        create<MavenPublication>("maven") { from(components["java"]) }
    }
}

Step 3: Write the interface first, implementation second

The first question must be: “What will consumers call?” not “How will I implement it?”

// src/main/java/com/example/audit/AuditClient.java
public interface AuditClient {
    void send(AuditEvent event);
}

// src/main/java/com/example/audit/AuditEvent.java
public record AuditEvent(String actorId, String action, String resourceId) {}

At this point, write a few lines of simulated consumer code to feel out the API:

// If this looks awkward to write, redesign the API before going further
AuditEvent event = new AuditEvent("user-1", "CREATE", "order-42");
client.send(event);

If this code reads naturally, continue. If not, adjust the interface now, before any consumer depends on it.

Step 4: Implement the simplest version that works

No retry, no connection pool, no async yet. Make it correct first:

// src/main/java/com/example/audit/DefaultAuditClient.java
public class DefaultAuditClient implements AuditClient {
    private final String endpoint;
    private final HttpClient http = HttpClient.newHttpClient();
    private final ObjectMapper mapper = new ObjectMapper();

    public DefaultAuditClient(String endpoint) {
        this.endpoint = Objects.requireNonNull(endpoint, "endpoint must not be null");
    }

    @Override
    public void send(AuditEvent event) {
        try {
            var body = mapper.writeValueAsString(event);
            var request = HttpRequest.newBuilder()
                .uri(URI.create(endpoint + "/events"))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();
            http.send(request, HttpResponse.BodyHandlers.discarding());
        } catch (Exception e) {
            throw new RuntimeException("Failed to send audit event", e);
        }
    }
}

This version is not perfect: no timeout, no retry, rough exception handling. But it does one thing correctly and is easy to understand.

Step 5: Test through the public API

Tests must be written from the consumer’s perspective, not from inside the implementation’s internals:

class AuditClientTest {

    private MockWebServer server;
    private AuditClient client; // use the interface, not DefaultAuditClient

    @BeforeEach
    void setUp() throws IOException {
        server = new MockWebServer();
        server.start();
        client = new DefaultAuditClient(server.url("/").toString());
    }

    @AfterEach
    void tearDown() throws IOException { server.shutdown(); }

    @Test
    void sendsEventToEndpoint() throws InterruptedException {
        server.enqueue(new MockResponse().setResponseCode(200));

        client.send(new AuditEvent("user-1", "CREATE", "order-42"));

        RecordedRequest req = server.takeRequest();
        assertThat(req.getPath()).isEqualTo("/events");
        assertThat(req.getBody().readUtf8()).contains("user-1");
    }
}

Add com.squareup.okhttp3:mockwebserver:4.12.0 to testImplementation.

Step 6: Publish locally and test in a real consumer project

./gradlew publishToMavenLocal

The jar appears at ~/.m2/repository/com/example/audit-client/0.1.0-SNAPSHOT/. In the consumer project:

// consumer's build.gradle.kts
repositories {
    mavenLocal()  // look here first
    mavenCentral()
}

dependencies {
    implementation("com.example:audit-client:0.1.0-SNAPSHOT")
}

Write actual code in the consumer and try it. If anything feels unnatural, this is the time to fix the API, before many services depend on it.

Step 7: Iterate from real usage

After the trial, you will discover specific gaps:

  • “The String endpoint constructor is not enough; I need timeout config” -> add an AuditConfig builder.
  • “Consumers need to attach metadata to events” -> extend AuditEvent.
  • “I need an async version so it does not block the request thread” -> add sendAsync().

This is when to apply the patterns in the rest of this post. Do not design everything upfront; design from real usage and refine incrementally.

Sign that you are ready for the next section: the library has passing tests, can be published locally, and at least one consumer project has used it successfully. From here, decisions about multi-module structure, dependency hygiene, and auto-configuration become meaningful.


Multi-module Architecture: Separate Core from Framework

The most common mistake when writing a library is embedding Spring or Quarkus in the core business logic. When that happens, the library only works in one framework, and every major framework upgrade may require a library version bump as well.

The correct structure splits the library into multiple modules:

Drag · Scroll to zoom
audit-client/
├── audit-client-core/          # pure Java, zero framework dependency
├── audit-client-spring/        # Spring-specific adapters (optional dep on Spring)
├── audit-client-spring-boot-starter/ # auto-configuration
├── audit-client-quarkus/       # Quarkus CDI extension
└── audit-client-bom/           # Bill of Materials for version management

audit-client-core is only allowed to depend on Java SE and small utility libraries like SLF4J (the logging facade). No Spring, no Quarkus, no Jakarta EE. Code in core runs in any JVM application.

What breaks without this separation: Library v1.0 uses spring-web 5.x. When a consumer upgrades to Spring Boot 3 (Spring 6), they must wait for the library to release v2.0. In the meantime, they cannot upgrade Spring. If Spring 5 has a security vulnerability, they are stuck.

Trade-off: Multi-module requires more Gradle/Maven setup effort than a single module. For a small library (a few hundred lines), a single module is fine. Multi-module is only justified when the library needs to support multiple frameworks or when there are genuinely optional parts.

Dependency Hygiene: Do Not Pollute the Consumer’s Classpath

Every dependency you declare in the library is a dependency the consumer is forced to accept, even if they do not want it. This is the source of “dependency hell”: version conflicts when library A wants jackson 2.14 and library B wants jackson 2.16.

Gradle: declare the correct dependency scope:

// audit-client-core's build.gradle.kts
dependencies {
    // api: appears in public signatures; consumers need this at compile time
    api("com.fasterxml.jackson.core:jackson-databind:2.17.0")

    // implementation: used internally, not exposed in public API
    // consumers do not see it and cannot conflict with it
    implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1")

    // compileOnly: used at compile time, not included in the runtime jar
    // used for annotation processors and annotation-only dependencies
    compileOnly("org.jetbrains:annotations:24.1.0")

    // Framework deps that the consumer already has
    compileOnly("org.slf4j:slf4j-api:2.0.13") // consumer chooses the implementation

    // Test dependencies never leak into production
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.mockito:mockito-core:5.11.0")
}

Maven equivalent:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.13</version>
    <scope>provided</scope> <!-- does not leak into consumer -->
</dependency>

<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
    <version>5.3.1</version>
    <optional>true</optional> <!-- consumer must opt in explicitly -->
</dependency>

Bill of Materials (BOM) for easy consumer version management:

<!-- audit-client-bom/pom.xml -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>audit-client-core</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>audit-client-spring-boot-starter</artifactId>
            <version>${project.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

Consumers only need to import the BOM and do not need to specify a version for each module:

implementation(platform("com.example:audit-client-bom:1.2.0"))
implementation("com.example:audit-client-spring-boot-starter") // version comes from BOM automatically

A real production bug: The library declares jackson-databind at compile scope (the default). The consumer is using jackson-databind 2.15 to avoid a CVE. The library pulls in 2.13, which has the vulnerability. Result: the consumer is silently downgraded on security without knowing it.

Trade-off: Use api instead of implementation when a type from that dependency appears in a public method signature of the library. Using it incorrectly (using implementation for a type in the public API) causes compile errors in the consumer. Overusing api leaks unnecessary transitive dependencies.

API Design: Minimal Surface, Maximum Stability

The public API is a contract with the consumer. Everything you make public is something you must maintain forever, or provide a migration path when you change it.

Principle: make everything that is not API package-private:

// audit-client-core/src/main/java/com/example/audit/
// ┣ AuditClient.java         <- public, this is the API
// ┣ AuditEvent.java          <- public, consumers create events
// ┣ AuditConfig.java         <- public, consumers configure the client
// ┗ internal/
//   ┣ HttpAuditSender.java   <- package-private, implementation detail
//   ┣ RetryPolicy.java       <- package-private
//   ┗ EventSerializer.java   <- package-private
// Internal class: package-private, consumers cannot depend on it
class HttpAuditSender implements AuditSender {
    // implementation detail, can be changed freely
}

// Public API: interface, stable contract
public interface AuditClient {
    void send(AuditEvent event);
    CompletableFuture<Void> sendAsync(AuditEvent event);
}

If HttpAuditSender were public, consumers could instantiate it directly and bypass the interface. When you refactor to a gRPC sender, consumer code breaks.

Interface-first for extensibility:

// Public interface - consumers can mock it in tests or implement a custom sender
public interface AuditClient {
    void send(AuditEvent event);
    CompletableFuture<Void> sendAsync(AuditEvent event);

    // Default method: can be added to the interface without breaking backward compatibility
    default void sendBatch(List<AuditEvent> events) {
        events.forEach(this::send);
    }
}

Immutable value objects with Java Records (Java 16+):

// AuditEvent is immutable - consumers cannot accidentally modify it after sending
public record AuditEvent(
    String actorId,
    String action,
    String resourceType,
    String resourceId,
    Instant occurredAt,
    Map<String, String> metadata
) {
    // Compact constructor: validate here once, not everywhere it is used
    public AuditEvent {
        Objects.requireNonNull(actorId, "actorId must not be null");
        Objects.requireNonNull(action, "action must not be null");
        Objects.requireNonNull(resourceType, "resourceType must not be null");
        Objects.requireNonNull(resourceId, "resourceId must not be null");
        Objects.requireNonNull(occurredAt, "occurredAt must not be null");
        // Defensive copy of the mutable map
        metadata = metadata != null
            ? Map.copyOf(metadata)
            : Map.of();
    }

    // Builder for an ergonomic API
    public static Builder builder() { return new Builder(); }

    public static final class Builder {
        private String actorId;
        private String action;
        private String resourceType;
        private String resourceId;
        private Instant occurredAt = Instant.now(); // sensible default
        private final Map<String, String> metadata = new LinkedHashMap<>();

        public Builder actorId(String actorId) {
            this.actorId = actorId; return this;
        }
        public Builder action(String action) {
            this.action = action; return this;
        }
        public Builder resourceType(String resourceType) {
            this.resourceType = resourceType; return this;
        }
        public Builder resourceId(String resourceId) {
            this.resourceId = resourceId; return this;
        }
        public Builder metadata(String key, String value) {
            this.metadata.put(key, value); return this;
        }
        public AuditEvent build() {
            return new AuditEvent(actorId, action, resourceType,
                resourceId, occurredAt, metadata);
        }
    }
}

Sealed classes for error types (Java 17+):

// Exhaustive error hierarchy - consumers know exactly which errors can occur
public sealed interface AuditException permits
    AuditException.ConnectionFailed,
    AuditException.EventRejected,
    AuditException.Unauthorized {

    record ConnectionFailed(String endpoint, Throwable cause) implements AuditException {}
    record EventRejected(String reason, AuditEvent event) implements AuditException {}
    record Unauthorized(String message) implements AuditException {}
}

Consumers use pattern matching to handle each case explicitly:

try {
    auditClient.send(event);
} catch (AuditRuntimeException ex) {
    switch (ex.getError()) {
        case AuditException.ConnectionFailed cf ->
            log.warn("Audit service unreachable: {}", cf.endpoint());
        case AuditException.EventRejected er ->
            log.error("Invalid event: {}", er.reason());
        case AuditException.Unauthorized u ->
            alertSecurityTeam(u.message());
    }
}

Trade-off: Records and sealed classes require Java 16+ and 17+ respectively. If the library must support Java 11, regular classes are needed. Always document the Java version requirement clearly in the README and in pom.xml/build.gradle.

Configuration Pattern: Sensible Defaults, Validate Early

What breaks without early config validation:

// BAD: config is passed in but never validated
public class DefaultAuditClient implements AuditClient {
    private final String endpoint;
    private final int timeoutMs;

    public DefaultAuditClient(String endpoint, int timeoutMs) {
        this.endpoint = endpoint; // null? empty? invalid URL?
        this.timeoutMs = timeoutMs; // negative? zero?
    }

    public void send(AuditEvent event) {
        // NullPointerException or MalformedURLException surfaces
        // when send() is first called, not when the object is constructed
        // The stack trace does not point to where the config was set up
        httpClient.post(endpoint, event);
    }
}

The error occurs at runtime, far from where the config was set up. Debugging takes time.

Correct: validate at build time, fail fast:

public final class AuditConfig {
    private final URI endpoint;
    private final Duration connectTimeout;
    private final Duration requestTimeout;
    private final int maxRetries;
    private final boolean asyncMode;

    private AuditConfig(Builder builder) {
        this.endpoint       = builder.endpoint;
        this.connectTimeout = builder.connectTimeout;
        this.requestTimeout = builder.requestTimeout;
        this.maxRetries     = builder.maxRetries;
        this.asyncMode      = builder.asyncMode;
    }

    public static Builder builder(String endpoint) {
        return new Builder(endpoint);
    }

    public static final class Builder {
        private final URI endpoint;
        private Duration connectTimeout = Duration.ofSeconds(5);  // sensible default
        private Duration requestTimeout = Duration.ofSeconds(10); // sensible default
        private int maxRetries = 3;         // sensible default
        private boolean asyncMode = false;  // safe default

        public Builder(String endpoint) {
            // Validate right here
            Objects.requireNonNull(endpoint, "endpoint must not be null");
            if (endpoint.isBlank()) throw new IllegalArgumentException("endpoint must not be blank");
            try {
                this.endpoint = URI.create(endpoint);
                if (!this.endpoint.isAbsolute()) {
                    throw new IllegalArgumentException("endpoint must be an absolute URI: " + endpoint);
                }
            } catch (IllegalArgumentException e) {
                throw new AuditConfigException("Invalid endpoint URI: " + endpoint, e);
            }
        }

        public Builder connectTimeout(Duration timeout) {
            if (timeout == null || timeout.isNegative() || timeout.isZero())
                throw new IllegalArgumentException("connectTimeout must be positive");
            this.connectTimeout = timeout;
            return this;
        }

        public Builder maxRetries(int retries) {
            if (retries < 0) throw new IllegalArgumentException("maxRetries must be >= 0");
            this.maxRetries = retries;
            return this;
        }

        public AuditConfig build() {
            // Cross-field validation
            if (connectTimeout.compareTo(requestTimeout) > 0) {
                throw new AuditConfigException(
                    "connectTimeout must not exceed requestTimeout");
            }
            return new AuditConfig(this);
        }
    }
}

The consumer receives an exception at startup, not during production traffic:

// Exception thrown here when the application starts
AuditConfig config = AuditConfig.builder("not-a-valid-url")
    .connectTimeout(Duration.ofSeconds(5))
    .build(); // AuditConfigException: Invalid endpoint URI

Logging: SLF4J Facade, Never Configure Appenders

Libraries must use SLF4J (or System.Logger from Java 9+) as a logging facade. Never declare a dependency on Log4j, Logback, or any java.util.logging implementation.

Why: Consumers choose the logging implementation. If the library hardcodes Logback but the consumer uses Log4j2, the classpath has two logging frameworks. Where output goes becomes unpredictable and performance is affected.

// CORRECT: SLF4J facade - no implementation is chosen
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class HttpAuditSender {
    private static final Logger log = LoggerFactory.getLogger(HttpAuditSender.class);

    public void send(AuditEvent event) {
        log.debug("Sending audit event: action={}, resource={}/{}",
            event.action(), event.resourceType(), event.resourceId());
        // ...
        log.info("Audit event sent successfully in {}ms", durationMs);
    }
}

Never do this:

// WRONG: library configures the consumer's logging
static {
    // Libraries must never do this
    LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
    ctx.getConfiguration().addAppender(new ConsoleAppender(...));
}

Log level conventions:

  • ERROR: the library cannot recover; consumer attention required.
  • WARN: degraded operation (retry exhausted, fallback used).
  • INFO: significant lifecycle events (client started, config loaded). Disable in production.
  • DEBUG: per-request detail. OFF by default in production.
  • TRACE: detailed internal state. Only for debugging the library itself.

Never log sensitive data: API keys, passwords, and user PII must not appear in logs at any level, including DEBUG.

Thread Safety: Document Clearly, Design for Concurrent Use

Every class in the public API must document its thread safety. “Thread-safe” is not the default, and “not thread-safe” is not a bug, but it must be stated explicitly.

AuditClient must be thread-safe: Services inject a single instance shared across many request threads.

public final class DefaultAuditClient implements AuditClient {
    // Java 11+ HttpClient is thread-safe
    private final HttpClient httpClient;
    // Jackson ObjectMapper is thread-safe after configuration
    private final ObjectMapper objectMapper;
    // Immutable after construction
    private final AuditConfig config;
    // AtomicLong: thread-safe counter
    private final AtomicLong eventsSent = new AtomicLong();

    // Constructor: fully set up on construction, effectively immutable, thread-safe from the start
    public DefaultAuditClient(AuditConfig config) {
        this.config = Objects.requireNonNull(config);
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(config.connectTimeout())
            .build();
        this.objectMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
            // After configuration, ObjectMapper is effectively immutable and thread-safe
    }

    @Override
    public void send(AuditEvent event) {
        // No shared mutable state modified here
        // All state is either local or thread-safe
        String body = serialize(event);
        executeWithRetry(body, config.maxRetries());
        eventsSent.incrementAndGet(); // AtomicLong: thread-safe
    }
}

Document clearly in Javadoc:

/**
 * Thread-safe implementation of {@link AuditClient}.
 *
 * <p>Instances are safe for use by multiple concurrent threads.
 * A single instance should be created and reused for the lifetime of the application.
 *
 * <p>Creating multiple instances per request is wasteful and may exhaust
 * connection pool resources.
 */
public final class DefaultAuditClient implements AuditClient { ... }

Spring Boot Auto-configuration: Writing a Starter Correctly

A Spring Boot Starter allows consumers to add one dependency and get working configuration with no manual setup. This is the most important pattern when writing a library for the Spring ecosystem.

audit-client-spring-boot-starter/
├── src/main/java/com/example/audit/autoconfigure/
│   ├── AuditClientAutoConfiguration.java
│   └── AuditClientProperties.java
└── src/main/resources/META-INF/spring/
    └── org.springframework.boot.autoconfigure.AutoConfiguration.imports

AutoConfiguration.imports (Spring Boot 2.7+):

com.example.audit.autoconfigure.AuditClientAutoConfiguration

AuditClientProperties: bound from application.yml

@ConfigurationProperties(prefix = "audit.client")
@Validated
public class AuditClientProperties {

    @NotBlank
    private String endpoint;

    @DurationUnit(ChronoUnit.MILLIS)
    private Duration connectTimeout = Duration.ofSeconds(5);

    @DurationUnit(ChronoUnit.MILLIS)
    private Duration requestTimeout = Duration.ofSeconds(10);

    @Min(0) @Max(10)
    private int maxRetries = 3;

    private boolean enabled = true;

    // getters and setters
}

AuditClientAutoConfiguration:

@AutoConfiguration
@ConditionalOnClass(AuditClient.class)          // only activates when the library is on the classpath
@ConditionalOnProperty(
    prefix = "audit.client",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true                        // enabled by default; opt out with enabled=false
)
@EnableConfigurationProperties(AuditClientProperties.class)
public class AuditClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(AuditClient.class) // consumer can override
    public AuditClient auditClient(AuditClientProperties props) {
        AuditConfig config = AuditConfig.builder(props.getEndpoint())
            .connectTimeout(props.getConnectTimeout())
            .requestTimeout(props.getRequestTimeout())
            .maxRetries(props.getMaxRetries())
            .build();
        return new DefaultAuditClient(config);
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(AuditClient.class)
    public AuditEventPublisher auditEventPublisher(AuditClient auditClient) {
        return new DefaultAuditEventPublisher(auditClient);
    }
}

@ConditionalOnMissingBean is the key: Consumers override the bean by declaring their own. Auto-configuration does not register if a bean already exists. This is dependency injection, not magic.

Configuration metadata for IDE autocomplete:

// src/main/resources/META-INF/spring-configuration-metadata.json
{
  "properties": [
    {
      "name": "audit.client.endpoint",
      "type": "java.lang.String",
      "description": "URL of the audit service. Example: https://audit.internal.example.com"
    },
    {
      "name": "audit.client.max-retries",
      "type": "java.lang.Integer",
      "description": "Maximum number of retry attempts on transient failure.",
      "defaultValue": 3
    }
  ]
}

Without this file, IntelliJ and VS Code do not autocomplete audit.client.* in application.yml.

Trade-off: @ConditionalOnProperty(matchIfMissing = true) activates auto-configuration even when the consumer has written no audit.client.* properties. Convenient but potentially surprising. For critical libraries (payment, security), matchIfMissing = false is safer: the consumer must opt in explicitly.

Quarkus Extension: CDI Native

A Quarkus extension integrates the library with the CDI container and is optimized at build time. Unlike Spring Boot auto-configuration (which is runtime), a Quarkus extension does most of its work at build time.

// audit-client-quarkus/src/main/java/com/example/audit/quarkus/
// AuditClientProducer.java
@ApplicationScoped
public class AuditClientProducer {

    @ConfigProperty(name = "audit.client.endpoint")
    String endpoint;

    @ConfigProperty(name = "audit.client.connect-timeout", defaultValue = "PT5S")
    Duration connectTimeout;

    @ConfigProperty(name = "audit.client.max-retries", defaultValue = "3")
    int maxRetries;

    @Produces
    @ApplicationScoped
    @DefaultBean // consumer overrides by injecting an @Alternative bean
    public AuditClient produceAuditClient() {
        AuditConfig config = AuditConfig.builder(endpoint)
            .connectTimeout(connectTimeout)
            .maxRetries(maxRetries)
            .build();
        return new DefaultAuditClient(config);
    }

    public void dispose(@Disposes AuditClient client) {
        if (client instanceof Closeable c) {
            try { c.close(); }
            catch (IOException e) { log.warn("Error closing AuditClient", e); }
        }
    }
}

application.properties in Quarkus:

audit.client.endpoint=https://audit.internal.example.com
audit.client.connect-timeout=PT5S
audit.client.max-retries=3

Test with QuarkusTest:

@QuarkusTest
class AuditClientIntegrationTest {

    @Inject
    AuditClient auditClient; // CDI injection

    @Test
    void shouldSendEvent() {
        AuditEvent event = AuditEvent.builder()
            .actorId("user-42")
            .action("CREATE")
            .resourceType("Order")
            .resourceId("ORD-001")
            .build();

        assertDoesNotThrow(() -> auditClient.send(event));
    }
}

GraalVM Native Image: Prepare for AOT

GraalVM compiles Java to a native binary at build time. It performs static analysis and removes unused code. The problem: reflection, dynamic class loading, and resource loading cannot be statically analyzed.

Libraries must provide hints for GraalVM:

// Use annotations to register classes that need reflection
@RegisterForReflection(targets = {
    AuditEvent.class,
    AuditException.ConnectionFailed.class,
    AuditException.EventRejected.class,
    AuditException.Unauthorized.class
})
public class AuditClientReflectionConfig {}

Or use reflect-config.json for a pure Java library:

// src/main/resources/META-INF/native-image/com.example/audit-client-core/reflect-config.json
[
  {
    "name": "com.example.audit.AuditEvent",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true
  }
]

Resource configuration:

// resource-config.json
{
  "resources": {
    "includes": [
      { "pattern": "audit-client.version" }
    ]
  }
}

Spring Boot 3 + GraalVM: Spring Boot 3 supports native images through RuntimeHintsRegistrar:

@ImportRuntimeHints(AuditClientRuntimeHints.class)
public class AuditClientAutoConfiguration { ... }

class AuditClientRuntimeHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection()
            .registerType(AuditEvent.class, MemberCategory.values())
            .registerType(AuditConfig.class, MemberCategory.values());
        hints.resources()
            .registerPattern("audit-client.version");
    }
}

Test native image compatibility:

@SpringBootTest
@EnabledIfSystemProperty(named = "spring.aot.processing", matches = "true")
class AuditClientNativeTest {
    @Test
    void contextLoads() { } // if the context loads in native mode, the hints are correct
}

Trade-off: Native image compilation takes longer than a JVM build (typically 2 to 5 minutes). Debugging a native image is harder. Not every JVM feature is supported (agent-based instrumentation, certain reflection patterns). Separate testing in the CI/CD pipeline is required.

Semantic Versioning and Backward Compatibility

Once a library is published and consumers depend on it, a breaking change is the most serious mistake you can make.

A breaking change is:

  • Removing or renaming a public method, class, or field.
  • Changing a method signature (adding a required parameter, changing the return type).
  • Adding a new checked exception.
  • Changing behavior that consumers depend on (even if “the old behavior was a bug”).
  • Renaming a config property or changing a default value in a non-backward-compatible way.

Not a breaking change:

  • Adding a new method to a class.
  • Adding a default method to an interface.
  • Adding an optional config property with a default value.
  • Fixing a bug without changing the signature (even if behavior changes).
  • Improving performance.

The correct deprecation strategy:

/**
 * @deprecated since 1.3.0, use {@link #sendAsync(AuditEvent)} instead.
 *             This method will be removed in 2.0.0.
 *             Migration: replace {@code send(event)} with
 *             {@code sendAsync(event).join()} for equivalent blocking behavior.
 */
@Deprecated(since = "1.3.0", forRemoval = true)
public void sendSync(AuditEvent event) {
    sendAsync(event).join();
}

Do not remove a @Deprecated method until the next major version. Document clearly which version it will be removed in and provide the migration path.

Module system (Java 9+):

// module-info.java in audit-client-core
module com.example.audit.core {
    // Export only the packages consumers are allowed to use
    exports com.example.audit;

    // Internal packages are not exported
    // com.example.audit.internal -> not visible to consumers

    requires org.slf4j;                    // compile-time and runtime
    requires static com.fasterxml.jackson; // optional, only when present on the classpath
}

The module system enforces encapsulation at the JVM level. The internal package is genuinely inaccessible, not just a convention.

Testing: Test Like a Consumer

Library tests must cover two categories:

  1. Unit tests of internal logic.
  2. Integration tests from the consumer’s perspective, using the public API exactly as a consumer would.

Test the public API, not the internals:

// CORRECT: test through the public interface as a consumer would
class AuditClientTest {

    private MockWebServer mockServer; // WireMock or MockWebServer
    private AuditClient client;

    @BeforeEach
    void setup() throws IOException {
        mockServer = new MockWebServer();
        mockServer.start();

        AuditConfig config = AuditConfig.builder(mockServer.url("/").toString())
            .maxRetries(1)
            .build();
        client = new DefaultAuditClient(config);
    }

    @Test
    void shouldRetryOnTransientFailure() throws InterruptedException {
        // Server returns 503 on the first attempt, 200 on the second
        mockServer.enqueue(new MockResponse().setResponseCode(503));
        mockServer.enqueue(new MockResponse().setResponseCode(200));

        AuditEvent event = AuditEvent.builder()
            .actorId("user-1")
            .action("DELETE")
            .resourceType("Product")
            .resourceId("prod-42")
            .build();

        // The consumer does not need to know about retry internals
        assertDoesNotThrow(() -> client.send(event));
        assertThat(mockServer.getRequestCount()).isEqualTo(2);
    }
}

Testability is an API design concern: If consumers find it hard to mock AuditClient in their tests, the design is wrong. Interface plus factory method is the correct approach:

// Consumer test: mocking AuditClient is straightforward
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    AuditClient auditClient; // mock the interface, not the concrete class

    @InjectMocks
    OrderService orderService;

    @Test
    void shouldAuditOrderCreation() {
        orderService.createOrder(orderRequest);
        verify(auditClient).send(argThat(event ->
            event.action().equals("CREATE") &&
            event.resourceType().equals("Order")
        ));
    }
}

Summary

DecisionReasonWhat breaks if wrong
Multi-module, core has no framework depReusable across frameworksFramework upgrade forces library upgrade
implementation vs api scopeAvoids leaking transitive depsClasspath conflicts in the consumer
Validate config earlyFail fast, clear error locationRuntime NPE far from where config was set up
SLF4J facadeConsumer chooses logging implTwo logging frameworks on the classpath
@ConditionalOnMissingBeanConsumer can override the beanAuto-config conflicts with custom bean
Document thread safetyConsumers know how to use it correctlyRace condition from concurrent use of a non-safe instance
Strict semantic versioningConsumers are not surprised by upgradesBreaking change in a minor version, angry consumers
GraalVM hintsNative image compatibilityReflection errors during native compilation

Mental model: When writing a library, you are not writing code for yourself. You are writing a contract for consumers. Every public class, method, and config property is a commitment. Minimize what you expose, validate aggressively at the boundary, and document behavior explicitly. A good library makes consumer code simpler, not more complex.