Micronaut Object Storage

Micronaut Object Storage provides a uniform API to create, read and delete objects in the major cloud providers

Version: 3.1.0

1 Introduction

Micronaut Object Storage provides a uniform API to create, read and delete objects in the major cloud providers:

There is also a local storage implementation for testing purposes.

Using this API enables the creation of truly multi-cloud, portable applications.

Micronaut Object Storage also provides a reactive companion API that mirrors the blocking contract with Reactive Streams Publisher return types.

2 Release History

For this project, you can find a list of releases (with release notes) here:

3 Quick Start

To get started, you need to declare a dependency for the actual cloud provider you are using. See the actual cloud provider documentation for more details:

Then, you can inject in your controllers/services/etc. a bean of type ObjectStorageOperations, the parent interface that allows you to use the API in a generic way for all cloud providers:

@Singleton
public class ProfileService {

    private static final Logger LOG = LoggerFactory.getLogger(ProfileService.class);

    private final ObjectStorageOperations<?, ?, ?> objectStorage;

    public ProfileService(ObjectStorageOperations<?, ?, ?> objectStorage) {
        this.objectStorage = objectStorage;
    }

}
@Singleton
open class ProfileService(private val objectStorage: ObjectStorageOperations<*, *, *>) {

}
@Singleton
class ProfileService {

    final ObjectStorageOperations<?, ?, ?> objectStorage

    ProfileService(ObjectStorageOperations<?, ?, ?> objectStorage) {
        this.objectStorage = objectStorage
    }

}

If your application is not multi-cloud, and/or you need cloud-specific details, you can use a concrete implementation. For example, for AWS S3:

@Controller
public class UploadController {

    private final AwsS3Operations objectStorage;

    public UploadController(AwsS3Operations objectStorage) {
        this.objectStorage = objectStorage;
    }

}
@Controller
open class UploadController(private val objectStorage: AwsS3Operations) {

}
@Controller
class UploadController {

    final AwsS3Operations objectStorage

    UploadController(AwsS3Operations objectStorage) {
        this.objectStorage = objectStorage
    }

}

If you have multiple object storages configured, it is possible to select which one to work with via bean qualifiers.

For example, given the following configuration:

src/main/resources/application-ec2.yml
micronaut.object-storage.aws.pictures.bucket=pictures-bucket
micronaut.object-storage.aws.logos.bucket=logos-bucket
micronaut:
  object-storage:
    aws:
      pictures:
        bucket: pictures-bucket
      logos:
        bucket: logos-bucket
micronaut = {object-storage = {aws = {pictures = {bucket = "pictures-bucket"}, logos = {bucket = "logos-bucket"}}}}
micronaut {
  objectStorage {
    aws {
      pictures {
        bucket = "pictures-bucket"
      }
      logos {
        bucket = "logos-bucket"
      }
    }
  }
}
{
  micronaut {
    object-storage {
      aws {
        pictures {
          bucket = "pictures-bucket"
        }
        logos {
          bucket = "logos-bucket"
        }
      }
    }
  }
}
{
  "micronaut": {
    "object-storage": {
      "aws": {
        "pictures": {
          "bucket": "pictures-bucket"
        },
        "logos": {
          "bucket": "logos-bucket"
        }
      }
    }
  }
}

You then need to use @Named("pictures") or @Named("logos") to specify which of the object storages you want to use.

Resolving resources by URI

Configured storages also publish Micronaut’s ResourceLoader beans, so you can resolve objects through ResourceResolver instead of injecting an ObjectStorageOperations bean directly.

For the configuration above, the following URI resolves through the pictures storage:

pictures://avatars/logo.png

This resolution stays additive to the existing API:

  • Existing @Named injection continues to work unchanged.

  • Provider-native aliases resolve only when the target bucket or container is already backed by a configured storage.

  • Storage names must not reuse reserved Micronaut prefixes such as classpath, file, string, or base64, and they must not collide with the provider aliases s3, gs, azb, or os.

Uploading files

public String saveProfilePicture(String userId, Path path) {
    UploadRequest request = UploadRequest.fromPath(path, userId); // (1)
    UploadResponse<?> response = objectStorage.upload(request); // (2)
    return response.getKey(); // (3)
}
open fun saveProfilePicture(userId: String, path: Path): String? {
    val request = UploadRequest.fromPath(path, userId) // (1)
    val response = objectStorage.upload(request) // (2)
    return response.key // (3)
}
String saveProfilePicture(String userId, Path path) {
    UploadRequest request = UploadRequest.fromPath(path, userId) // (1)
    UploadResponse response = objectStorage.upload(request) // (2)
    response.key // (3)
}
1 You can use any of the UploadRequest static methods to build an upload request.
2 The upload operation returns an UploadResponse, which wraps the cloud-specific SDK response object.
3 The response object contains some common properties for all cloud vendor, and a getNativeResponse() method that can be used for accessing the vendor-specific response object.

In case you want to have better control of the upload options used, you can use the method upload(UploadRequest, Consumer) of ObjectStorageOperations, which will give you access to the cloud vendor-specific request class or builder.

For example, for AWS S3:

UploadResponse<PutObjectResponse> response = objectStorage.upload(objectStorageUpload, builder -> {
    builder.acl(ObjectCannedACL.PUBLIC_READ);
});
val response = objectStorage.upload(objectStorageUpload) { builder: PutObjectRequest.Builder ->
    builder.acl(ObjectCannedACL.PUBLIC_READ)
}
UploadResponse<PutObjectResponse> response = objectStorage.upload(objectStorageUpload, { builder ->
    builder.acl(ObjectCannedACL.PUBLIC_READ)
})

If you receive a StreamingFileUpload in a multipart controller, you can forward it to object storage without first converting it to a local file or byte array:

