ShipStation is a leading web-based shipping solution that helps e-commerce businesses manage their orders, shipments, and logistics. This comprehensive guide covers integrating ShipStation's REST API into Java applications.
Overview of ShipStation Features
Key Capabilities:
- Order Management - Import, update, and manage orders
- Shipping Labels - Generate shipping labels for multiple carriers
- Rate Shopping - Compare shipping rates across carriers
- Fulfillment - Manage order fulfillment workflows
- Warehouse Management - Multi-warehouse inventory and shipping
- Customs Documentation - International shipping compliance
- Webhooks - Real-time notifications for order events
Supported Carriers:
- USPS, UPS, FedEx, DHL
- Amazon Shipping, OnTrac, Canada Post
- Regional carriers worldwide
Setup and Dependencies
1. Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<okhttp.version>4.11.0</okhttp.version>
<jackson.version>2.15.2</jackson.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
2. ShipStation Configuration
@Configuration
@ConfigurationProperties(prefix = "shipstation")
@Data
public class ShipStationConfig {
private String apiKey;
private String apiSecret;
private String baseUrl = "https://ssapi.shipstation.com";
private String partnerKey;
private int timeoutSeconds = 30;
private int maxRetries = 3;
private long retryDelayMs = 1000;
@PostConstruct
public void validateConfig() {
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("ShipStation API key is required");
}
if (apiSecret == null || apiSecret.trim().isEmpty()) {
throw new IllegalStateException("ShipStation API secret is required");
}
}
public String getBasicAuthHeader() {
String credentials = apiKey + ":" + apiSecret;
return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
}
}
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
return mapper;
}
}
# application.yml
shipstation:
api-key: ${SHIPSTATION_API_KEY:your_api_key}
api-secret: ${SHIPSTATION_API_SECRET:your_api_secret}
base-url: https://ssapi.shipstation.com
timeout-seconds: 30
max-retries: 3
retry-delay-ms: 1000
Core Implementation
1. HTTP Client Service
@Component
@Slf4j
public class ShipStationHttpClient {
private final ShipStationConfig config;
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
public ShipStationHttpClient(ShipStationConfig config, ObjectMapper objectMapper) {
this.config = config;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.readTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.writeTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.addInterceptor(new RetryInterceptor(config.getMaxRetries(), config.getRetryDelayMs()))
.addInterceptor(new AuthInterceptor(config.getBasicAuthHeader()))
.addInterceptor(new LoggingInterceptor())
.build();
}
public <T> T get(String endpoint, Class<T> responseType) {
Request request = new Request.Builder()
.url(config.getBaseUrl() + endpoint)
.get()
.build();
return executeRequest(request, responseType);
}
public <T> T post(String endpoint, Object requestBody, Class<T> responseType) {
try {
String jsonBody = objectMapper.writeValueAsString(requestBody);
RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json"));
Request request = new Request.Builder()
.url(config.getBaseUrl() + endpoint)
.post(body)
.build();
return executeRequest(request, responseType);
} catch (JsonProcessingException e) {
throw new ShipStationException("Failed to serialize request body", e);
}
}
public <T> T put(String endpoint, Object requestBody, Class<T> responseType) {
try {
String jsonBody = objectMapper.writeValueAsString(requestBody);
RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json"));
Request request = new Request.Builder()
.url(config.getBaseUrl() + endpoint)
.put(body)
.build();
return executeRequest(request, responseType);
} catch (JsonProcessingException e) {
throw new ShipStationException("Failed to serialize request body", e);
}
}
public void delete(String endpoint) {
Request request = new Request.Builder()
.url(config.getBaseUrl() + endpoint)
.delete()
.build();
executeRequest(request, Void.class);
}
private <T> T executeRequest(Request request, Class<T> responseType) {
try (Response response = httpClient.newCall(request).execute()) {
String responseBody = response.body() != null ? response.body().string() : null;
if (!response.isSuccessful()) {
handleErrorResponse(response, responseBody);
}
if (responseType == Void.class || responseBody == null || responseBody.trim().isEmpty()) {
return null;
}
return objectMapper.readValue(responseBody, responseType);
} catch (IOException e) {
throw new ShipStationException("HTTP request failed: " + e.getMessage(), e);
}
}
private void handleErrorResponse(Response response, String responseBody) {
try {
if (responseBody != null) {
ShipStationError error = objectMapper.readValue(responseBody, ShipStationError.class);
throw new ShipStationApiException(error.getMessage(), response.code(), error);
} else {
throw new ShipStationException("HTTP error: " + response.code() + " - " + response.message());
}
} catch (JsonProcessingException e) {
throw new ShipStationException("Failed to parse error response: " + responseBody, e);
}
}
// Interceptor classes
private static class AuthInterceptor implements Interceptor {
private final String authHeader;
public AuthInterceptor(String authHeader) {
this.authHeader = authHeader;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request().newBuilder()
.addHeader("Authorization", authHeader)
.addHeader("Content-Type", "application/json")
.build();
return chain.proceed(request);
}
}
private static class RetryInterceptor implements Interceptor {
private final int maxRetries;
private final long retryDelayMs;
public RetryInterceptor(int maxRetries, long retryDelayMs) {
this.maxRetries = maxRetries;
this.retryDelayMs = retryDelayMs;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = null;
IOException exception = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
try {
Thread.sleep(retryDelayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ShipStationException("Request interrupted", e);
}
}
try {
response = chain.proceed(request);
if (response.isSuccessful() || !isRetryable(response.code())) {
return response;
}
response.close();
} catch (IOException e) {
exception = e;
}
}
if (response != null) {
throw new ShipStationException("Request failed after " + maxRetries + " retries. Last status: " + response.code());
} else {
throw new ShipStationException("Request failed after " + maxRetries + " retries", exception);
}
}
private boolean isRetryable(int statusCode) {
return statusCode == 429 || // Too Many Requests
statusCode >= 500; // Server errors
}
}
private static class LoggingInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
long startTime = System.currentTimeMillis();
log.debug("--> {} {}", request.method(), request.url());
Response response = chain.proceed(request);
long duration = System.currentTimeMillis() - startTime;
log.debug("<-- {} {} ({}ms)", response.code(), response.message(), duration);
return response;
}
}
}
2. Domain Models
// Common Response Wrapper
@Data
public class ShipStationResponse<T> {
private Integer page;
private Integer pages;
private Integer total;
private List<T> data;
}
// Error Response
@Data
public class ShipStationError {
private String message;
private String error;
private String code;
}
// Custom Exceptions
public class ShipStationException extends RuntimeException {
public ShipStationException(String message) {
super(message);
}
public ShipStationException(String message, Throwable cause) {
super(message, cause);
}
}
public class ShipStationApiException extends ShipStationException {
private final int statusCode;
private final ShipStationError error;
public ShipStationApiException(String message, int statusCode, ShipStationError error) {
super(message);
this.statusCode = statusCode;
this.error = error;
}
public int getStatusCode() { return statusCode; }
public ShipStationError getError() { return error; }
}
Order Management
1. Order Models
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Order {
private Long orderId;
private String orderNumber;
private String orderKey;
private LocalDateTime orderDate;
private LocalDateTime createDate;
private LocalDateTime modifyDate;
private String orderStatus;
private String customerUsername;
private String customerEmail;
private BigDecimal orderTotal;
private BigDecimal amountPaid;
private BigDecimal taxAmount;
private BigDecimal shippingAmount;
private String customerNotes;
private String internalNotes;
private Boolean gift;
private String giftMessage;
private String paymentMethod;
private LocalDateTime paymentDate;
private BigDecimal adjustment;
private String adjustmentReason;
private String requestedShippingService;
private String carrierCode;
private String serviceCode;
private String packageCode;
private String confirmation;
private LocalDateTime shipDate;
private Boolean holdUntilDate;
private Weight weight;
private Dimensions dimensions;
private InsuranceOptions insuranceOptions;
private InternationalOptions internationalOptions;
private AdvancedOptions advancedOptions;
private List<Tag> tagIds;
private String userId;
private Boolean externallyFulfilled;
private String externallyFulfilledBy;
private String externallyFulfilledById;
private String externallyFulfilledData;
private String labelMessages;
private List<OrderItem> items;
private List<Fee> fees;
// Addresses
private Address billTo;
private Address shipTo;
// Embedded objects
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Weight {
private BigDecimal value;
private String units; // pounds, ounces, grams
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Dimensions {
private String units; // inches, centimeters
private BigDecimal length;
private BigDecimal width;
private BigDecimal height;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class InsuranceOptions {
private String provider;
private Boolean insureShipment;
private BigDecimal insuredValue;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class InternationalOptions {
private String contents;
private String customsItems;
private String nonDelivery;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AdvancedOptions {
private Long warehouseId;
private Boolean nonMachinable;
private Boolean saturdayDelivery;
private Boolean containsAlcohol;
private Boolean mergedOrSplit;
private List<Long> mergedIds;
private Long parentId;
private String storeId;
private String customField1;
private String customField2;
private String customField3;
private String source;
private String billToParty;
private String billToAccount;
private String billToPostalCode;
private String billToCountryCode;
private String billToMyOtherAccount;
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderItem {
private Long orderItemId;
private String lineItemKey;
private String sku;
private String name;
private String imageUrl;
private Weight weight;
private Integer quantity;
private BigDecimal unitPrice;
private BigDecimal taxAmount;
private BigDecimal shippingAmount;
private String warehouseLocation;
private Integer productId;
private String fulfillmentSku;
private Boolean adjustment;
private String upc;
private String createDate;
private String modifyDate;
private Map<String, Object> options;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String name;
private String company;
private String street1;
private String street2;
private String street3;
private String city;
private String state;
private String postalCode;
private String country;
private String phone;
private Boolean residential;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Tag {
private Long tagId;
private String name;
private String color;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Fee {
private String name;
private BigDecimal amount;
}
// Order Request DTOs
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrderRequest {
@NotBlank
private String orderNumber;
@NotBlank
private String orderStatus;
@NotNull
private LocalDateTime orderDate;
@NotNull
private Address shipTo;
@NotEmpty
private List<OrderItem> items;
private Address billTo;
private BigDecimal orderTotal;
private BigDecimal taxAmount;
private BigDecimal shippingAmount;
private String customerEmail;
private String customerNotes;
private String internalNotes;
private AdvancedOptions advancedOptions;
private List<Tag> tagIds;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateOrderRequest {
private String orderStatus;
private Address shipTo;
private List<OrderItem> items;
private BigDecimal orderTotal;
private BigDecimal taxAmount;
private BigDecimal shippingAmount;
private String customerNotes;
private String internalNotes;
private AdvancedOptions advancedOptions;
private List<Tag> tagIds;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderQuery {
private String customerName;
private String itemKeyword;
private LocalDateTime createDateStart;
private LocalDateTime createDateEnd;
private LocalDateTime modifyDateStart;
private LocalDateTime modifyDateEnd;
private LocalDateTime orderDateStart;
private LocalDateTime orderDateEnd;
private String orderNumber;
private String orderStatus;
private String paymentDateStart;
private String paymentDateEnd;
private String storeId;
private String sortBy;
private String sortDir;
private Integer page;
private Integer pageSize;
}
2. Order Service
public interface OrderService {
Order createOrder(CreateOrderRequest request);
Order getOrder(Long orderId);
Order getOrderByNumber(String orderNumber);
Order updateOrder(Long orderId, UpdateOrderRequest request);
void deleteOrder(Long orderId);
void addTagToOrder(Long orderId, Long tagId);
void removeTagFromOrder(Long orderId, Long tagId);
ShipStationResponse<Order> listOrders(OrderQuery query);
void assignUserToOrder(Long orderId, String userId);
List<Order> getOrdersByStatus(String status);
Order markOrderAsShipped(Long orderId, MarkAsShippedRequest request);
}
@Service
@Slf4j
public class ShipStationOrderService implements OrderService {
private final ShipStationHttpClient httpClient;
private final ObjectMapper objectMapper;
public ShipStationOrderService(ShipStationHttpClient httpClient, ObjectMapper objectMapper) {
this.httpClient = httpClient;
this.objectMapper = objectMapper;
}
@Override
public Order createOrder(CreateOrderRequest request) {
log.info("Creating order: {}", request.getOrderNumber());
Order order = httpClient.post("/orders", request, Order.class);
log.info("Order created successfully: {} (ID: {})", order.getOrderNumber(), order.getOrderId());
return order;
}
@Override
public Order getOrder(Long orderId) {
log.debug("Fetching order: {}", orderId);
Order order = httpClient.get("/orders/" + orderId, Order.class);
log.debug("Order fetched successfully: {}", orderId);
return order;
}
@Override
public Order getOrderByNumber(String orderNumber) {
log.debug("Fetching order by number: {}", orderNumber);
OrderQuery query = OrderQuery.builder()
.orderNumber(orderNumber)
.pageSize(1)
.build();
ShipStationResponse<Order> response = listOrders(query);
if (response.getData() == null || response.getData().isEmpty()) {
throw new OrderNotFoundException("Order not found with number: " + orderNumber);
}
return response.getData().get(0);
}
@Override
public Order updateOrder(Long orderId, UpdateOrderRequest request) {
log.info("Updating order: {}", orderId);
Order order = httpClient.put("/orders/" + orderId, request, Order.class);
log.info("Order updated successfully: {}", orderId);
return order;
}
@Override
public void deleteOrder(Long orderId) {
log.info("Deleting order: {}", orderId);
httpClient.delete("/orders/" + orderId);
log.info("Order deleted successfully: {}", orderId);
}
@Override
public void addTagToOrder(Long orderId, Long tagId) {
log.debug("Adding tag {} to order: {}", tagId, orderId);
Map<String, Object> request = Map.of("tagId", tagId);
httpClient.post("/orders/addtag", request, Void.class);
log.debug("Tag added successfully to order: {}", orderId);
}
@Override
public void removeTagFromOrder(Long orderId, Long tagId) {
log.debug("Removing tag {} from order: {}", tagId, orderId);
Map<String, Object> request = Map.of("tagId", tagId);
httpClient.post("/orders/removetag", request, Void.class);
log.debug("Tag removed successfully from order: {}", orderId);
}
@Override
public ShipStationResponse<Order> listOrders(OrderQuery query) {
log.debug("Listing orders with query: {}", query);
String queryString = buildQueryString(query);
ShipStationResponse<Order> response = httpClient.get("/orders" + queryString,
objectMapper.getTypeFactory().constructParametricType(ShipStationResponse.class, Order.class));
log.debug("Found {} orders", response.getData() != null ? response.getData().size() : 0);
return response;
}
@Override
public void assignUserToOrder(Long orderId, String userId) {
log.info("Assigning user {} to order: {}", userId, orderId);
Map<String, Object> request = Map.of("userId", userId);
httpClient.post("/orders/assignUser", request, Void.class);
log.info("User assigned successfully to order: {}", orderId);
}
@Override
public List<Order> getOrdersByStatus(String status) {
log.debug("Fetching orders with status: {}", status);
OrderQuery query = OrderQuery.builder()
.orderStatus(status)
.pageSize(100) // Maximum page size
.build();
ShipStationResponse<Order> response = listOrders(query);
return response.getData() != null ? response.getData() : new ArrayList<>();
}
@Override
public Order markOrderAsShipped(Long orderId, MarkAsShippedRequest request) {
log.info("Marking order as shipped: {}", orderId);
Order order = httpClient.post("/orders/markasshipped", request, Order.class);
log.info("Order marked as shipped successfully: {}", orderId);
return order;
}
private String buildQueryString(OrderQuery query) {
if (query == null) {
return "";
}
Map<String, Object> params = new HashMap<>();
if (query.getCustomerName() != null) params.put("customerName", query.getCustomerName());
if (query.getItemKeyword() != null) params.put("itemKeyword", query.getItemKeyword());
if (query.getCreateDateStart() != null) params.put("createDateStart", formatDate(query.getCreateDateStart()));
if (query.getCreateDateEnd() != null) params.put("createDateEnd", formatDate(query.getCreateDateEnd()));
if (query.getModifyDateStart() != null) params.put("modifyDateStart", formatDate(query.getModifyDateStart()));
if (query.getModifyDateEnd() != null) params.put("modifyDateEnd", formatDate(query.getModifyDateEnd()));
if (query.getOrderDateStart() != null) params.put("orderDateStart", formatDate(query.getOrderDateStart()));
if (query.getOrderDateEnd() != null) params.put("orderDateEnd", formatDate(query.getOrderDateEnd()));
if (query.getOrderNumber() != null) params.put("orderNumber", query.getOrderNumber());
if (query.getOrderStatus() != null) params.put("orderStatus", query.getOrderStatus());
if (query.getStoreId() != null) params.put("storeId", query.getStoreId());
if (query.getSortBy() != null) params.put("sortBy", query.getSortBy());
if (query.getSortDir() != null) params.put("sortDir", query.getSortDir());
if (query.getPage() != null) params.put("page", query.getPage());
if (query.getPageSize() != null) params.put("pageSize", query.getPageSize());
if (params.isEmpty()) {
return "";
}
return "?" + params.entrySet().stream()
.map(entry -> entry.getKey() + "=" + encodeValue(entry.getValue()))
.collect(Collectors.joining("&"));
}
private String formatDate(LocalDateTime dateTime) {
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
private String encodeValue(Object value) {
try {
return URLEncoder.encode(value.toString(), StandardCharsets.UTF_8);
} catch (Exception e) {
return value.toString();
}
}
}
Shipping and Labels
1. Shipping Models
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Shipment {
private Long shipmentId;
private Long orderId;
private String orderKey;
private String userId;
private String customerEmail;
private String orderNumber;
private LocalDateTime createDate;
private LocalDateTime shipDate;
private BigDecimal shipmentCost;
private BigDecimal insuranceCost;
private String trackingNumber;
private Boolean isReturnLabel;
private String batchNumber;
private String carrierCode;
private String serviceCode;
private String packageCode;
private String confirmation;
private Long warehouseId;
private Boolean voided;
private LocalDateTime voidDate;
private Boolean marketplaceNotified;
private String notifyErrorMessage;
private String shipTo;
private Weight weight;
private Dimensions dimensions;
private InsuranceOptions insuranceOptions;
private AdvancedOptions advancedOptions;
private List<ShipmentItem> shipmentItems;
private LabelData labelData;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShipmentItem {
private Long orderItemId;
private String lineItemKey;
private String sku;
private String name;
private String imageUrl;
private Weight weight;
private Integer quantity;
private BigDecimal unitPrice;
private BigDecimal adjustment;
private String upc;
private String createDate;
private String modifyDate;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LabelData {
private String shipmentId;
private String shipmentCost;
private String insuranceCost;
private String trackingNumber;
private String labelDate;
private String shipDate;
private String voidDate;
private String vendorId;
private String status;
private String shipTo;
private String shipFrom;
private String weight;
private String dimensions;
private String insurance;
private String carrierCode;
private String serviceCode;
private String packageCode;
private String confirmation;
private String labelData; // Base64 encoded label
private String formData; // Base64 encoded form data
private String labelDataFormat; // PDF, ZPL, etc.
private String internationalDocument;
private List<LabelMessage> messages;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LabelMessage {
private String type;
private String message;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateLabelRequest {
@NotNull
private Long orderId;
private String carrierCode;
private String serviceCode;
private String packageCode;
private String confirmation;
private String shipDate;
private Weight weight;
private Dimensions dimensions;
private InsuranceOptions insuranceOptions;
private InternationalOptions internationalOptions;
private AdvancedOptions advancedOptions;
private Boolean testLabel;
private String labelFormat; // pdf, zpl, png
private String labelLayout; // 4x6, letter
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Rate {
private String serviceName;
private String serviceCode;
private BigDecimal shipmentCost;
private BigDecimal otherCost;
private String carrierCode;
private String carrierNickname;
private String carrierFriendlyName;
private String currency;
private LocalDateTime deliveryDate;
private String deliveryDays;
private Boolean deliveryDateGuaranteed;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RateRequest {
@NotNull
private String carrierCode;
@NotNull
private String fromPostalCode;
@NotNull
private String toState;
@NotNull
private String toCountry;
@NotNull
private String toPostalCode;
@NotNull
private String toCity;
@NotNull
private Weight weight;
private Dimensions dimensions;
private String serviceCode;
private String packageCode;
private String confirmation;
private Boolean residential;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarkAsShippedRequest {
@NotNull
private Long orderId;
private String carrierCode;
private String serviceCode;
private String trackingNumber;
private LocalDateTime shipDate;
private Boolean notifyCustomer;
private Boolean notifySalesChannel;
}
2. Shipping Service
@Service
@Slf4j
public class ShippingService {
private final ShipStationHttpClient httpClient;
private final ObjectMapper objectMapper;
public ShippingService(ShipStationHttpClient httpClient, ObjectMapper objectMapper) {
this.httpClient = httpClient;
this.objectMapper = objectMapper;
}
public Shipment createShippingLabel(CreateLabelRequest request) {
log.info("Creating shipping label for order: {}", request.getOrderId());
Shipment shipment = httpClient.post("/orders/createlabelfororder", request, Shipment.class);
log.info("Shipping label created successfully for order: {}", request.getOrderId());
return shipment;
}
public List<Rate> getShippingRates(RateRequest request) {
log.debug("Getting shipping rates for carrier: {}", request.getCarrierCode());
List<Rate> rates = httpClient.post("/shipments/getrates", request,
objectMapper.getTypeFactory().constructCollectionType(List.class, Rate.class));
log.debug("Found {} shipping rates for carrier: {}", rates.size(), request.getCarrierCode());
return rates;
}
public void voidLabel(Long shipmentId) {
log.info("Voiding shipping label: {}", shipmentId);
httpClient.post("/shipments/voidlabel", Map.of("shipmentId", shipmentId), Void.class);
log.info("Shipping label voided successfully: {}", shipmentId);
}
public ShipStationResponse<Shipment> listShipments(ShipmentQuery query) {
log.debug("Listing shipments with query: {}", query);
String queryString = buildQueryString(query);
ShipStationResponse<Shipment> response = httpClient.get("/shipments" + queryString,
objectMapper.getTypeFactory().constructParametricType(ShipStationResponse.class, Shipment.class));
log.debug("Found {} shipments", response.getData() != null ? response.getData().size() : 0);
return response;
}
public byte[] getLabelAsPdf(Long shipmentId) {
log.debug("Fetching PDF label for shipment: {}", shipmentId);
// First get the shipment with label data
Shipment shipment = httpClient.get("/shipments/" + shipmentId, Shipment.class);
if (shipment.getLabelData() == null || shipment.getLabelData().getLabelData() == null) {
throw new ShipStationException("No label data available for shipment: " + shipmentId);
}
// Decode base64 label data
String base64Data = shipment.getLabelData().getLabelData();
return Base64.getDecoder().decode(base64Data);
}
public Shipment createShipmentFromRate(Rate selectedRate, Long orderId) {
log.info("Creating shipment from rate for order: {}", orderId);
CreateLabelRequest request = CreateLabelRequest.builder()
.orderId(orderId)
.carrierCode(selectedRate.getCarrierCode())
.serviceCode(selectedRate.getServiceCode())
.shipDate(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE))
.build();
return createShippingLabel(request);
}
private String buildQueryString(ShipmentQuery query) {
if (query == null) {
return "";
}
Map<String, Object> params = new HashMap<>();
if (query.getShipmentDateStart() != null) params.put("shipmentDateStart", formatDate(query.getShipmentDateStart()));
if (query.getShipmentDateEnd() != null) params.put("shipmentDateEnd", formatDate(query.getShipmentDateEnd()));
if (query.getCreateDateStart() != null) params.put("createDateStart", formatDate(query.getCreateDateStart()));
if (query.getCreateDateEnd() != null) params.put("createDateEnd", formatDate(query.getCreateDateEnd()));
if (query.getStoreId() != null) params.put("storeId", query.getStoreId());
if (query.getOrderNumber() != null) params.put("orderNumber", query.getOrderNumber());
if (query.getOrderId() != null) params.put("orderId", query.getOrderId());
if (query.getCarrierCode() != null) params.put("carrierCode", query.getCarrierCode());
if (query.getServiceCode() != null) params.put("serviceCode", query.getServiceCode());
if (query.getTrackingNumber() != null) params.put("trackingNumber", query.getTrackingNumber());
if (query.getRecipientName() != null) params.put("recipientName", query.getRecipientName());
if (query.getPage() != null) params.put("page", query.getPage());
if (query.getPageSize() != null) params.put("pageSize", query.getPageSize());
if (params.isEmpty()) {
return "";
}
return "?" + params.entrySet().stream()
.map(entry -> entry.getKey() + "=" + encodeValue(entry.getValue()))
.collect(Collectors.joining("&"));
}
private String formatDate(LocalDateTime dateTime) {
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private String encodeValue(Object value) {
try {
return URLEncoder.encode(value.toString(), StandardCharsets.UTF_8);
} catch (Exception e) {
return value.toString();
}
}
}
Webhook Handling
1. Webhook Models
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Webhook {
private Long webhookId;
private String sellerId;
private String storeId;
private String hookType;
private String messageFormat;
private String url;
private String name;
private Boolean active;
private LocalDateTime createDate;
private LocalDateTime modifyDate;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WebhookEvent {
private String resource_url;
private String resource_type;
private Map<String, Object> data;
public Long getOrderId() {
if (data != null && data.containsKey("resource_id")) {
Object resourceId = data.get("resource_id");
if (resourceId instanceof Number) {
return ((Number) resourceId).longValue();
} else if (resourceId instanceof String) {
try {
return Long.parseLong((String) resourceId);
} catch (NumberFormatException e) {
return null;
}
}
}
return null;
}
public String getEventType() {
return resource_type;
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateWebhookRequest {
@NotBlank
private String hookType;
@NotBlank
private String url;
private String name;
private Boolean active = true;
private String messageFormat = "json";
private String storeId;
}
// Webhook Types
public enum WebhookType {
ORDER_NOTIFY("ORDER_NOTIFY"),
ITEM_ORDER_NOTIFY("ITEM_ORDER_NOTIFY"),
SHIP_NOTIFY("SHIP_NOTIFY"),
ITEM_SHIP_NOTIFY("ITEM_SHIP_NOTIFY");
private final String value;
WebhookType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
2. Webhook Service
@Service
@Slf4j
public class WebhookService {
private final ShipStationHttpClient httpClient;
private final OrderService orderService;
public WebhookService(ShipStationHttpClient httpClient, OrderService orderService) {
this.httpClient = httpClient;
this.orderService = orderService;
}
public Webhook createWebhook(CreateWebhookRequest request) {
log.info("Creating webhook: {} -> {}", request.getHookType(), request.getUrl());
Webhook webhook = httpClient.post("/webhooks", request, Webhook.class);
log.info("Webhook created successfully: {}", webhook.getWebhookId());
return webhook;
}
public List<Webhook> listWebhooks() {
log.debug("Listing all webhooks");
List<Webhook> webhooks = httpClient.get("/webhooks",
objectMapper.getTypeFactory().constructCollectionType(List.class, Webhook.class));
log.debug("Found {} webhooks", webhooks.size());
return webhooks;
}
public void deleteWebhook(Long webhookId) {
log.info("Deleting webhook: {}", webhookId);
httpClient.delete("/webhooks/" + webhookId);
log.info("Webhook deleted successfully: {}", webhookId);
}
public void processWebhookEvent(WebhookEvent event, String signature) {
log.info("Processing webhook event: {} - {}", event.getEventType(), event.getResource_url());
// Verify webhook signature (implementation depends on your security requirements)
if (!verifySignature(event, signature)) {
log.warn("Invalid webhook signature for event: {}", event.getEventType());
throw new SecurityException("Invalid webhook signature");
}
switch (event.getEventType()) {
case "ORDER_NOTIFY":
handleOrderNotify(event);
break;
case "SHIP_NOTIFY":
handleShipNotify(event);
break;
case "ITEM_ORDER_NOTIFY":
handleItemOrderNotify(event);
break;
case "ITEM_SHIP_NOTIFY":
handleItemShipNotify(event);
break;
default:
log.warn("Unhandled webhook event type: {}", event.getEventType());
}
}
private void handleOrderNotify(WebhookEvent event) {
Long orderId = event.getOrderId();
if (orderId == null) {
log.warn("No order ID found in ORDER_NOTIFY event");
return;
}
try {
Order order = orderService.getOrder(orderId);
log.info("Processing new/updated order: {} - {}", order.getOrderNumber(), order.getOrderStatus());
// Implement your business logic here
// e.g., update your database, send notifications, etc.
} catch (Exception e) {
log.error("Failed to process ORDER_NOTIFY event for order: {}", orderId, e);
}
}
private void handleShipNotify(WebhookEvent event) {
Long orderId = event.getOrderId();
if (orderId == null) {
log.warn("No order ID found in SHIP_NOTIFY event");
return;
}
try {
Order order = orderService.getOrder(orderId);
log.info("Processing shipment notification for order: {} - {}", order.getOrderNumber(), order.getOrderStatus());
// Implement your business logic here
// e.g., update order status, send tracking emails, etc.
} catch (Exception e) {
log.error("Failed to process SHIP_NOTIFY event for order: {}", orderId, e);
}
}
private void handleItemOrderNotify(WebhookEvent event) {
log.debug("Processing ITEM_ORDER_NOTIFY event");
// Implement item-level order notifications
}
private void handleItemShipNotify(WebhookEvent event) {
log.debug("Processing ITEM_SHIP_NOTIFY event");
// Implement item-level shipment notifications
}
private boolean verifySignature(WebhookEvent event, String signature) {
// Implementation depends on your security requirements
// ShipStation may provide signature verification
return true; // Placeholder
}
}
REST Controllers
1. Order Controller
@RestController
@RequestMapping("/api/shipstation/orders")
@Validated
@Slf4j
public class OrderController {
private final OrderService orderService;
private final ShippingService shippingService;
public OrderController(OrderService orderService, ShippingService shippingService) {
this.orderService = orderService;
this.shippingService = shippingService;
}
@PostMapping
public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
@GetMapping("/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable Long orderId) {
Order order = orderService.getOrder(orderId);
return ResponseEntity.ok(order);
}
@GetMapping("/number/{orderNumber}")
public ResponseEntity<Order> getOrderByNumber(@PathVariable String orderNumber) {
Order order = orderService.getOrderByNumber(orderNumber);
return ResponseEntity.ok(order);
}
@PutMapping("/{orderId}")
public ResponseEntity<Order> updateOrder(@PathVariable Long orderId,
@Valid @RequestBody UpdateOrderRequest request) {
Order order = orderService.updateOrder(orderId, request);
return ResponseEntity.ok(order);
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> deleteOrder(@PathVariable Long orderId) {
orderService.deleteOrder(orderId);
return ResponseEntity.noContent().build();
}
@GetMapping
public ResponseEntity<ShipStationResponse<Order>> listOrders(@Valid OrderQuery query) {
ShipStationResponse<Order> response = orderService.listOrders(query);
return ResponseEntity.ok(response);
}
@PostMapping("/{orderId}/tags/{tagId}")
public ResponseEntity<Void> addTagToOrder(@PathVariable Long orderId,
@PathVariable Long tagId) {
orderService.addTagToOrder(orderId, tagId);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{orderId}/tags/{tagId}")
public ResponseEntity<Void> removeTagFromOrder(@PathVariable Long orderId,
@PathVariable Long tagId) {
orderService.removeTagFromOrder(orderId, tagId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{orderId}/ship")
public ResponseEntity<Shipment> createShippingLabel(@PathVariable Long orderId,
@Valid @RequestBody CreateLabelRequest request) {
request.setOrderId(orderId);
Shipment shipment = shippingService.createShippingLabel(request);
return ResponseEntity.ok(shipment);
}
@PostMapping("/{orderId}/mark-shipped")
public ResponseEntity<Order> markOrderAsShipped(@PathVariable Long orderId,
@Valid @RequestBody MarkAsShippedRequest request) {
request.setOrderId(orderId);
Order order = orderService.markOrderAsShipped(orderId, request);
return ResponseEntity.ok(order);
}
}
2. Shipping Controller
@RestController
@RequestMapping("/api/shipstation/shipping")
@Validated
@Slf4j
public class ShippingController {
private final ShippingService shippingService;
public ShippingController(ShippingService shippingService) {
this.shippingService = shippingService;
}
@PostMapping("/rates")
public ResponseEntity<List<Rate>> getShippingRates(@Valid @RequestBody RateRequest request) {
List<Rate> rates = shippingService.getShippingRates(request);
return ResponseEntity.ok(rates);
}
@GetMapping("/shipments/{shipmentId}/label")
public ResponseEntity<byte[]> getShippingLabel(@PathVariable Long shipmentId) {
byte[] labelPdf = shippingService.getLabelAsPdf(shipmentId);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header("Content-Disposition", "attachment; filename=\"label-" + shipmentId + ".pdf\"")
.body(labelPdf);
}
@PostMapping("/shipments/{shipmentId}/void")
public ResponseEntity<Void> voidShippingLabel(@PathVariable Long shipmentId) {
shippingService.voidLabel(shipmentId);
return ResponseEntity.ok().build();
}
@GetMapping("/shipments")
public ResponseEntity<ShipStationResponse<Shipment>> listShipments(@Valid ShipmentQuery query) {
ShipStationResponse<Shipment> response = shippingService.listShipments(query);
return ResponseEntity.ok(response);
}
}
3. Webhook Controller
@RestController
@RequestMapping("/api/shipstation/webhooks")
@Slf4j
public class WebhookController {
private final WebhookService webhookService;
public WebhookController(WebhookService webhookService) {
this.webhookService = webhookService;
}
@PostMapping
public ResponseEntity<Webhook> createWebhook(@Valid @RequestBody CreateWebhookRequest request) {
Webhook webhook = webhookService.createWebhook(request);
return ResponseEntity.status(HttpStatus.CREATED).body(webhook);
}
@GetMapping
public ResponseEntity<List<Webhook>> listWebhooks() {
List<Webhook> webhooks = webhookService.listWebhooks();
return ResponseEntity.ok(webhooks);
}
@DeleteMapping("/{webhookId}")
public ResponseEntity<Void> deleteWebhook(@PathVariable Long webhookId) {
webhookService.deleteWebhook(webhookId);
return ResponseEntity.noContent().build();
}
@PostMapping("/events")
public ResponseEntity<String> handleWebhookEvent(@RequestBody WebhookEvent event,
@RequestHeader(value = "X-ShipStation-Signature", required = false) String signature) {
try {
webhookService.processWebhookEvent(event, signature);
return ResponseEntity.ok("Webhook processed successfully");
} catch (Exception e) {
log.error("Webhook processing failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Webhook processing failed: " + e.getMessage());
}
}
}
Advanced Features
1. Batch Order Processing
@Service
@Slf4j
public class BatchOrderService {
private final OrderService orderService;
private final ShippingService shippingService;
@Async
public CompletableFuture<BatchOrderResult> processOrdersInBatch(List<CreateOrderRequest> orders) {
List<Order> successfulOrders = new ArrayList<>();
List<FailedOrder> failedOrders = new ArrayList<>();
for (CreateOrderRequest orderRequest : orders) {
try {
Order order = orderService.createOrder(orderRequest);
successfulOrders.add(order);
log.info("Successfully created order: {}", order.getOrderNumber());
} catch (Exception e) {
failedOrders.add(FailedOrder.builder()
.orderRequest(orderRequest)
.errorMessage(e.getMessage())
.build());
log.error("Failed to create order: {}", orderRequest.getOrderNumber(), e);
}
}
return CompletableFuture.completedFuture(
BatchOrderResult.builder()
.totalProcessed(orders.size())
.successfulCount(successfulOrders.size())
.failedCount(failedOrders.size())
.successfulOrders(successfulOrders)
.failedOrders(failedOrders)
.completedAt(LocalDateTime.now())
.build()
);
}
@Async
public CompletableFuture<BatchShippingResult> createLabelsInBatch(List<Long> orderIds) {
List<Shipment> successfulShipments = new ArrayList<>();
List<FailedShipping> failedShipments = new ArrayList<>();
for (Long orderId : orderIds) {
try {
CreateLabelRequest labelRequest = CreateLabelRequest.builder()
.orderId(orderId)
.testLabel(true) // Use test mode for batch processing
.build();
Shipment shipment = shippingService.createShippingLabel(labelRequest);
successfulShipments.add(shipment);
log.info("Successfully created label for order: {}", orderId);
} catch (Exception e) {
failedShipments.add(FailedShipping.builder()
.orderId(orderId)
.errorMessage(e.getMessage())
.build());
log.error("Failed to create label for order: {}", orderId, e);
}
}
return CompletableFuture.completedFuture(
BatchShippingResult.builder()
.totalProcessed(orderIds.size())
.successfulCount(successfulShipments.size())
.failedCount(failedShipments.size())
.successfulShipments(successfulShipments)
.failedShipments(failedShipments)
.completedAt(LocalDateTime.now())
.build()
);
}
}
2. Rate Shopping Service
@Service
@Slf4j
public class RateShoppingService {
private final ShippingService shippingService;
public RateSelection findBestRate(RateRequest request, RateSelectionStrategy strategy) {
List<Rate> allRates = shippingService.getShippingRates(request);
if (allRates.isEmpty()) {
throw new ShipStationException("No shipping rates available for the given request");
}
Rate selectedRate = strategy.selectRate(allRates);
return RateSelection.builder()
.allRates(allRates)
.selectedRate(selectedRate)
.selectionReason(strategy.getSelectionReason())
.selectedAt(LocalDateTime.now())
.build();
}
public RateSelection findCheapestRate(RateRequest request) {
return findBestRate(request, new CheapestRateStrategy());
}
public RateSelection findFastestRate(RateRequest request) {
return findBestRate(request, new FastestRateStrategy());
}
public RateSelection findBestValueRate(RateRequest request) {
return findBestRate(request, new BestValueStrategy());
}
}
public interface RateSelectionStrategy {
Rate selectRate(List<Rate> rates);
String getSelectionReason();
}
@Component
@Slf4j
public class CheapestRateStrategy implements RateSelectionStrategy {
@Override
public Rate selectRate(List<Rate> rates) {
Rate cheapest = rates.stream()
.min(Comparator.comparing(Rate::getShipmentCost))
.orElseThrow(() -> new ShipStationException("No rates available"));
log.debug("Selected cheapest rate: {} - ${}",
cheapest.getServiceName(), cheapest.getShipmentCost());
return cheapest;
}
@Override
public String getSelectionReason() {
return "Lowest cost";
}
}
@Component
@Slf4j
public class FastestRateStrategy implements RateSelectionStrategy {
@Override
public Rate selectRate(List<Rate> rates) {
Rate fastest = rates.stream()
.filter(rate -> rate.getDeliveryDays() != null)
.min(Comparator.comparing(rate -> {
try {
return Integer.parseInt(rate.getDeliveryDays());
} catch (NumberFormatException e) {
return Integer.MAX_VALUE;
}
}))
.orElseThrow(() -> new ShipStationException("No rates with delivery days available"));
log.debug("Selected fastest rate: {} - {} days",
fastest.getServiceName(), fastest.getDeliveryDays());
return fastest;
}
@Override
public String getSelectionReason() {
return "Fastest delivery";
}
}
Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class)
class ShipStationOrderServiceTest {
@Mock
private ShipStationHttpClient httpClient;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private ShipStationOrderService orderService;
@Test
void whenCreateOrder_thenSuccess() {
// Given
CreateOrderRequest request = CreateOrderRequest.builder()
.orderNumber("TEST-001")
.orderStatus("awaiting_shipment")
.orderDate(LocalDateTime.now())
.shipTo(new Address())
.items(List.of(new OrderItem()))
.build();
Order expectedOrder = new Order();
expectedOrder.setOrderId(123L);
expectedOrder.setOrderNumber("TEST-001");
when(httpClient.post(eq("/orders"), any(), eq(Order.class)))
.thenReturn(expectedOrder);
// When
Order result = orderService.createOrder(request);
// Then
assertNotNull(result);
assertEquals(123L, result.getOrderId());
assertEquals("TEST-001", result.getOrderNumber());
verify(httpClient).post(eq("/orders"), any(), eq(Order.class));
}
@Test
void whenGetOrderNotFound_thenThrowException() {
// Given
when(httpClient.get(eq("/orders/999"), eq(Order.class)))
.thenThrow(new ShipStationApiException("Order not found", 404, new ShipStationError()));
// When & Then
assertThrows(ShipStationApiException.class, () -> {
orderService.getOrder(999L);
});
}
}
2. Integration Test
@SpringBootTest
@TestPropertySource(properties = {
"shipstation.api-key=test-key",
"shipstation.api-secret=test-secret"
})
class ShipStationIntegrationTest {
@Autowired
private OrderService orderService;
@Test
@Disabled("For actual integration testing with real credentials")
void testRealApiIntegration() {
// This test would require actual ShipStation credentials
OrderQuery query = OrderQuery.builder()
.pageSize(5)
.build();
ShipStationResponse<Order> response = orderService.listOrders(query);
assertNotNull(response);
// Additional assertions based on actual API response
}
}
Best Practices
- Error Handling: Comprehensive exception handling with retry logic
- Rate Limiting: Respect ShipStation API rate limits (40 requests per minute)
- Idempotency: Ensure operations can be safely retried
- Logging: Detailed logging for debugging and monitoring
- Validation: Strict input validation for all API requests
- Security: Secure handling of API credentials and webhook signatures
- Performance: Async processing for batch operations
- Monitoring: Track API usage and error rates
This ShipStation integration provides a complete solution for e-commerce businesses to manage their shipping operations programmatically, from order creation to label generation and tracking.
Java Observability, Logging Intelligence & AI-Driven Monitoring (APM, Tracing, Logs & Anomaly Detection)
https://macronepal.com/blog/beyond-metrics-observing-serverless-and-traditional-java-applications-with-thundra-apm/
Explains using Thundra APM to observe both serverless and traditional Java applications by combining tracing, metrics, and logs into a unified observability platform for faster debugging and performance insights.
https://macronepal.com/blog/dynatrace-oneagent-in-java-2/
Explains Dynatrace OneAgent for Java, which automatically instruments JVM applications to capture metrics, traces, and logs, enabling full-stack monitoring and root-cause analysis with minimal configuration.
https://macronepal.com/blog/lightstep-java-sdk-distributed-tracing-and-observability-implementation/
Explains Lightstep Java SDK for distributed tracing, helping developers track requests across microservices and identify latency issues using OpenTelemetry-based observability.
https://macronepal.com/blog/honeycomb-io-beeline-for-java-complete-guide-2/
Explains Honeycomb Beeline for Java, which provides high-cardinality observability and deep query capabilities to understand complex system behavior and debug distributed systems efficiently.
https://macronepal.com/blog/lumigo-for-serverless-in-java-complete-distributed-tracing-guide-2/
Explains Lumigo for Java serverless applications, offering automatic distributed tracing, log correlation, and error tracking to simplify debugging in cloud-native environments. (Lumigo Docs)
https://macronepal.com/blog/from-noise-to-signals-implementing-log-anomaly-detection-in-java-applications/
Explains how to detect anomalies in Java logs using behavioral patterns and machine learning techniques to separate meaningful incidents from noisy log data and improve incident response.
https://macronepal.com/blog/ai-powered-log-analysis-in-java-from-reactive-debugging-to-proactive-insights/
Explains AI-driven log analysis for Java applications, shifting from manual debugging to predictive insights that identify issues early and improve system reliability using intelligent log processing.
https://macronepal.com/blog/titliel-java-logging-best-practices/
Explains best practices for Java logging, focusing on structured logs, proper log levels, performance optimization, and ensuring logs are useful for debugging and observability systems.
https://macronepal.com/blog/seeking-a-loguru-for-java-the-quest-for-elegant-and-simple-logging/
Explains the search for simpler, more elegant logging frameworks in Java, comparing modern logging approaches that aim to reduce complexity while improving readability and developer experience.