diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java
new file mode 100644
index 000000000..ceddb3829
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProvider.java
@@ -0,0 +1,66 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.configuration;
+
+import com.sap.cds.services.messages.LocalizedMessageProvider;
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link LocalizedMessageProvider} that resolves error messages from a package-qualified resource
+ * bundle ({@code com.sap.cds.feature.attachments.i18n.errors}), avoiding classpath conflicts with
+ * the consuming application's {@code messages.properties}.
+ *
+ *
Resolution order:
+ *
+ *
+ * - Look up {@code messageOrKey} in the plugin's own resource bundle
+ *
- If not found, delegate to the previous provider in the chain
+ *
+ */
+public class AttachmentsLocalizedMessageProvider implements LocalizedMessageProvider {
+
+ private static final Logger logger =
+ LoggerFactory.getLogger(AttachmentsLocalizedMessageProvider.class);
+
+ static final String BUNDLE_NAME = "com.sap.cds.feature.attachments.i18n.errors";
+
+ private LocalizedMessageProvider previous;
+
+ @Override
+ public void setPrevious(LocalizedMessageProvider previous) {
+ this.previous = previous;
+ }
+
+ @Override
+ public String get(String messageOrKey, Object[] args, Locale locale) {
+ String resolved = resolveFromBundle(messageOrKey, args, locale);
+ if (resolved != null) {
+ return resolved;
+ }
+ if (previous != null) {
+ return previous.get(messageOrKey, args, locale);
+ }
+ return null;
+ }
+
+ private static String resolveFromBundle(String key, Object[] args, Locale locale) {
+ try {
+ Locale effectiveLocale = locale != null ? locale : Locale.getDefault();
+ ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE_NAME, effectiveLocale);
+ if (bundle.containsKey(key)) {
+ String pattern = bundle.getString(key);
+ return new MessageFormat(pattern, effectiveLocale)
+ .format(args != null ? args : new Object[0]);
+ }
+ } catch (MissingResourceException e) {
+ logger.debug("Resource bundle '{}' not found for key '{}'", BUNDLE_NAME, key, e);
+ }
+ return null;
+ }
+}
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java
new file mode 100644
index 000000000..810787442
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/MessageKeys.java
@@ -0,0 +1,40 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.configuration;
+
+/**
+ * Constants for message keys used in localized error messages. These keys correspond to entries in
+ * the package-qualified resource bundle {@code com.sap.cds.feature.attachments.i18n.errors}.
+ */
+public final class MessageKeys {
+
+ // Core module - file size validation
+ /** File size exceeds the configured @Validation.Maximum limit. Arg: {0} = max size string. */
+ public static final String FILE_SIZE_EXCEEDED =
+ "com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED";
+
+ /** File size exceeds the limit (fallback without size info). */
+ public static final String FILE_SIZE_EXCEEDED_NO_SIZE =
+ "com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED_NO_SIZE";
+
+ /** Invalid Content-Length header. */
+ public static final String INVALID_CONTENT_LENGTH =
+ "com.sap.cds.feature.attachments.INVALID_CONTENT_LENGTH";
+
+ // OSS module - operational errors
+ /** Failed to upload file. Arg: {0} = file name. */
+ public static final String UPLOAD_FAILED = "com.sap.cds.feature.attachments.UPLOAD_FAILED";
+
+ /** Failed to delete file. Arg: {0} = document id. */
+ public static final String DELETE_FAILED = "com.sap.cds.feature.attachments.DELETE_FAILED";
+
+ /** Failed to read file. Arg: {0} = document id. */
+ public static final String READ_FAILED = "com.sap.cds.feature.attachments.READ_FAILED";
+
+ /** Document not found. Arg: {0} = document id. */
+ public static final String DOCUMENT_NOT_FOUND =
+ "com.sap.cds.feature.attachments.DOCUMENT_NOT_FOUND";
+
+ private MessageKeys() {}
+}
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java
index e99586ea3..7bf49ac33 100644
--- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java
@@ -58,6 +58,11 @@ public class Registration implements CdsRuntimeConfiguration {
private static final Logger logger = LoggerFactory.getLogger(Registration.class);
+ @Override
+ public void providers(CdsRuntimeConfigurer configurer) {
+ configurer.provider(new AttachmentsLocalizedMessageProvider());
+ }
+
@Override
public void services(CdsRuntimeConfigurer configurer) {
configurer.service(new AttachmentsServiceImpl());
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java
index 8372bc689..20ad65ebe 100644
--- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java
@@ -6,6 +6,7 @@
import static java.util.Objects.requireNonNull;
import com.sap.cds.CdsData;
+import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
@@ -82,12 +83,10 @@ void restoreError(EventContext context) {
String maxSizeStr = (String) context.get("attachment.MaxSize");
if (maxSizeStr != null) {
throw new ServiceException(
- ExtendedErrorStatuses.CONTENT_TOO_LARGE,
- "File size exceeds the limit of {}.",
- maxSizeStr);
+ ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxSizeStr);
}
throw new ServiceException(
- ExtendedErrorStatuses.CONTENT_TOO_LARGE, "File size exceeds the limit.");
+ ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE);
}
throw e;
}
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java
index 2c315bbe9..487d045b0 100644
--- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java
@@ -6,6 +6,7 @@
import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Converter;
+import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEvent;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
@@ -94,9 +95,7 @@ public static InputStream handleAttachmentForEntity(
maxSizeStr); // make max size available in context for error handling later
ServiceException tooLargeException =
new ServiceException(
- ExtendedErrorStatuses.CONTENT_TOO_LARGE,
- "File size exceeds the limit of {}.",
- maxSizeStr);
+ ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxSizeStr);
if (contentLength != null) {
try {
@@ -104,7 +103,7 @@ public static InputStream handleAttachmentForEntity(
throw tooLargeException;
}
} catch (NumberFormatException e) {
- throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid Content-Length header");
+ throw new ServiceException(ErrorStatuses.BAD_REQUEST, MessageKeys.INVALID_CONTENT_LENGTH);
}
}
CountingInputStream wrappedContent =
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java
index f297e0ad7..c494101b3 100644
--- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java
@@ -3,6 +3,7 @@
*/
package com.sap.cds.feature.attachments.handler.applicationservice.readhelper;
+import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.FileSizeUtils;
import com.sap.cds.services.ServiceException;
@@ -81,9 +82,7 @@ private void checkLimit(long bytes) {
byteCount += bytes;
if (byteCount > maxBytes) {
throw new ServiceException(
- ExtendedErrorStatuses.CONTENT_TOO_LARGE,
- "File size exceeds the limit of {}.",
- maxBytesString);
+ ExtendedErrorStatuses.CONTENT_TOO_LARGE, MessageKeys.FILE_SIZE_EXCEEDED, maxBytesString);
}
}
}
diff --git a/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties b/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties
new file mode 100644
index 000000000..0435de183
--- /dev/null
+++ b/cds-feature-attachments/src/main/resources/com/sap/cds/feature/attachments/i18n/errors.properties
@@ -0,0 +1,15 @@
+# Error messages for the cds-feature-attachments plugin.
+# Placeholders use java.text.MessageFormat syntax: {0}, {1}, etc.
+# Consumers can override these messages by registering their own LocalizedMessageProvider
+# or by adding the same keys to their application's messages.properties.
+
+# Core module: file size validation
+com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED=File size exceeds the limit of {0}.
+com.sap.cds.feature.attachments.FILE_SIZE_EXCEEDED_NO_SIZE=File size exceeds the limit.
+com.sap.cds.feature.attachments.INVALID_CONTENT_LENGTH=Invalid Content-Length header.
+
+# OSS module: operational errors
+com.sap.cds.feature.attachments.UPLOAD_FAILED=Failed to upload file {0}.
+com.sap.cds.feature.attachments.DELETE_FAILED=Failed to delete file with document id {0}.
+com.sap.cds.feature.attachments.READ_FAILED=Failed to read file with document id {0}.
+com.sap.cds.feature.attachments.DOCUMENT_NOT_FOUND=Document not found for id {0}.
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java
new file mode 100644
index 000000000..2d6b578bc
--- /dev/null
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/AttachmentsLocalizedMessageProviderTest.java
@@ -0,0 +1,123 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.configuration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.sap.cds.services.messages.LocalizedMessageProvider;
+import java.util.Locale;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AttachmentsLocalizedMessageProviderTest {
+
+ private AttachmentsLocalizedMessageProvider cut;
+ private LocalizedMessageProvider previousProvider;
+
+ @BeforeEach
+ void setup() {
+ cut = new AttachmentsLocalizedMessageProvider();
+ previousProvider = mock(LocalizedMessageProvider.class);
+ }
+
+ @Test
+ void knownKeyReturnsFormattedMessage() {
+ var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"400MB"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("File size exceeds the limit of 400MB.");
+ }
+
+ @Test
+ void knownKeyWithoutArgsReturnsMessage() {
+ var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE, null, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("File size exceeds the limit.");
+ }
+
+ @Test
+ void knownKeyWithEmptyArgsReturnsMessage() {
+ var result = cut.get(MessageKeys.INVALID_CONTENT_LENGTH, new Object[] {}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("Invalid Content-Length header.");
+ }
+
+ @Test
+ void unknownKeyDelegatesToPreviousProvider() {
+ cut.setPrevious(previousProvider);
+ var args = new Object[] {"arg1"};
+ when(previousProvider.get("unknown.key", args, Locale.ENGLISH)).thenReturn("previous result");
+
+ var result = cut.get("unknown.key", args, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("previous result");
+ verify(previousProvider).get("unknown.key", args, Locale.ENGLISH);
+ }
+
+ @Test
+ void unknownKeyWithNoPreviousReturnsNull() {
+ var result = cut.get("unknown.key", new Object[] {}, Locale.ENGLISH);
+
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void knownKeyDoesNotDelegateToPrevious() {
+ cut.setPrevious(previousProvider);
+
+ var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"10KB"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("File size exceeds the limit of 10KB.");
+ verifyNoInteractions(previousProvider);
+ }
+
+ @Test
+ void nullLocaleUsesDefault() {
+ var result = cut.get(MessageKeys.FILE_SIZE_EXCEEDED, new Object[] {"100MB"}, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result).contains("100MB");
+ }
+
+ @Test
+ void setPreviousStoresProvider() {
+ cut.setPrevious(previousProvider);
+ when(previousProvider.get("any.key", null, Locale.ENGLISH)).thenReturn("from previous");
+
+ var result = cut.get("any.key", null, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("from previous");
+ }
+
+ @Test
+ void uploadFailedKeyReturnsFormattedMessage() {
+ var result = cut.get(MessageKeys.UPLOAD_FAILED, new Object[] {"test.pdf"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("Failed to upload file test.pdf.");
+ }
+
+ @Test
+ void deleteFailedKeyReturnsFormattedMessage() {
+ var result = cut.get(MessageKeys.DELETE_FAILED, new Object[] {"doc-123"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("Failed to delete file with document id doc-123.");
+ }
+
+ @Test
+ void readFailedKeyReturnsFormattedMessage() {
+ var result = cut.get(MessageKeys.READ_FAILED, new Object[] {"doc-456"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("Failed to read file with document id doc-456.");
+ }
+
+ @Test
+ void documentNotFoundKeyReturnsFormattedMessage() {
+ var result = cut.get(MessageKeys.DOCUMENT_NOT_FOUND, new Object[] {"doc-789"}, Locale.ENGLISH);
+
+ assertThat(result).isEqualTo("Document not found for id doc-789.");
+ }
+}
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java
index f79504fff..b3dcaef24 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java
@@ -16,6 +16,7 @@
import static org.mockito.Mockito.when;
import com.sap.cds.CdsData;
+import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_;
@@ -340,7 +341,7 @@ void restoreError_contentTooLargeWithMaxSize_throwsWithMaxSize() {
var exception = assertThrows(ServiceException.class, () -> cut.restoreError(context));
assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
- assertThat(exception.getMessage()).contains("File size exceeds the limit of 10MB.");
+ assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED);
assertThat(exception).isNotSameAs(originalException);
}
@@ -355,7 +356,7 @@ void restoreError_contentTooLargeWithoutMaxSize_throwsWithoutMaxSize() {
var exception = assertThrows(ServiceException.class, () -> cut.restoreError(context));
assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
- assertThat(exception.getMessage()).contains("File size exceeds the limit.");
+ assertThat(exception.getMessage()).contains(MessageKeys.FILE_SIZE_EXCEEDED_NO_SIZE);
assertThat(exception).isNotSameAs(originalException);
}
diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
index 9a44d9c8a..6526631ee 100644
--- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
+++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java
@@ -3,6 +3,7 @@
*/
package com.sap.cds.feature.attachments.oss.handler;
+import com.sap.cds.feature.attachments.configuration.MessageKeys;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode;
@@ -115,9 +116,9 @@ void createAttachment(AttachmentCreateEventContext context) {
context.setContentId(contentId);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
- throw new ServiceException("Failed to upload file {}", fileName, ex);
+ throw new ServiceException(MessageKeys.UPLOAD_FAILED, fileName, ex);
} catch (ObjectStoreServiceException | ExecutionException ex) {
- throw new ServiceException("Failed to upload file {}", fileName, ex);
+ throw new ServiceException(MessageKeys.UPLOAD_FAILED, fileName, ex);
} finally {
context.setCompleted();
}
@@ -133,11 +134,9 @@ void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) {
osClient.deleteContent(context.getContentId()).get();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
- throw new ServiceException(
- "Failed to delete file with document id {}", context.getContentId(), ex);
+ throw new ServiceException(MessageKeys.DELETE_FAILED, context.getContentId(), ex);
} catch (ObjectStoreServiceException | ExecutionException ex) {
- throw new ServiceException(
- "Failed to delete file with document id {}", context.getContentId(), ex);
+ throw new ServiceException(MessageKeys.DELETE_FAILED, context.getContentId(), ex);
} finally {
context.setCompleted();
}
@@ -165,16 +164,13 @@ void readAttachment(AttachmentReadEventContext context) {
context.getData().setContent(inputStream);
} else {
logger.error("Document not found for id {}", context.getContentId());
- throw new ServiceException(
- "Document not found for id " + context.getContentId(), context.getContentId());
+ throw new ServiceException(MessageKeys.DOCUMENT_NOT_FOUND, context.getContentId());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
- throw new ServiceException(
- "Failed to read file with document id {}", context.getContentId(), ex);
+ throw new ServiceException(MessageKeys.READ_FAILED, context.getContentId(), ex);
} catch (ObjectStoreServiceException | ExecutionException ex) {
- throw new ServiceException(
- "Failed to read file with document id {}", context.getContentId(), ex);
+ throw new ServiceException(MessageKeys.READ_FAILED, context.getContentId(), ex);
} finally {
context.setCompleted();
}