@Post(uri = "/stream", consumes = MediaType.MULTIPART_FORM_DATA, produces = MediaType.TEXT_PLAIN)
public HttpResponse<String> streamingUpload(StreamingFileUpload fileUpload) {
    UploadRequest objectStorageUpload = UploadRequest.fromStreamingFileUpload(fileUpload, "uploads/" + fileUpload.getFilename());
    UploadResponse<PutObjectResponse> response = objectStorage.upload(objectStorageUpload);
    return HttpResponse
        .created(response.getKey())
        .header("ETag", response.getNativeResponse().eTag());
}
@Post(uri = "/stream", consumes = [MediaType.MULTIPART_FORM_DATA], produces = [MediaType.TEXT_PLAIN])
open fun streamingUpload(fileUpload: StreamingFileUpload): HttpResponse<String>? {
    val objectStorageUpload = UploadRequest.fromStreamingFileUpload(fileUpload, "uploads/${fileUpload.filename}")
    val response: io.micronaut.objectstorage.response.UploadResponse<PutObjectResponse> = objectStorage.upload(objectStorageUpload)
    return HttpResponse
        .created(response.key)
        .header("ETag", response.nativeResponse.eTag())
}
@Post(uri = "/stream", consumes = MediaType.MULTIPART_FORM_DATA, produces = MediaType.TEXT_PLAIN)
HttpResponse<String> streamingUpload(StreamingFileUpload fileUpload) {
    UploadRequest objectStorageUpload = UploadRequest.fromStreamingFileUpload(fileUpload, "uploads/${fileUpload.filename}")
    UploadResponse<PutObjectResponse> response = objectStorage.upload(objectStorageUpload)
    return HttpResponse
            .created(response.key)
            .header("ETag", response.getNativeResponse().eTag())
}

If the content is already available as an InputStream, for example after validating or transforming an incoming request stream, you can upload it directly without first collecting it into a byte array:

public String saveProfilePicture(String userId, InputStream inputStream, long contentLength) {
    UploadRequest request = UploadRequest.fromInputStream(
        inputStream,
        userId + "/profile.jpg",
        "image/jpeg",
        contentLength // (1)
    );
    request.setMetadata(Map.of("source", "validated-upload")); // (2)
    return objectStorage.upload(request).getKey();
}
open fun saveProfilePicture(userId: String, inputStream: InputStream, contentLength: Long): String? {
    val request = UploadRequest.fromInputStream(
        inputStream,
        "$userId/profile.jpg",
        "image/jpeg",
        contentLength // (1)
    )
    request.metadata = mapOf("source" to "validated-upload") // (2)
    return objectStorage.upload(request).key
}
String saveProfilePicture(String userId, InputStream inputStream, long contentLength) {
    UploadRequest request = UploadRequest.fromInputStream(
        inputStream,
        "${userId}/profile.jpg",
        "image/jpeg",
        contentLength // (1)
    )
    request.metadata = [source: "validated-upload"] // (2)
    objectStorage.upload(request).key
}
1 Supply the content length whenever it is known. This allows providers to stream the content directly; some provider operations require it. In particular, synchronous AWS S3 uploads buffer unknown-length streams, and AWS S3 and OCI multipart part uploads require a declared content length.
2 Content type and metadata work the same way as for other UploadRequest implementations.

An InputStreamUploadRequest returns the same stream instance without copying or buffering it. Do not access the stream while a blocking upload is running or until a reactive upload terminates. Provider implementations may close the stream; code that owns it should ensure it is closed after the upload completes.

Because the same stream instance is used for the whole upload, automatic retries may require a stream that supports mark and reset. In particular, a synchronous AWS S3 upload with a non-repeatable stream can fail if the SDK retries after consuming part of the stream.

Retrieving files

public Optional<Path> retrieveProfilePicture(String userId, String fileName) {
    Path destination = null;
    try {
        String key = userId + "/" + fileName;
        Optional<InputStream> stream = objectStorage.retrieve(key) // (1)
            .map(ObjectStorageEntry::getInputStream);
        if (stream.isPresent()) {
            destination = File.createTempFile(userId, "temp").toPath();
            Files.copy(stream.get(), destination, StandardCopyOption.REPLACE_EXISTING);
            return Optional.of(destination);
        } else {
            return Optional.empty();
        }
    } catch (IOException e) {
        LOG.error("Error while trying to save profile picture to the local file [{}]: {}", destination, e.getMessage());
        return Optional.empty();
    }
}
open fun retrieveProfilePicture(userId: String, fileName: String): Path? {
    val key = "$userId/$fileName"
    val stream = objectStorage.retrieve<ObjectStorageEntry<*>>(key) // (1)
        .map { obj: ObjectStorageEntry<*> -> obj.inputStream }

    return if (stream.isPresent) {
        val destination = File.createTempFile(userId, "temp").toPath()
        Files.copy(stream.get(), destination, StandardCopyOption.REPLACE_EXISTING)
        destination
    } else {
        null
    }
}
Optional<Path> retrieveProfilePicture(String userId, String fileName) {
    String key = "${userId}/${fileName}"
    Optional<InputStream> stream = objectStorage.retrieve(key) // (1)
            .map(ObjectStorageEntry::getInputStream)
    if (stream.isPresent()) {
        Path destination = File.createTempFile(userId, "temp").toPath()
        Files.copy(stream.get(), destination, StandardCopyOption.REPLACE_EXISTING)
        return Optional.of(destination)
    } else {
        return Optional.empty()
    }
}
1 The retrieve operation returns an ObjectStorageEntry, from which you can get an InputStream. There is also a getNativeEntry() method that gives you access to the cloud vendor-specific response object.

If you prefer URI-based resolution, you can retrieve the same object through ResourceResolver:

ResourceResolver resourceResolver = new ResourceResolver(resourceLoaders);
Optional<InputStream> logo = resourceResolver.getResourceAsStream("pictures://avatars/logo.png");

Deleting files

public void deleteProfilePicture(String userId, String fileName) {
    String key = userId + "/" + fileName;
    objectStorage.delete(key); // (1)
}
open fun deleteProfilePicture(userId: String, fileName: String) {
    val key = "$userId/$fileName"
    objectStorage.delete(key) // (1)
}
void deleteProfilePicture(String userId, String fileName) {
    String key = "${userId}/${fileName}"
    objectStorage.delete(key) // (1)
}
1 The delete operation returns the cloud vendor-specific delete response object in case you need it.

See the dedicated Pre-Signed Uploads section for portable, time-limited client upload requests.

