Home Engineering

Best Practice khi viết Common Library trong Java

03 June 2026 · 20 min read
Table of contents

Một internal library sai thiết kế lan rộng nhanh hơn và tồn tại lâu hơn bất kỳ thứ gì khác trong codebase. Khi 20 service phụ thuộc vào nó, thay đổi bất kỳ thứ gì đều đau đớn. Bài này dùng một ví dụ xuyên suốt - một audit-client library gửi audit event đến central audit service - để giải thích từng quyết định thiết kế từ góc độ: tại sao nó tồn tại, Điều gì xảy ra nếu không có nó, và trade-off cụ thể.

Bắt đầu từ đâu: Từ Zero đến Library Đầu Tiên

Trước khi đi vào các best practice phức tạp, đây là lộ trình thực tế để viết library đầu tiên — giả sử bạn đã quen với Java và Gradle, và bạn đang thấy một đoạn code lặp lại ở nhiều service.

Bước 1: Xác định vấn đề cụ thể

Đừng bắt đầu bằng “tôi muốn viết một library hữu ích”. Bắt đầu bằng một vấn đề cụ thể mà bạn đang gặp:

  • “Mọi service đều copy-paste đoạn code gửi audit event này”
  • “Mọi người viết HTTP client khác nhau, một số không có retry”
  • “Config parsing bị duplicate ở 5 chỗ”

Library tốt giải quyết một vấn đề cụ thể, không phải mọi vấn đề.

Bước 2: Tạo project

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

Hoặc tạo thủ công build.gradle.kts tối giản:

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"]) }
    }
}

Bước 3: Viết interface trước, implementation sau

Câu hỏi đầu tiên phải là: “Consumer sẽ gọi cái gì?” — không phải “tôi sẽ implement thế nào?”.

// 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) {}

Ngay lúc này, thử viết vài dòng giả lập consumer code để cảm nhận API:

// Nếu đây trông awkward khi viết, API cần redesign trước khi đi tiếp
AuditEvent event = new AuditEvent("user-1", "CREATE", "order-42");
client.send(event);

Nếu code này đọc tự nhiên, tiếp tục. Nếu không, điều chỉnh interface ngay bây giờ — trước khi có consumer nào phụ thuộc vào nó.

Bước 4: Implement đơn giản nhất có thể

Chưa cần retry, connection pool, hay async. Làm cho nó chạy đúng trước:

// 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);
        }
    }
}

Phiên bản này không hoàn hảo — không có timeout, không retry, exception handling thô — nhưng nó làm đúng một việc và dễ hiểu.

Bước 5: Test qua public API

Test phải được viết từ góc độ consumer, không phải từ trong nội tạng của implementation:

class AuditClientTest {

    private MockWebServer server;
    private AuditClient client; // dùng interface, không dùng 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");
    }
}

Thêm dependency com.squareup.okhttp3:mockwebserver:4.12.0 vào testImplementation.

Bước 6: Publish local và test trong consumer project thật

./gradlew publishToMavenLocal

File jar sẽ xuất hiện ở ~/.m2/repository/com/example/audit-client/0.1.0-SNAPSHOT/. Trong consumer project:

// build.gradle.kts của consumer
repositories {
    mavenLocal()  // tìm trong ~/.m2 trước
    mavenCentral()
}

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

Viết một đoạn code thực tế trong consumer và dùng thử. Nếu có gì cảm thấy không tự nhiên, đây là lúc để sửa API — trước khi có nhiều service phụ thuộc.

Bước 7: Iterate từ usage thực tế

Sau khi dùng thử, bạn sẽ phát hiện những thiếu sót cụ thể:

  • “Constructor nhận String endpoint không đủ, cần timeout config” → thêm AuditConfig builder
  • “Consumer cần thêm metadata vào event” → mở rộng AuditEvent
  • “Cần async version để không block request thread” → thêm sendAsync()

