nyazul@portfolio:~$ ./boot.sh

Nyazul Ansari

Backend Engineer — Java / Spring Boot

000%

← All case studies

Case study 05 / 05

Document storage lifecycle for a multi-tenant CRM

Upload, download and short-lived share links for a property CRM, with every file kept under its project's prefix in a private bucket. When creating a project fails partway, a compensating delete removes the files it already uploaded. That clean-up doesn't cover every failure path yet, and the fix is to tie it to the rollback itself.

Role
Software engineering intern
When
2025
Stack
AWS S3Spring BootReact

10

Minutes a download link stays valid

0

Public objects in the bucket

5

Levels from organization to flat

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.

Simplified: a single-object, time-boxed download linkjava
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.

Simplified: project-scoped keys and a prefix deletejava
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.

Simplified: compensation on a reported failurejava
@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.

Proposed: undo uploads on any rollbackjava
// 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.