See the dedicated Paginated Listing section for provider-agnostic page-by-page object listing.

4 Reactive Operations

Micronaut Object Storage provides ReactiveObjectStorageOperations as an additive, Reactive Streams-based companion to ObjectStorageOperations.

The reactive API mirrors the blocking contract:

  • uploads emit UploadResponse

  • retrieve emits Optional<ObjectStorageEntry<?>>

  • delete, exists, list, and paginated listing each emit a single result

  • copy returns a completion-only Publisher<Void>

Example injection:

package example;

import io.micronaut.objectstorage.ReactiveObjectStorageOperations;
import jakarta.inject.Singleton;

@Singleton
class ReactiveProfileService {

    private final ReactiveObjectStorageOperations<?, ?, ?> objectStorage;

    ReactiveProfileService(ReactiveObjectStorageOperations<?, ?, ?> objectStorage) {
        this.objectStorage = objectStorage;
    }
}

Phase 1 keeps the existing request and entry abstractions unchanged:

That means the operation lifecycle is reactive, while payload handling remains stream-based. Fully reactive payload streaming would require new additive request and entry abstractions in a follow-up change.

Reactive beans are created alongside the existing blocking beans, so current injections of ObjectStorageOperations continue to work unchanged. To opt in, inject ReactiveObjectStorageOperations instead.

Provider behavior in phase 1 is intentionally split:

  • AWS S3 uses the AWS SDK v2 S3AsyncClient

  • Azure Blob Storage uses Azure async blob clients

  • Oracle Cloud Infrastructure uses the OCI ObjectStorageAsyncClient

  • Google Cloud Storage and local storage keep the same additive reactive API, but currently adapt their existing blocking implementations onto Micronaut’s blocking executor

5 Pre-Signed Uploads

Pre-Signed Uploads

Applications can ask supporting providers to create a time-limited HTTP request for uploading a single object without proxying the file bytes through the application.

Use CreatePresignedUploadRequest to describe the object key and signing requirements, then call createPresignedUpload(…​) on ObjectStorageOperations.

The returned PresignedUpload includes:

  • the target URI

  • the HTTP method to use

  • the headers the caller must forward exactly

  • the expiration instant

Portable contract:

  • The signed request uploads exactly one object key.

  • Clients must send an HTTP PUT.

  • Clients must preserve all required headers from the response.

  • Provider support is optional. Providers that do not support pre-signed uploads, or cannot sign in the current configuration, return Optional.empty().

  • Oracle Cloud uses OCI pre-authenticated requests for this API and returns the upload URL as the portable signed request URI.

  • Local storage does not support this API because it has no HTTP endpoint to sign.

The following controller example returns a JSON payload your frontend can use to upload directly to object storage:

@Post(uri = "/signed", consumes = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON)
public HttpResponse<PresignedUploadResponse> create(@Body CreateUploadCommand command) {
    CreatePresignedUploadRequest request =
        new CreatePresignedUploadRequest("profiles/" + command.userId() + ".png", Duration.ofMinutes(5)); // (1)
    request.setContentType(MediaType.IMAGE_PNG);
    request.setMetadata(Map.of("owner", command.userId()));

    PresignedUpload signedUpload = objectStorage.createPresignedUpload(request)
        .orElseThrow(() -> new IllegalStateException("This provider does not support pre-signed uploads"));

    return HttpResponse.ok(new PresignedUploadResponse( // (2)
        signedUpload.getUri().toString(),
        signedUpload.getMethod(),
        signedUpload.getHeaders(),
        signedUpload.getExpiration()
    ));
}
@Post(uri = "/signed", consumes = [MediaType.APPLICATION_JSON], produces = [MediaType.APPLICATION_JSON])
open fun create(@Body command: CreateUploadCommand): HttpResponse<PresignedUploadResponse> {
    val request = CreatePresignedUploadRequest("profiles/${command.userId}.png", Duration.ofMinutes(5)) // (1)
    request.setContentType(MediaType.IMAGE_PNG)
    request.setMetadata(mapOf("owner" to command.userId))

    val signedUpload = objectStorage.createPresignedUpload(request)
        .orElseThrow { IllegalStateException("This provider does not support pre-signed uploads") }

    return HttpResponse.ok(
        PresignedUploadResponse( // (2)
            signedUpload.uri.toString(),
            signedUpload.method,
            signedUpload.headers,
            signedUpload.expiration
        )
    )
}
@Post(uri = '/signed', consumes = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON)
HttpResponse<PresignedUploadResponse> create(@Body CreateUploadCommand command) {
    CreatePresignedUploadRequest request =
        new CreatePresignedUploadRequest("profiles/${command.userId}.png", Duration.ofMinutes(5)) // (1)
    request.contentType = MediaType.IMAGE_PNG
    request.metadata = [owner: command.userId]

    PresignedUpload signedUpload = objectStorage.createPresignedUpload(request)
        .orElseThrow(() -> new IllegalStateException('This provider does not support pre-signed uploads'))

    HttpResponse.ok(new PresignedUploadResponse( // (2)
        signedUpload.uri.toString(),
        signedUpload.method,
        signedUpload.headers,
        signedUpload.expiration
    ))
}
1 Build a provider-agnostic CreatePresignedUploadRequest with the target object key and expiry.
2 Return the signed URL, HTTP method, headers, and expiry from PresignedUpload so the client can replay them exactly.

6 Paginated Listing

Paginated Listing

The paginated listing API exposes ObjectStorageOperations#listObjects(ListObjectsRequest) which returns a ListObjectsResponse containing the ordered keys for the current page and an opaque continuation token for the next page. The request accepts a raw prefix for simple starts-with filtering and normalizes empty strings to absent. The examples below show how to iterate pages by replaying the continuation token returned by each response. Providers do not promise global ordering across all keys, so the safe pattern is: use a small deterministic page size, filter by a raw prefix such as userId + "/", and repeat requests while replaying the continuation token until the response omits it.