Đây là thời điểm áp dụng các pattern trong phần còn lại của bài. Đừng design tất cả từ đầu — design từ usage thực tế, refine từng bước.

Dấu hiệu bạn sẵn sàng cho phần tiếp theo: library có test pass, publish local được, và ít nhất một consumer project dùng thử thành công. Từ đây, các quyết định về multi-module, dependency hygiene, và auto-configuration mới trở nên có ý nghĩa.


Kiến trúc Multi-module: Tách Core khỏi Framework

Sai lầm phổ biến nhất khi viết library là nhúng Spring hoặc Quarkus vào core business logic. Khi đó library chỉ dùng được trong một framework, và mỗi major framework upgrade có thể kéo theo phải nâng version library.

Cấu trúc đúng là tách thành nhiều module:

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 chỉ được phép phụ thuộc vào Java SE và các utility library nhỏ như SLF4J (logging facade). Không có Spring, không có Quarkus, không có Jakarta EE. Code trong core chạy được trong bất kỳ JVM application nào.

Điều gì xảy ra nếu không tách: Library v1.0 dùng spring-web 5.x. Đến khi consumer nâng lên Spring Boot 3 (Spring 6), họ phải đợi library release v2.0. Trong thời gian đó, họ không thể nâng Spring. Nếu Spring 5 có security vulnerability, họ bị kẹt.

Trade-off: Multi-module tốn effort setup Gradle/Maven hơn single module. Với library nhỏ (vài trăm dòng), single module đủ. Multi-module chỉ justify khi library cần support nhiều framework hoặc khi có phần “optional” thực sự.

Dependency Hygiene: Đừng Làm Ô nhiễm Classpath của Consumer

Mỗi dependency bạn khai báo trong library là dependency mà consumer bắt buộc phải chịu - kể cả khi họ không muốn. Đây là nguồn gốc của “dependency hell”: version conflict giữa library A muốn jackson 2.14 và library B muốn jackson 2.16.

Gradle: khai báo dependency scope đúng:

// build.gradle.kts của audit-client-core
dependencies {
    // API: nằm trong public signature, consumer cần compile dependency này
    api("com.fasterxml.jackson.core:jackson-databind:2.17.0")

    // Implementation: dùng nội bộ, không lộ ra public API
    // Consumer không thấy, không conflict
    implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1")

    // CompileOnly: dùng lúc compile, không có trong runtime jar
    // Dùng cho annotation processors, annotation-only deps
    compileOnly("org.jetbrains:annotations:24.1.0")

    // Provided (Gradle: compileOnly + runtimeOnly của consumer)
    // Framework deps mà consumer đã có sẵn
    compileOnly("org.slf4j:slf4j-api:2.0.13") // consumer chọn implementation

    // Test dependencies không bao giờ leak ra production
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.mockito:mockito-core:5.11.0")
}

Maven: tương đương:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.13</version>
    <scope>provided</scope> <!-- không leak vào consumer -->
</dependency>

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

Bill of Materials (BOM) cho consumer dễ quản lý version:

<!-- 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>

Consumer chỉ cần import BOM, không cần chỉ định version từng module:

implementation(platform("com.example:audit-client-bom:1.2.0"))
implementation("com.example:audit-client-spring-boot-starter") // version tự động từ BOM

Production bug thực tế: Library khai báo jackson-databind ở scope compile (default). Consumer đang dùng jackson-databind 2.15 để tránh một CVE. Library kéo vào 2.13 có lỗ hổng. Kết quả: consumer bị downgrade security mà không biết.

Trade-off: Dùng api thay vì implementation khi type từ dependency đó xuất hiện trong public method signature của library. Dùng sai (dùng implementation cho type trong public API) sẽ làm consumer bị compile error. Dùng api quá nhiều sẽ leak dependency không cần thiết.

API Design: Minimal Surface, Maximum Stability

