Problem
The CRM stores documents for each property project: letterheads, agreements, IDs, receipts. Users need to upload, download and share them without the bucket ever being public.
Object storage isn't part of the database transaction. Creating a project uploads its letterhead and documents to S3 partway through one @Transactional method. If a later step fails, PostgreSQL rolls back, but the files are already in the bucket: referenced by nothing, invisible in the app, and billed indefinitely.
Approach
01Short-lived links instead of a public bucket
Downloads and shares go through presigned URLs scoped to a single object and valid for ten minutes. The bucket stays private, and access control stays in the application, where the permission model lives. Only the object key is stored in the database, never the bytes or a public URL.
GetObjectPresignRequest request = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10))
.getObjectRequest(g -> g.bucket(bucket).key(key))
.build();
return presigner.presignGetObject(request).url().toString();02Scope every object to its project
Every key starts with the organization and the project: {orgId}/{projectName}/LetterHead/… and /Documents/…. That one convention is what makes clean-up tractable: removing a project's files is a single prefix delete, nothing more and nothing left behind.
String key = orgId + "/" + projectName + "/Documents/" + fileName;
void deleteProjectDirectory(UUID orgId, String projectName) {
String prefix = orgId + "/" + projectName + "/";
List<ObjectIdentifier> keys = s3.listObjectsV2(r -> r.bucket(bucket).prefix(prefix))
.contents().stream()
.map(o -> ObjectIdentifier.builder().key(o.key()).build())
.toList();
s3.deleteObjects(r -> r.bucket(bucket).delete(d -> d.objects(keys)));
}03Compensate when creation fails
Each step of project creation (wings and flats, bank details, amenities, documents, disbursements) reports whether it succeeded. If one reports failure, the method runs a compensating hard delete: the project's S3 prefix first, then its rows, then it throws so the transaction rolls back. A user-facing delete, by contrast, is a soft delete: the rows stay, so the files they point to stay with them.
@Transactional
public Project createProject(ProjectCreationDTO dto, AppUserDetails user) {
Project project = projectRepository.save(newProject(dto, user)); // + letterhead upload
if (!wingService.createWingsForProject(project, dto.wings())
|| !documentService.createDocumentsForProject(project, dto.documents(), user)
|| /* ... banks, amenities, disbursements ... */) {
hardDeleteProjectRecursive(project, user); // S3 prefix, then rows
throw new ApiException(INTERNAL_SERVER_ERROR, "Project creation failed");
}
return project;
}04Next: clean up on every rollback
The compensation only runs when a step returns false. If a step throws instead (an S3 timeout mid-upload, a constraint violation), the exception skips that branch: the database rolls back and the files uploaded so far stay in the bucket. The RealEstate project page has a playground that shows both paths.
The fix I'd make next ties the clean-up to the transaction's outcome rather than to one code path: register a synchronization on the first upload, and delete the prefix whenever the transaction completes with a rollback, whatever caused it. Two smaller fixes go with it: page through listObjectsV2 (one call returns at most 1,000 keys), and key objects by project id instead of name, so a rename can't separate a project from its files.
// On the first upload inside a transaction:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
storage.deletePrefix(prefix); // pages through every key
}
}
});05Named report policies
The feature was paired with a configurable PDF report engine. Each project can have named policies that choose which sections a generated report includes, such as the project overview or the wing breakdown, rendered server-side from HTML instead of maintained as separate templates.
Outcome
Documents are private by default and shareable on demand, and a project whose creation fails with a reported error removes the files it uploaded instead of leaving them to accumulate cost.
The clean-up isn't complete yet: exceptions, very large projects and renamed projects can still leave files behind. Tying it to the rollback, paginating and keying by id is the change I'd ship next.
Lessons
- Anything you create, you're responsible for deleting. Design the cleanup path at the same time as the create path.
- A transaction stops at the database. Anything else it touches needs its own undo, and that undo has to run on every failure path, not just the ones you return from.