the examples intentionally keep the code provider-agnostic and rely only on ObjectStorageOperations API.
public void listProfilePicturesByPage(String userId) {
    String prefix = userId + "/"; // raw prefix filtering
    String continuation = null;
    do {
        ListObjectsRequest request = new ListObjectsRequest(2, prefix, continuation); // (1)
        ListObjectsResponse response = objectStorage.listObjects(request);

        // Process the keys in this page
        response.getKeys().forEach(key -> System.out.println("Found: " + key)); // (2)

        // Replay the continuation token returned by the provider for the next page
        continuation = response.getContinuationToken().orElse(null); // (3)
    } while (continuation != null);
}
open fun listProfilePicturesByPage(userId: String) {
    val prefix = "$userId/" // raw prefix filtering
    var continuation: String? = null
    do {
        val request = ListObjectsRequest(2, prefix, continuation) // (1)
        val response: ListObjectsResponse = objectStorage.listObjects(request)

        // Process the keys in this page
        response.keys.forEach { key -> println("Found: $key") } // (2)

        // Replay the continuation token returned by the provider for the next page
        continuation = response.continuationToken.orElse(null) // (3)
    } while (continuation != null)
}
void listProfilePicturesByPage(String userId) {
    String prefix = userId + "/" // raw prefix filtering
    String continuation = null
    do {
        ListObjectsRequest request = new ListObjectsRequest(2, prefix, continuation) // (1)
        ListObjectsResponse response = objectStorage.listObjects(request)

        // Process the keys in this page
        response.keys.forEach { key -> println("Found: $key") } // (2)

        // Replay the continuation token returned by the provider for the next page
        continuation = response.continuationToken.orElse(null) // (3)
    } while (continuation != null)
}
1 Build a ListObjectsRequest with the desired page size, an optional raw prefix like userId + "/", and the last seen continuation token (null for the first page).
2 Call ObjectStorageOperations#listObjects(ListObjectsRequest) and process the returned keys.
3 Replay the continuation token returned by the response for the next page until it is absent.

7 Multipart Uploads

Multipart Uploads

Multipart uploads are available through MultipartObjectStorageOperations. This contract is additive to ObjectStorageOperations so existing single-object upload code does not change.

The multipart lifecycle uses the following portable request and response types:

Support Matrix

Backend Multipart lifecycle support

Amazon S3

Supported

Azure Blob Storage

Not yet supported

Google Cloud Storage

Not yet supported

Oracle Cloud Infrastructure Object Storage

Supported

Local Storage

Supported

Lifecycle Rules

  • createMultipartUpload returns an opaque upload id and does not imply any background cleanup guarantee.

  • In-progress multipart uploads remain caller-owned resources until they are completed or aborted.

  • uploadPart associates a numbered part with a previously created upload handle.

  • Object metadata and content type are set when creating the multipart upload, not on each individual part.

  • Providers may enforce their own part-size limits. Amazon S3 requires each non-final part to be at least 5 MiB. Oracle Cloud Infrastructure Object Storage requires stricter non-final chunks; use chunks greater than 10 MiB, for example 11 MiB. The local storage provider does not enforce a multipart part-size minimum. The final part may be smaller.

  • listParts is read-only and exposes provider pagination through opaque continuation tokens.

  • completeMultipartUpload requires a strictly ordered part manifest.

  • Retrying completeMultipartUpload is only safe when reusing the same upload id and exact part manifest.

  • abortMultipartUpload is safe to retry.

Example

public String uploadVideo(String key, byte[] nonFinalChunk, byte[] finalChunk) {
    var createResponse = multipartObjectStorage.createMultipartUpload(
        new CreateMultipartUploadRequest(key, "video/mp4")
    );
    var upload = createResponse.getUpload();

    try {
        var firstPart = multipartObjectStorage.uploadPart(
            new UploadPartRequest(upload, 1, UploadRequest.fromBytes(nonFinalChunk, upload.getKey()))
        );
        var secondPart = multipartObjectStorage.uploadPart(
            new UploadPartRequest(upload, 2, UploadRequest.fromBytes(finalChunk, upload.getKey()))
        );

        multipartObjectStorage.completeMultipartUpload(
            new CompleteMultipartUploadRequest(upload, List.of(firstPart.getPart(), secondPart.getPart()))
        );
        return upload.getKey();
    } catch (RuntimeException e) {
        multipartObjectStorage.abortMultipartUpload(new AbortMultipartUploadRequest(upload));
        throw e;
    }
}
open fun uploadVideo(key: String, nonFinalChunk: ByteArray, finalChunk: ByteArray): String {
    val createResponse = multipartObjectStorage.createMultipartUpload(
        CreateMultipartUploadRequest(key, "video/mp4")
    )
    val upload = createResponse.upload

    try {
        val firstPart = multipartObjectStorage.uploadPart(
            UploadPartRequest(upload, 1, UploadRequest.fromBytes(nonFinalChunk, upload.key))
        )
        val secondPart = multipartObjectStorage.uploadPart(
            UploadPartRequest(upload, 2, UploadRequest.fromBytes(finalChunk, upload.key))
        )

        multipartObjectStorage.completeMultipartUpload(
            CompleteMultipartUploadRequest(upload, listOf(firstPart.part, secondPart.part))
        )
        return upload.key
    } catch (e: RuntimeException) {
        multipartObjectStorage.abortMultipartUpload(AbortMultipartUploadRequest(upload))
        throw e
    }
}
String uploadVideo(String key, byte[] nonFinalChunk, byte[] finalChunk) {
    def createResponse = multipartObjectStorage.createMultipartUpload(
        new CreateMultipartUploadRequest(key, 'video/mp4')
    )
    def upload = createResponse.upload

    try {
        def firstPart = multipartObjectStorage.uploadPart(
            new UploadPartRequest(upload, 1, UploadRequest.fromBytes(nonFinalChunk, upload.key))
        )
        def secondPart = multipartObjectStorage.uploadPart(
            new UploadPartRequest(upload, 2, UploadRequest.fromBytes(finalChunk, upload.key))
        )

        multipartObjectStorage.completeMultipartUpload(
            new CompleteMultipartUploadRequest(upload, [firstPart.part, secondPart.part])
        )
        upload.key
    } catch (RuntimeException e) {
        multipartObjectStorage.abortMultipartUpload(new AbortMultipartUploadRequest(upload))
        throw e
    }
}