Public API là contract với consumer. Mỗi thứ bạn public là thứ bạn phải maintain mãi mãi (hoặc phải có migration path khi thay đổi).

Nguyên tắc: package-private everything that is not API:

// audit-client-core/src/main/java/com/example/audit/
// ┣ AuditClient.java         ← public, đây là API
// ┣ AuditEvent.java          ← public, consumer tạo event
// ┣ AuditConfig.java         ← public, consumer configure
// ┗ internal/
//   ┣ HttpAuditSender.java   ← package-private, implementation detail
//   ┣ RetryPolicy.java       ← package-private
//   ┗ EventSerializer.java   ← package-private
// Internal class: package-private, consumer không thể depend vào
class HttpAuditSender implements AuditSender {
    // implementation detail, có thể thay đổi tự do
}

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

Nếu HttpAuditSender là public, consumer có thể instantiate nó trực tiếp, bypass interface. Khi bạn refactor sang gRPC sender, code của consumer bị break.

Interface-first cho extensibility:

// Public interface - consumer có thể mock trong tests, hoặc implement custom sender
public interface AuditClient {
    void send(AuditEvent event);
    CompletableFuture<Void> sendAsync(AuditEvent event);

    // Default method: thêm vào interface mà không break backward compat
    default void sendBatch(List<AuditEvent> events) {
        events.forEach(this::send);
    }
}

Immutable value objects với Java Records (Java 16+):

// AuditEvent là immutable - consumer không thể accidentally modify sau khi send
public record AuditEvent(
    String actorId,
    String action,
    String resourceType,
    String resourceId,
    Instant occurredAt,
    Map<String, String> metadata
) {
    // Compact constructor: validate tại đây, một lần, không phải ở mọi nơi dùng
    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 của mutable map
        metadata = metadata != null
            ? Map.copyOf(metadata)
            : Map.of();
    }

    // Builder cho 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 class cho error types (Java 17+):

// Exhaustive error hierarchy - consumer biết chính xác những lỗi có thể xảy ra
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 {}
}

Consumer dùng pattern matching để xử lý từng case rõ ràng:

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 và sealed classes đòi hỏi Java 16+ và 17+. Nếu library phải support Java 11, cần dùng regular class. Luôn document Java version requirement rõ ràng trong README và pom.xml/build.gradle.

Configuration Pattern: Sensible Defaults, Validate Early

Điều gì xảy ra nếu không validate config sớm:

// BAD: config được truyền vào nhưng không validate
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 hoặc MalformedURLException xảy ra
        // khi send() được gọi lần đầu, không phải khi khởi tạo
        // Stack trace không chỉ ra lỗi ở đâu trong config
        httpClient.post(endpoint, event);
    }
}

Lỗi xảy ra runtime, cách xa code setup config. Debugging mất thời gian.

Correct: validate tại thời điểm build, 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 ngay tại đây
            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);
        }
    }
}

Consumer nhận exception ngay khi startup, không phải lúc production traffic chạy qua:

// Exception xảy ra tại đây khi application khởi động
AuditConfig config = AuditConfig.builder("not-a-valid-url")
    .connectTimeout(Duration.ofSeconds(5))
    .build(); // AuditConfigException: Invalid endpoint URI

Logging: SLF4J Facade, Không Bao Giờ Configure Appender

Library phải dùng SLF4J (hoặc System.Logger từ Java 9+) làm logging facade. Không bao giờ khai báo dependency vào Log4j, Logback, hoặc java.util.logging implementation.

Tại sao: Consumer chọn logging implementation. Nếu library hardcode Logback nhưng consumer dùng Log4j2, classpath có hai logging frameworks. Output đi đâu không đoán được, performance bị ảnh hưởng.

// CORRECT: SLF4J facade - không có implementation nào được chọn
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);
    }
}

Không bao giờ làm:

// WRONG: library cấu hình logging của consumer
static {
    // Library không được làm điều này
    LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
    ctx.getConfiguration().addAppender(new ConsoleAppender(...));
}

Quy tắc về log level:

  • ERROR: library không thể recover, cần consumer attention
  • WARN: degraded operation (retry exhausted, fallback used)
  • INFO: significant lifecycle events (client started, config loaded) - tắt trong production
  • DEBUG: per-request detail - OFF by default trong production
  • TRACE: detailed internal state - chỉ để debug library itself

Không bao giờ log sensitive data: API key, password, PII của user không được xuất hiện trong log dù ở DEBUG level.

Thread Safety: Document rõ ràng, Design for Concurrent Use

Mọi class trong public API phải được document thread safety của nó. “Thread-safe” không phải mặc định, và “not thread-safe” không phải lỗi - nhưng phải được ghi rõ.

AuditClient phải thread-safe: Service inject một instance dùng chung cho nhiều request thread.

public final class DefaultAuditClient implements AuditClient {
    // HttpClient của Java 11+ là thread-safe
    private final HttpClient httpClient;
    // ObjectMapper của Jackson là thread-safe sau khi configured
    private final ObjectMapper objectMapper;
    // Immutable sau khi constructed
    private final AuditConfig config;
    // AtomicLong: thread-safe counter
    private final AtomicLong eventsSent = new AtomicLong();

    // Constructor: thiết lập xong là immutable. Thread-safe ngay từ đầu.
    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);
            // Sau configure xong, ObjectMapper là effectively immutable và thread-safe
    }

    @Override
    public void send(AuditEvent event) {
        // No shared mutable state modified here
        // Mọi state đều local hoặc thread-safe
        String body = serialize(event);
        executeWithRetry(body, config.maxRetries());
        eventsSent.incrementAndGet(); // AtomicLong: thread-safe
    }
}

Document rõ ràng trong 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: Viết Starter đúng cách

Một Spring Boot Starter cho phép consumer chỉ cần thêm dependency, không cần config thủ công. Đây là pattern quan trọng nhất khi viết library cho 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: bind từ 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 và setters
}

AuditClientAutoConfiguration:

@AutoConfiguration
@ConditionalOnClass(AuditClient.class)          // chỉ activate khi library có trên classpath
@ConditionalOnProperty(
    prefix = "audit.client",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true                        // default enabled, opt-out với enabled=false
)
@EnableConfigurationProperties(AuditClientProperties.class)
public class AuditClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(AuditClient.class) // consumer có thể 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 là chìa khóa: Consumer override bean bằng cách khai báo bean của riêng họ. Auto-configuration không register nếu đã có bean. Đây là dependency injection, không phải magic.

Configuration metadata cho 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
    }
  ]
}

Không có file này, IntelliJ và VS Code không autocomplete audit.client.* trong application.yml.

Trade-off: @ConditionalOnProperty(matchIfMissing = true) kích hoạt auto-config ngay cả khi consumer không viết audit.client.* gì trong config. Thuận tiện nhưng có thể gây surprise. Với library quan trọng (payment, security), matchIfMissing = false là an toàn hơn - consumer phải opt-in explicit.

Quarkus Extension: CDI Native

Quarkus extension cho phép library tích hợp với CDI container và được tối ưu lúc build time. Khác Spring Boot auto-configuration (runtime), Quarkus extension xử lý phần lớn ở 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 override bằng cách inject @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 trong Quarkus:

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

Test với QuarkusTest:

@QuarkusTest
class AuditClientIntegrationTest {

    @Inject
    AuditClient auditClient; // CDI inject

    @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 compile Java sang native binary lúc build time. Nó phân tích static và loại bỏ code không dùng. Vấn đề: reflection, dynamic class loading, và resource loading không thể phân tích static.

Library phải cung cấp hints cho GraalVM:

// Dùng annotation để register class cần reflection
@RegisterForReflection(targets = {
    AuditEvent.class,
    AuditException.ConnectionFailed.class,
    AuditException.EventRejected.class,
    AuditException.Unauthorized.class
})
public class AuditClientReflectionConfig {}

Hoặc dùng reflect-config.json cho library thuần Java:

// 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 hỗ trợ native image thông qua 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() { } // nếu context load được trong native, hints đúng
}