In this example, nonFinalChunk is non-final and must satisfy the selected provider’s minimum part size. The final chunk may be smaller when the provider allows final-part size waivers.

When uploading multipart parts from a stream-backed request instead of a file or byte array, provide the content size up front so providers such as Amazon S3 and Oracle Cloud Infrastructure Object Storage can stream the part without buffering it fully in memory.

8 Bucket and Container Management

Bucket and Container Management

The BucketOperations API adds provider-agnostic lifecycle management for object storage buckets and containers without changing the existing ObjectStorageOperations contract.

@Singleton
public class BucketService {

    private final BucketOperations<?> bucketOperations;
    private final ReactiveBucketOperations<?> reactiveBucketOperations;

    public BucketService(@Named("default") BucketOperations<?> bucketOperations,
                         @Named("default") ReactiveBucketOperations<?> reactiveBucketOperations) {
        this.bucketOperations = bucketOperations;
        this.reactiveBucketOperations = reactiveBucketOperations;
    }

}
@Singleton
open class BucketService(
    @param:Named("default") private val bucketOperations: BucketOperations<*>,
    @param:Named("default") private val reactiveBucketOperations: ReactiveBucketOperations<*>
) {

}
@Singleton
class BucketService {

    final BucketOperations<?> bucketOperations
    final ReactiveBucketOperations<?> reactiveBucketOperations

    BucketService(@Named("default") BucketOperations<?> bucketOperations,
                  @Named("default") ReactiveBucketOperations<?> reactiveBucketOperations) {
        this.bucketOperations = bucketOperations
        this.reactiveBucketOperations = reactiveBucketOperations
    }

}

Use the injected bean to create, retrieve, and delete provider-managed buckets or containers by name:

public void manageBucket(String name) {
    bucketOperations.create(name);
    boolean exists = bucketOperations.exists(name);
    bucketOperations.retrieve(name).ifPresent(bucket -> {
        System.out.println(bucket.name());
    });
    if (exists) {
        bucketOperations.delete(name);
    }
}
open fun manageBucket(name: String) {
    bucketOperations.create(name)
    val exists = bucketOperations.exists(name)
    bucketOperations.retrieve(name).ifPresent { bucket -> println(bucket.name()) }
    if (exists) {
        bucketOperations.delete(name)
    }
}
void manageBucket(String name) {
    bucketOperations.create(name)
    boolean exists = bucketOperations.exists(name)
    bucketOperations.retrieve(name).ifPresent { bucket -> println(bucket.name()) }
    if (exists) {
        bucketOperations.delete(name)
    }
}

The provider-specific native metadata is exposed through BucketEntry#nativeEntry() for advanced use cases, while the portable API keeps the logical bucket or container name available via BucketEntry#name().

If you also need a non-blocking lifecycle API, inject ReactiveBucketOperations and subscribe to the returned `Publisher`s the same way you would with the reactive object-storage contract:

public void manageBucketReactive(String name) {
    Publisher<Void> create = reactiveBucketOperations.create(name);
    Publisher<Boolean> exists = reactiveBucketOperations.exists(name);
}
open fun manageBucketReactive(name: String) {
    val create: Publisher<Void> = reactiveBucketOperations.create(name)
    val exists: Publisher<Boolean> = reactiveBucketOperations.exists(name)
}
void manageBucketReactive(String name) {
    Publisher<Void> create = reactiveBucketOperations.create(name)
    Publisher<Boolean> exists = reactiveBucketOperations.exists(name)
}
Creating or deleting a bucket or container does not retarget an already injected ObjectStorageOperations bean. Existing object-operation beans continue to use the configured default bucket or container for their named storage.

Provider notes:

  • AWS and Oracle Cloud use native async SDK clients for ReactiveBucketOperations.

  • Google Cloud Storage uses bucket terminology, but the google-cloud-storage client on 3.0.x does not expose a native async bucket API, so ReactiveBucketOperations bridges the blocking client on the Micronaut blocking executor for that provider.

  • Azure Blob Storage uses container terminology, but the same BucketOperations API is exposed for portability.

  • Azure Blob Storage uses the SDK’s async container client for ReactiveBucketOperations.

  • Local storage maps bucket names to sibling directories under the configured local storage root.

  • Oracle Cloud bucket lifecycle operations require micronaut.object-storage.oracle-cloud.<name>.compartment-id in addition to the existing bucket and namespace configuration.

9 Metadata Persistence SPI

Micronaut Object Storage now exposes additive metadata contracts for implementations that need to persist bucket/container metadata or object metadata separately from raw object bytes:

The portable write models use replace/upsert semantics:

The corresponding read models expose the stored metadata snapshot plus any provider-native metadata handle:

This SPI is intentionally separate from ObjectStorageOperations and BucketOperations.

That keeps the existing upload, retrieve, and bucket lifecycle contracts source- and binary-compatible while still allowing new implementations to compose metadata and content storage differently.

Version 3.0.x keeps the first release deliberately narrow:

  • CRUD-style metadata persistence is supported.

  • Updating metadata without rewriting object bytes is supported.

  • Portable metadata and attributes are string maps.

  • Querying, indexing, and attribute-based search are out of scope.

Current provider support:

  • Local storage implements both object and bucket metadata contracts as the first reference implementation.

Consistency note:

  • When an implementation stores metadata separately from content, Micronaut does not guarantee distributed transaction semantics between those stores. Providers should document their own consistency model.

10 Amazon S3

To use Amazon S3, you need the following dependency:

implementation("io.micronaut.objectstorage:micronaut-object-storage-aws")
<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-aws</artifactId>
</dependency>

Refer to the Micronaut AWS documentation for more information about credentials and region configuration.

The object storage specific configuration options available are:

🔗
Table 1. Configuration Properties for AwsS3Configuration
Property Type Description Default value

micronaut.object-storage.aws.*.enabled

boolean

Whether to enable or disable this object storage.

micronaut.object-storage.aws.*.bucket

java.lang.String

Bucket name.

For example:

src/main/resources/application-ec2.yml
micronaut.object-storage.aws.default.bucket=profile-pictures-bucket
micronaut:
  object-storage:
    aws:
      default:
        bucket: profile-pictures-bucket
micronaut = {object-storage = {aws = {default = {bucket = "profile-pictures-bucket"}}}}
micronaut {
  objectStorage {
    aws {
      'default' {
        bucket = "profile-pictures-bucket"
      }
    }
  }
}
{
  micronaut {
    object-storage {
      aws {
        default {
          bucket = "profile-pictures-bucket"
        }
      }
    }
  }
}
{
  "micronaut": {
    "object-storage": {
      "aws": {
        "default": {
          "bucket": "profile-pictures-bucket"
        }
      }
    }
  }
}

The concrete implementation of ObjectStorageOperations is AwsS3Operations. It also implements MultipartObjectStorageOperations for multipart upload lifecycle support.

You can also resolve objects through Micronaut’s ResourceLoader abstraction:

  • Named storage URI: pictures://avatars/logo.png

  • AWS alias URI: s3://pictures-bucket/avatars/logo.png

The s3: alias only resolves buckets that are already configured under micronaut.object-storage.aws.

Advanced configuration

For configuration properties other than the specified above, you can add bean to your application that implements BeanCreatedEventListener. For example:

@Singleton
public class S3ClientBuilderCustomizer implements BeanCreatedEventListener<S3ClientBuilder> {

    @Override
    public S3ClientBuilder onCreated(@NonNull BeanCreatedEvent<S3ClientBuilder> event) {
        return event.getBean()
            .overrideConfiguration(c -> c.apiCallTimeout(Duration.of(60, ChronoUnit.SECONDS)));
    }
}

See MultipartObjectStorageOperations and the Multipart Uploads guide for the portable multipart lifecycle contract.

11 Azure Blob Storage

To use Azure Blob Storage, you need the following dependency:

implementation("io.micronaut.objectstorage:micronaut-object-storage-azure")
<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-azure</artifactId>
</dependency>

Refer to the Micronaut Azure documentation for more information about authentication options.

The object storage specific configuration options available are:

🔗
Table 1. Configuration Properties for AzureBlobStorageConfiguration
Property Type Description Default value

micronaut.object-storage.azure.*.enabled

boolean

Whether to enable or disable this object storage.

micronaut.object-storage.azure.*.container

java.lang.String

The blob container name.

micronaut.object-storage.azure.*.endpoint

java.lang.String

the endpoint.

For example:

src/main/resources/application-azure.yml
azure.credential.client-secret.client-id=<client-id>
azure.credential.client-secret.tenant-id=<tenant-id>
azure.credential.client-secret.secret=<secret>
micronaut.object-storage.azure.default.container=profile-pictures-container
micronaut.object-storage.azure.default.endpoint=https://my-account.blob.core.windows.net
azure:
  credential:
    client-secret:
      client-id: <client-id>
      tenant-id: <tenant-id>
      secret: <secret>

micronaut:
  object-storage:
    azure:
      default:
        container: profile-pictures-container
        endpoint: https://my-account.blob.core.windows.net
azure = {credential = {client-secret = {client-id = "<client-id>", tenant-id = "<tenant-id>", secret = "<secret>"}}}
micronaut = {object-storage = {azure = {default = {container = "profile-pictures-container", endpoint = "https://my-account.blob.core.windows.net"}}}}
azure {
  credential {
    clientSecret {
      clientId = "<client-id>"
      tenantId = "<tenant-id>"
      secret = "<secret>"
    }
  }
}
micronaut {
  objectStorage {
    azure {
      'default' {
        container = "profile-pictures-container"
        endpoint = "https://my-account.blob.core.windows.net"
      }
    }
  }
}
{
  azure {
    credential {
      client-secret {
        client-id = "<client-id>"
        tenant-id = "<tenant-id>"
        secret = "<secret>"
      }
    }
  }
  micronaut {
    object-storage {
      azure {
        default {
          container = "profile-pictures-container"
          endpoint = "https://my-account.blob.core.windows.net"
        }
      }
    }
  }
}
{
  "azure": {
    "credential": {
      "client-secret": {
        "client-id": "<client-id>",
        "tenant-id": "<tenant-id>",
        "secret": "<secret>"
      }
    }
  },
  "micronaut": {
    "object-storage": {
      "azure": {
        "default": {
          "container": "profile-pictures-container",
          "endpoint": "https://my-account.blob.core.windows.net"
        }
      }
    }
  }
}

The concrete implementation of ObjectStorageOperations is AzureBlobStorageOperations.

You can also resolve objects through Micronaut’s ResourceLoader abstraction:

  • Named storage URI: pictures://avatars/logo.png

  • Azure alias URI: azb:storageaccount://pictures/avatars/logo.png

The azb: alias only resolves configured storages, matching the account name from endpoint together with the configured container name.

Advanced configuration

For configuration properties other than the specified above, you can add bean to your application that implements BeanCreatedEventListener. For example:

@Singleton
public class BlobServiceClientBuilderCustomizer implements BeanCreatedEventListener<BlobServiceClientBuilder> {

    @Override
    public BlobServiceClientBuilder onCreated(@NonNull BeanCreatedEvent<BlobServiceClientBuilder> event) {
        HttpPipelinePolicy noOp = (context, next) -> next.process();
        return event.getBean().addPolicy(noOp);
    }
}

12 Google Cloud Storage

To use Google Cloud Storage, you need the following dependency:

implementation("io.micronaut.objectstorage:micronaut-object-storage-gcp")
<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-gcp</artifactId>
</dependency>

Refer to the Micronaut GCP documentation for more information about configuring your GCP project.

The object storage specific configuration options available are:

🔗
Table 1. Configuration Properties for GoogleCloudStorageConfiguration
Property Type Description Default value

micronaut.object-storage.gcp.*.enabled

boolean

Whether to enable or disable this object storage.

micronaut.object-storage.gcp.*.bucket

java.lang.String

The name of the Google Cloud Storage bucket.

For example:

src/main/resources/application-gcp.yml
gcp.project-id=my-gcp-project
micronaut.object-storage.gcp.default.bucket=profile-pictures-bucket
gcp:
  project-id: my-gcp-project