Trade-off: Native image compilation tốn nhiều thời gian hơn JVM build (thường 2-5 phút). Debug native image khó hơn. Không phải mọi JVM feature đều support (agent-based instrumentation, certain reflection patterns). Cần testing riêng trong CI/CD pipeline.

Semantic Versioning và Backward Compatibility

Một khi library được publish và consumer depend vào nó, breaking change là tội lỗi nghiêm trọng nhất.

Breaking change là:

  • Remove hoặc rename public method/class/field
  • Change method signature (add required parameter, change return type)
  • Throw thêm checked exception mới
  • Change behavior mà consumer đang depend vào (ngay cả khi “behavior cũ là bug”)
  • Thay đổi config property name hoặc default value theo hướng không backward-compatible

Không phải breaking change:

  • Thêm method mới vào class
  • Thêm default method vào interface
  • Thêm optional config property với default value
  • Fix bug mà không change signature (dù behavior thay đổi)
  • Improve performance

Deprecation strategy đúng cách:

/**
 * @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();
}

Không xóa @Deprecated method cho đến major version. Document rõ version sẽ bị remove và migration path.

Module system (Java 9+):

// module-info.java trong audit-client-core
module com.example.audit.core {
    // Export package nào consumer được dùng
    exports com.example.audit;

    // Internal packages không export
    // com.example.audit.internal → không visible cho consumer

    requires org.slf4j;                    // compile-time và runtime
    requires static com.fasterxml.jackson; // optional, chỉ khi có trên classpath
}

Module system enforce encapsulation ở JVM level - internal package thực sự không accessible, không phải chỉ là convention.

Testing: Test Như Một Consumer

Library test phải cover hai loại:

  1. Unit test của internal logic
  2. Integration test từ góc nhìn consumer - dùng public API giống như consumer sẽ dùng

Test public API, không internal:

// CORRECT: test qua public interface như consumer sẽ làm
class AuditClientTest {

    private MockWebServer mockServer; // WireMock hoặc 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 trả về 503 lần đầu, 200 lần hai
        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();

        // Consumer không cần biết về retry internals
        assertDoesNotThrow(() -> client.send(event));
        assertThat(mockServer.getRequestCount()).isEqualTo(2);
    }
}

Testability là API design concern: Nếu consumer khó mock AuditClient trong test của họ, design của bạn sai. Interface + factory method là cách đúng:

// Consumer test: mock AuditClient dễ dàng
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    AuditClient auditClient; // mock interface, không phải concrete class

    @InjectMocks
    OrderService orderService;

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

Tổng kết

Quyết địnhLý doĐiều gì xảy ra nếu sai
Multi-module, core không có framework depReusable across frameworksFramework upgrade kéo theo library upgrade
implementation vs api scopeKhông leak transitive depClasspath conflict ở consumer
Validate config sớmFail fast, rõ ràngRuntime NPE cách xa code setup config
SLF4J facadeConsumer chọn logging implHai logging framework trên classpath
@ConditionalOnMissingBeanConsumer có thể overrideAuto-config conflict với custom bean
Document thread safetyConsumer biết cách dùng đúngRace condition do dùng non-safe instance concurrently
Semantic versioning nghiêmConsumer không bị surprise khi upgradeBreaking change trong minor version, consumer angry
GraalVM hintsNative image compatibilityReflection error khi compile native

Mental model: Khi viết library, bạn không viết code cho mình - bạn viết contract cho consumer. Mỗi public class, method, và config property là một commitment. Minimize what you expose, validate aggressively at the boundary, document behavior explicitly. Một library tốt làm cho consumer code trở nên đơn giản hơn, không phức tạp hơn.