micronaut:
  object-storage:
    gcp:
      default:
        bucket: profile-pictures-bucket
gcp = {project-id = "my-gcp-project"}
micronaut = {object-storage = {gcp = {default = {bucket = "profile-pictures-bucket"}}}}
gcp {
  projectId = "my-gcp-project"
}
micronaut {
  objectStorage {
    gcp {
      'default' {
        bucket = "profile-pictures-bucket"
      }
    }
  }
}
{
  gcp {
    project-id = "my-gcp-project"
  }
  micronaut {
    object-storage {
      gcp {
        default {
          bucket = "profile-pictures-bucket"
        }
      }
    }
  }
}
{
  "gcp": {
    "project-id": "my-gcp-project"
  },
  "micronaut": {
    "object-storage": {
      "gcp": {
        "default": {
          "bucket": "profile-pictures-bucket"
        }
      }
    }
  }
}

The concrete implementation of ObjectStorageOperations is GoogleCloudStorageOperations.

You can also resolve objects through Micronaut’s ResourceLoader abstraction:

  • Named storage URI: pictures://avatars/logo.png

  • Google Cloud alias URI: gs://pictures-bucket/avatars/logo.png

The gs: alias only resolves buckets that are already configured under micronaut.object-storage.gcp.

Advanced configuration

For configuration properties other than the specified above, you can add bean to your application that implements BeanCreatedEventListener. For example:

@Singleton
public class StorageOptionsBuilderCustomizer implements BeanCreatedEventListener<StorageOptions.Builder> {

    @Override
    public StorageOptions.Builder onCreated(@NonNull BeanCreatedEvent<StorageOptions.Builder> event) {
        return event.getBean()
            .setTransportOptions(HttpTransportOptions.newBuilder().setConnectTimeout(60_000).build());
    }
}

13 Oracle Cloud Infrastructure (OCI) Object Storage

To use Oracle Cloud Infrastructure (OCI) Object Storage, you need the following dependency:

implementation("io.micronaut.objectstorage:micronaut-object-storage-oracle-cloud")
<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-oracle-cloud</artifactId>
</dependency>

Refer to the Micronaut Oracle Cloud documentation for more information about authentication options.

The object storage specific configuration options available are:

🔗
Table 1. Configuration Properties for OracleCloudStorageConfiguration
Property Type Description Default value

micronaut.object-storage.oracle-cloud.*.enabled

boolean

Whether to enable or disable this object storage.

micronaut.object-storage.oracle-cloud.*.bucket

java.lang.String

The name of the OCI Object Storage bucket.

micronaut.object-storage.oracle-cloud.*.namespace

java.lang.String

the OCI Object Storage namespace used.

micronaut.object-storage.oracle-cloud.*.compartment-id

java.lang.String

the OCI compartment identifier, or {@code null} if bucket lifecycle operations are not configured.

For example:

src/main/resources/application-oraclecloud.yml
oci.config.profile=DEFAULT
micronaut.object-storage.oracle-cloud.default.bucket=profile-pictures-bucket
micronaut.object-storage.oracle-cloud.default.namespace=MyNamespace
oci:
  config:
    profile: DEFAULT

micronaut:
  object-storage:
    oracle-cloud:
      default:
        bucket: profile-pictures-bucket
        namespace: MyNamespace
oci = {config = {profile = "DEFAULT"}}
micronaut = {object-storage = {oracle-cloud = {default = {bucket = "profile-pictures-bucket", namespace = "MyNamespace"}}}}
oci {
  config {
    profile = "DEFAULT"
  }
}
micronaut {
  objectStorage {
    oracleCloud {
      'default' {
        bucket = "profile-pictures-bucket"
        namespace = "MyNamespace"
      }
    }
  }
}
{
  oci {
    config {
      profile = "DEFAULT"
    }
  }
  micronaut {
    object-storage {
      oracle-cloud {
        default {
          bucket = "profile-pictures-bucket"
          namespace = "MyNamespace"
        }
      }
    }
  }
}
{
  "oci": {
    "config": {
      "profile": "DEFAULT"
    }
  },
  "micronaut": {
    "object-storage": {
      "oracle-cloud": {
        "default": {
          "bucket": "profile-pictures-bucket",
          "namespace": "MyNamespace"
        }
      }
    }
  }
}

The concrete implementation of ObjectStorageOperations is OracleCloudStorageOperations. It also implements MultipartObjectStorageOperations for multipart upload lifecycle support.

You can also resolve objects through Micronaut’s ResourceLoader abstraction:

  • Named storage URI: pictures://avatars/logo.png

  • Oracle Cloud alias URI: os:us-ashburn-1:my-namespace://pictures-bucket/avatars/logo.png

The os: alias only resolves configured storages and must match the active OCI region together with the configured namespace and bucket.

Advanced configuration

For configuration properties other than the specified above, you can add bean to your application that implements BeanCreatedEventListener. For example:

//See https://github.com/oracle/oci-java-sdk/blob/master/bmc-examples/src/main/java/ClientConfigurationTimeoutExample.java
@Singleton
public class ObjectStorageClientBuilderCustomizer implements BeanCreatedEventListener<ObjectStorageClient.Builder> {

    public static final int CONNECTION_TIMEOUT_IN_MILLISECONDS = 25000;
    public static final int READ_TIMEOUT_IN_MILLISECONDS = 35000;

    @Override
    public ObjectStorageClient.Builder onCreated(@NonNull BeanCreatedEvent<ObjectStorageClient.Builder> event) {
        ClientConfiguration clientConfiguration =
            ClientConfiguration.builder()
                .connectionTimeoutMillis(CONNECTION_TIMEOUT_IN_MILLISECONDS)
                .readTimeoutMillis(READ_TIMEOUT_IN_MILLISECONDS)
                .build();


        return event.getBean()
            .configuration(clientConfiguration);
    }
}

See MultipartObjectStorageOperations and the Multipart Uploads guide for the portable multipart lifecycle contract.

14 Local Storage

To use the local storage implementation (useful for tests), you need the following dependency:

testImplementation("io.micronaut.objectstorage:micronaut-object-storage-local")
<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-local</artifactId>
    <scope>test</scope>
</dependency>

Then, simply define a local storage:

micronaut.object-storage.local.default.enabled=true
micronaut:
  object-storage:
    local:
      default:
        enabled: true
micronaut = {object-storage = {local = {default = {enabled = true}}}}
micronaut {
  objectStorage {
    local {
      'default' {
        enabled = true
      }
    }
  }
}
{
  micronaut {
    object-storage {
      local {
        default {
          enabled = true
        }
      }
    }
  }
}
{
  "micronaut": {
    "object-storage": {
      "local": {
        "default": {
          "enabled": true
        }
      }
    }
  }
}
When added to the classpath, LocalStorageOperations becomes the primary implementation of ObjectStorageOperations.
The local storage implementation reserves the .mn-storage key namespace for provider-managed metadata, multipart upload state, temporary write files, and temporary rollback files. It also reserves the legacy .metadata key namespace used by released older versions for metadata sidecars. User object keys cannot be .mn-storage, .metadata, or start with either reserved namespace followed by / (or the equivalent platform file separator). Local bucket names and configured local bucket directory names cannot use those reserved namespaces either.

The local implementation is also the reference provider for ObjectMetadataOperations and BucketMetadataOperations. Provider-managed files live under one .mn-storage directory in the storage root that contains the configured bucket. Object metadata sidecars live under .mn-storage/metadata/objects/<bucket>/<key>, bucket metadata sidecars live under .mn-storage/metadata/buckets/<bucket>, in-progress multipart upload state lives under .mn-storage/multipart/<bucket>, temporary local file rollback snapshots live under .mn-storage/snapshots/<bucket>, and in-flight local file writes use typed provider temp directories under .mn-storage/tmp/<bucket>, such as objects, object-metadata, and bucket-metadata. Before creating a new replacement temporary file, local writes opportunistically attempt best-effort removal of matching replacement temporary files older than one day from the relevant typed provider temp directory. Rollback snapshots are normally removed after the local mutation completes, but a cleanup failure after a committed mutation can leave snapshot cleanup debt; rollback snapshots are not part of the one-day replacement temp cleanup. Temp-file replacement uses atomic moves where supported by the local file system, with a non-atomic replace fallback and best-effort directory synchronization where those operations are unavailable. Within a single JVM, local writes, deletes, and copies that mutate the same object path are serialized so a failed metadata operation rollback does not replace a later same-path mutation. Bucket deletion is also serialized against in-flight object mutations for that bucket in the same JVM and removes provider-managed object metadata sidecars, multipart upload state, rollback snapshots, replacement temporary files, and built-in local bucket metadata for that bucket. These guarantees are per file. Object byte replacement and metadata persistence are separate operations; the local provider does not provide a single crash-atomic transaction across both. Custom metadata providers, including database-backed implementations, are responsible for their own metadata durability, cleanup lifecycle, and any higher-level transaction or compensation behavior.

For compatibility with persistent local storage paths created by released older versions, the built-in local sidecar metadata provider also checks legacy per-bucket .metadata sidecars. New built-in sidecar metadata writes use the root .mn-storage layout, and deletes remove both the root sidecar and the legacy .metadata sidecar for the deleted object.

By default, it will create a temporary folder to store the files, but you can configure it to use a specific folder:

On POSIX-capable file systems, configured bucket paths created by the local implementation use owner-only permissions for bucket subdirectories, stored objects, and provider-managed .mn-storage files. On non-POSIX file systems, Micronaut cannot apply those permissions directly, so custom storage paths on shared hosts should be provisioned with restrictive ACLs by the caller.

🔗
Table 1. Configuration Properties for LocalStorageConfiguration
Property Type Description Default value

micronaut.object-storage.local.*.enabled

boolean

Whether to enable or disable this object storage.

micronaut.object-storage.local.*.path

java.nio.file.Path

The path of the local storage.

For example:

src/main/resources/application-test.yml
micronaut.object-storage.local.default.path=/tmp/my-object-storage
micronaut:
  object-storage:
    local:
      default:
        path: /tmp/my-object-storage
micronaut = {object-storage = {local = {default = {path = "/tmp/my-object-storage"}}}}
micronaut {
  objectStorage {
    local {
      'default' {
        path = "/tmp/my-object-storage"
      }
    }
  }
}
{
  micronaut {
    object-storage {
      local {
        default {
          path = "/tmp/my-object-storage"
        }
      }
    }
  }
}
{
  "micronaut": {
    "object-storage": {
      "local": {
        "default": {
          "path": "/tmp/my-object-storage"
        }
      }
    }
  }
}

The concrete implementation of ObjectStorageOperations is LocalStorageOperations. The local module also provides MultipartObjectStorageOperations for multipart upload lifecycle support. See the Multipart Uploads guide for the portable multipart API.

15 Control Panel

The Micronaut Control Panel module has support for Micronaut Object Storage by adding the following dependency:

developmentOnly("io.micronaut.controlpanel:micronaut-control-panel-object-storage")
<dependency>
    <groupId>io.micronaut.controlpanel</groupId>
    <artifactId>micronaut-control-panel-object-storage</artifactId>
    <scope>provided</scope>
</dependency>

Check the documentation for more information the documentation for more information

16 Guides

See the following list of guides to learn more about working with Object Storage in the Micronaut Framework:

17 Breaking Changes

This section documents breaking changes between Micronaut Object Storage versions:

Micronaut Object Storage 3.0.0

Deprecations

  • The constructor io.micronaut.objectstorage.azure.AzureBlobStorageEntry(String, BinaryData) deprecated previously has been removed. Use AzureBlobStorageEntry(String, BinaryData, BlobProperties) instead.

  • The bean constructor io.micronaut.objectstorage.oraclecloud.OracleCloudStorageOperations(OracleCloudStorageConfiguration, ObjectStorage) deprecated previously has been removed. OracleCloudStorageOperations(OracleCloudStorageConfiguration, ObjectStorage, RegionProvider) is used instead.

18 Repository

You can find the source code of this project in this repository: