ADR-002: Generic File Upload Service Architecture
Status: Accepted
Context
The Circular Dependency Problem
During implementation of company creation with logo upload functionality, we encountered a fundamental architectural issue: circular dependency between entity creation and file uploads.
When creating a new company in the web-frontend, users expect to provide all company data including the logo in a single atomic operation. However, the current architecture creates a catch-22 situation:
- Logo upload requires company to exist: The endpoint
POST /companies/{name}/logo/presigned-urlrequires the company name as a path parameter - Company lookup fails: Backend looks up the company by name (
companyRepository.findByName(name)) to generate S3 keys - Company doesnβt exist yet: During creation flow, the company record hasnβt been persisted to the database
- Poor workaround: Frontend uses placeholder
'temp-company-name', which fails backend validation
Current Flow (Broken):
ββββββββββββ 1. Create Company βββββββββββββββββββ
β Frontend ββββββββββββββββββββββββ>β POST /companies β
β β {name, website, ...} β β
ββββββββββββ ββββββββββ¬βββββββββ
β
v
β Cannot upload logo
(company must exist first)
ββββββββββββ 2. Upload Logo ββββββββββββββββββββββββ
β Frontend ββββββββββββββββββββββββ>β POST /companies/{name}β
β β β /logo/presigned-url β
ββββββββββββ ββββββββββ¬ββββββββββββββ
β
v
β Company not found!
(lookup by name fails)
Current Workaround (Poor UX):
- User must create company WITHOUT logo first
- User then edits the company to add the logo
- Requires two separate operations
- Confusing user experience
Problems with Entity-Specific Upload Endpoints
The current CompanyLogoService architecture has several issues:
CompanyLogoService.java (Current Implementation):
public PresignedUploadUrl generateLogoUploadUrl(String userId, String filename, long fileSizeBytes) {
// β Problem: Requires company to exist
Company company = companyRepository.findByName(userId)
.orElseThrow(() -> new CompanyNotFoundException(userId));
// S3 key depends on company name
String s3Key = generateS3Key(company.getName(), fileId, extension);
}
LogoUpload.tsx (Current Implementation):
const companyNameForUpload =
mode === 'edit' && initialData?.name
? initialData.name
: 'temp-company-name'; // β Placeholder hack
// β Fails because 'temp-company-name' doesn't exist
const presignedResponse = await apiClient.post(
`/companies/${companyNameForUpload}/logo/presigned-url`,
{ fileName, fileSize, mimeType }
);
Key Architectural Issues
- Tight Coupling: File upload logic tightly coupled to Company entity
- Not Reusable: Cannot be used for User profiles, Event banners, Partner logos
- Circular Dependency: Entity must exist before file upload, but file must exist for entity creation
- Poor UX: Forces multi-step operation where single step is expected
- Scalability Issue: Need separate upload service for every entity type
Scope of Impact
This problem affects (or will affect) multiple domains:
- β Company Management: Logo uploads (immediate problem)
- β³ User Management: Profile picture uploads (future)
- β³ Event Management: Event banner/thumbnail uploads (future)
- β³ Partner Management: Partner logo uploads (future)
- β³ Speaker Management: Speaker photo uploads (future)
Decision
We have decided to implement a Generic File Upload Service that completely decouples file uploads from entity lifecycle, solving the circular dependency through a three-phase upload pattern.
Core Principles
- Entity-Agnostic Uploads: Files can be uploaded without any entity existing
- State Machine Pattern: Explicit upload lifecycle (PENDING β CONFIRMED β ASSOCIATED)
- Deferred Association: Files are associated with entities after upload completion
- Automatic Cleanup: Orphaned uploads are automatically removed
- Single Reusable Service: Works for all entity types (companies, users, events, etc.)
Three-Phase Upload Pattern
PHASE 1: Upload Initiation (No Entity Required)
ββββββββββββ POST /logos/presigned-url βββββββββββββββββββ
β Frontend ββββββββββββββββββββββββββββ>β GenericLogo β
β β {fileName, fileSize, type} β Service β
ββββββββββββ ββββββββββ¬βββββββββ
β
v
ββββββββββββββββββ
β Create Logo β
β status=PENDING β
β β
β S3: logos/temp/β
β {uploadId}/β
ββββββββββ¬ββββββββ
β
v
ββββββββββββββββββ
β Return presignedβ
β URL + uploadId β
ββββββββββββββββββ
PHASE 2: S3 Upload & Confirmation
ββββββββββββ PUT (presigned URL) βββββββββββββββββββ
β Frontend ββββββββββββββββββββββββββββ>β AWS S3 β
β β [binary file data] β β
ββββββββββββ βββββββββββββββββββ
β
βββββ> POST /logos/{uploadId}/confirm βββββββββββββββββββ
{fileExtension, checksum} β Update Logo β
βββββββββββββββββββββββββββββ>β status=CONFIRMEDβ
βββββββββββββββββββ
PHASE 3: Entity Association (During Entity Creation)
ββββββββββββ POST /companies βββββββββββββββββββ
β Frontend ββββββββββββββββββββββββββββ>β CompanyService β
β β {name, website, industry, β β
β β logoUploadId} β 1. Create β
ββββββββββββ β company β
β β
β 2. Associate β
β logo β
β β
β 3. Copy S3 file β
β temp β final β
β β
β 4. Update Logo β
β ASSOCIATED β
βββββββββββββββββββ
Database Schema
New Table: logos
CREATE TABLE logos (
id UUID PRIMARY KEY,
upload_id VARCHAR(100) UNIQUE NOT NULL, -- Public identifier
s3_key VARCHAR(500) NOT NULL, -- Current S3 key
cloudfront_url VARCHAR(1000), -- CDN URL for access
file_extension VARCHAR(10) NOT NULL, -- png, jpg, jpeg, svg
file_size BIGINT NOT NULL, -- Size in bytes
mime_type VARCHAR(100) NOT NULL, -- image/png, etc.
checksum VARCHAR(100), -- SHA-256 for integrity
-- State machine
status VARCHAR(20) NOT NULL, -- PENDING, CONFIRMED, ASSOCIATED
-- Entity association (populated when ASSOCIATED)
associated_entity_type VARCHAR(50), -- COMPANY, USER, EVENT, etc.
associated_entity_id VARCHAR(255), -- Entity's identifier
-- Lifecycle timestamps
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP, -- For cleanup job
-- Performance indexes
INDEX idx_upload_id (upload_id),
INDEX idx_status_expires (status, expires_at)
);
Service Architecture
1. GenericLogoService (Core Upload Logic)
@Service
@Transactional
public class GenericLogoService {
private final S3Presigner s3Presigner;
private final S3Client s3Client;
private final LogoRepository logoRepository;
/**
* Phase 1: Generate presigned URL for upload
* No entity required - works standalone
*/
public PresignedUploadUrl generatePresignedUrl(
String fileName,
long fileSize,
String mimeType) {
validateFile(fileName, fileSize, mimeType);
String uploadId = UUID.randomUUID().toString();
String fileId = UUID.randomUUID().toString();
String extension = getExtension(fileName);
// Temp S3 key (no entity reference)
String tempS3Key = generateTempS3Key(uploadId, fileId, extension);
// Create Logo entity with PENDING status
Logo logo = Logo.builder()
.uploadId(uploadId)
.s3Key(tempS3Key)
.fileExtension(extension)
.fileSize(fileSize)
.mimeType(mimeType)
.status(LogoStatus.PENDING)
.expiresAt(Instant.now().plus(24, ChronoUnit.HOURS))
.build();
logoRepository.save(logo);
// Generate presigned URL
String presignedUrl = s3Presigner.presignPutObject(...);
return new PresignedUploadUrl(presignedUrl, uploadId, fileId, ...);
}
/**
* Phase 2: Confirm upload completion
* Updates status to CONFIRMED
*/
public void confirmUpload(String uploadId, String checksum) {
Logo logo = logoRepository.findByUploadId(uploadId)
.orElseThrow(() -> new LogoNotFoundException(uploadId));
logo.setStatus(LogoStatus.CONFIRMED);
logo.setChecksum(checksum);
logo.setExpiresAt(Instant.now().plus(7, ChronoUnit.DAYS));
logoRepository.save(logo);
}
/**
* Phase 3: Associate logo with entity
* Called during entity creation
*/
public String associateLogoWithEntity(
String uploadId,
String entityType,
String entityId,
String finalS3Key) {
Logo logo = logoRepository.findByUploadId(uploadId)
.orElseThrow(() -> new LogoNotFoundException(uploadId));
if (logo.getStatus() != LogoStatus.CONFIRMED) {
throw new IllegalStateException("Logo must be CONFIRMED before association");
}
// Copy S3 object from temp to final location
copyS3Object(logo.getS3Key(), finalS3Key);
// Delete temp file
deleteS3Object(logo.getS3Key());
// Update Logo entity
logo.setS3Key(finalS3Key);
logo.setCloudFrontUrl(buildCloudFrontUrl(finalS3Key));
logo.setStatus(LogoStatus.ASSOCIATED);
logo.setAssociatedEntityType(entityType);
logo.setAssociatedEntityId(entityId);
logo.setExpiresAt(null); // No expiration for associated logos
logoRepository.save(logo);
return logo.getCloudFrontUrl();
}
private String generateTempS3Key(String uploadId, String fileId, String ext) {
return String.format("logos/temp/%s/logo-%s.%s", uploadId, fileId, ext);
}
}
2. LogoCleanupService (Scheduled Cleanup Job)
@Service
public class LogoCleanupService {
private final LogoRepository logoRepository;
private final S3Client s3Client;
/**
* Runs daily to clean up orphaned uploads
*/
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
public void cleanupOrphanedLogos() {
Instant now = Instant.now();
// Find expired logos (PENDING > 24h, CONFIRMED > 7 days)
List<Logo> expiredLogos = logoRepository
.findByStatusAndExpiresAtBefore(LogoStatus.PENDING, now);
expiredLogos.addAll(logoRepository
.findByStatusAndExpiresAtBefore(LogoStatus.CONFIRMED, now));
for (Logo logo : expiredLogos) {
try {
// Delete S3 file
deleteS3Object(logo.getS3Key());
// Delete database record
logoRepository.delete(logo);
log.info("Cleaned up orphaned logo: {}", logo.getUploadId());
} catch (Exception e) {
log.error("Failed to cleanup logo: {}", logo.getUploadId(), e);
}
}
}
}
3. LogoController (REST API)
@RestController
@RequestMapping("/api/v1/logos")
@Tag(name = "File Upload", description = "Generic file upload operations")
public class LogoController {
private final GenericLogoService logoService;
@PostMapping("/presigned-url")
public ResponseEntity<PresignedUploadUrl> requestUploadUrl(
@Valid @RequestBody LogoUploadRequest request) {
PresignedUploadUrl response = logoService.generatePresignedUrl(
request.getFileName(),
request.getFileSize(),
request.getMimeType()
);
return ResponseEntity.ok(response);
}
@PostMapping("/{uploadId}/confirm")
public ResponseEntity<Void> confirmUpload(
@PathVariable String uploadId,
@Valid @RequestBody LogoUploadConfirmRequest request) {
logoService.confirmUpload(uploadId, request.getChecksum());
return ResponseEntity.ok().build();
}
@DeleteMapping("/{uploadId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> deleteUnusedLogo(@PathVariable String uploadId) {
logoService.deleteUnusedLogo(uploadId);
return ResponseEntity.noContent().build();
}
}
Integration with Company Creation
Updated CreateCompanyRequest:
public class CreateCompanyRequest {
@NotBlank
private String name;
private String displayName;
private String website;
private String industry;
private String description;
// NEW FIELD: Reference to uploaded logo
@Schema(description = "Upload ID from /logos/presigned-url")
private String logoUploadId; // Optional
}
Updated CompanyService:
@Service
public class CompanyService {
private final CompanyRepository companyRepository;
private final GenericLogoService logoService;
public CompanyResponse createCompany(CreateCompanyRequest request) {
// Create company entity
Company company = Company.builder()
.name(request.getName())
.displayName(request.getDisplayName())
.website(request.getWebsite())
.industry(request.getIndustry())
.description(request.getDescription())
.build();
Company savedCompany = companyRepository.save(company);
// Associate logo if provided
if (request.getLogoUploadId() != null) {
String finalS3Key = generateFinalS3Key(
company.getName(),
request.getLogoUploadId()
);
String logoUrl = logoService.associateLogoWithEntity(
request.getLogoUploadId(),
"COMPANY",
company.getName(),
finalS3Key
);
savedCompany.setLogoUrl(logoUrl);
companyRepository.save(savedCompany);
}
return mapToResponse(savedCompany);
}
private String generateFinalS3Key(String companyName, String uploadId) {
int year = LocalDate.now().getYear();
return String.format("logos/%d/companies/%s/logo-%s.png",
year, companyName, uploadId);
}
}
Frontend Implementation
New Reusable FileUpload Component:
// Location: web-frontend/src/components/shared/FileUpload/FileUpload.tsx
interface FileUploadProps {
currentFileUrl?: string;
onUploadSuccess?: (data: { uploadId: string; fileUrl: string }) => void;
onUploadError?: (error: { type: string; message: string }) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export const FileUpload: React.FC<FileUploadProps> = ({
currentFileUrl,
onUploadSuccess,
onUploadError,
maxFileSize = 5 * 1024 * 1024, // 5MB
allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml'],
}) => {
const uploadFile = async (file: File) => {
// Phase 1: Request presigned URL (generic endpoint)
const presignedResponse = await apiClient.post('/logos/presigned-url', {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
});
const { uploadUrl, uploadId, fileExtension } = presignedResponse.data;
// Phase 2: Upload to S3
await axios.put(uploadUrl, file, {
headers: { 'Content-Type': file.type },
onUploadProgress: (e) => setProgress(e.loaded / e.total * 100),
});
// Phase 3: Confirm upload
await apiClient.post(`/logos/${uploadId}/confirm`, {
fileExtension,
checksum: await calculateChecksum(file),
});
// Notify parent component
onUploadSuccess?.({ uploadId, fileUrl: uploadUrl });
};
// ... drag-and-drop UI
};
Updated CompanyForm:
export const CompanyForm: React.FC<CompanyFormProps> = ({ ... }) => {
const [logoUploadId, setLogoUploadId] = useState<string | undefined>();
const handleLogoUploadSuccess = (data: { uploadId: string }) => {
setLogoUploadId(data.uploadId); // Store for company creation
};
const handleFormSubmit = async (data: CompanyFormData) => {
const createRequest = {
...data,
logoUploadId, // Include upload ID
};
await onSubmit(createRequest);
};
return (
<Dialog open={open}>
{/* ... other form fields */}
<FileUpload
currentFileUrl={logoUrl}
onUploadSuccess={handleLogoUploadSuccess}
onUploadError={handleLogoUploadError}
/>
</Dialog>
);
};
S3 Key Strategy
Temporary Storage (During Upload):
logos/temp/{uploadId}/logo-{fileId}.{ext}
Example:
logos/temp/abc123-def456/logo-f3e8d1a4.png
Final Storage (After Association):
logos/{year}/{entity-type}/{entity-name}/logo-{fileId}.{ext}
Examples:
- logos/2025/companies/Swisscom-AG/logo-f3e8d1a4.png
- logos/2025/users/john-doe/profile-a1b2c3d4.jpg (future)
- logos/2025/events/bat-bern-2025/banner-e5f6g7h8.jpg (future)
API Changes
New Endpoints:
β
POST /api/v1/logos/presigned-url β Generate upload URL (no auth)
β
POST /api/v1/logos/{uploadId}/confirm β Confirm upload
β
DELETE /api/v1/logos/{uploadId} β Delete unused logo
Updated Endpoints:
β
POST /api/v1/companies
Request: { name, website, industry, logoUploadId }
β Now accepts logoUploadId for logo association
Removed Endpoints (Clean Break):
β POST /api/v1/companies/{name}/logo/presigned-url
β POST /api/v1/companies/{name}/logo/confirm
Scope Clarification (2026-03-03)
When to Use GenericLogoService vs Entity-Specific Presigned URL
ADR-002 was designed to solve one specific problem: circular dependency between entity creation and file upload. The three-phase PENDING β CONFIRMED β ASSOCIATED state machine is the right tool when a file must be uploaded before the entity it belongs to has been created.
Use GenericLogoService (ADR-002 pattern) when:
- Uploading a file at entity-creation time β the entity does not yet exist in the DB
- Example: uploading a company logo while filling out the βCreate Companyβ form
- Example: uploading a profile picture during new user registration
Use entity-specific presigned URL pattern (direct S3Presigner + S3Client) when:
- Uploading or managing files on an existing entity β the entity is already persisted
- The circular dependency problem does not apply
- Example:
SpeakerProfilePhotoServiceβ speaker already exists, organizer adds/replaces photo - Example:
EventPhotoService(Story 10.21) β event already exists, organizer uploads post-event photos - Example:
EventTeaserImageService(Story 10.22) β event already exists, organizer uploads teaser images
The entity-specific pattern for existing-entity uploads is compliant with the spirit of ADR-002 β it is not a workaround or drift. The GenericLogoService should not be called cross-service from event-management-service or speaker-coordination-service purely for uploads to already-persisted entities.
Pattern summary
| Scenario | Pattern | Service |
|---|---|---|
| Create entity + upload file atomically | GenericLogoService (PENDING β CONFIRMED β ASSOCIATED) | company-user-management-service |
| Add/replace file on existing entity | Direct S3Presigner + S3Client in domain service | Domain service (e.g. event-management-service) |
This clarification is retroactively valid for SpeakerProfilePhotoService and is the governing rule for all future file upload stories involving existing entities.
Scope Clarification 2: Server-Side URL Import (2026-06-04, Story 12.12)
The βalways presigned URLs, never proxy file content through the backendβ rule governs user uploads β files that originate on the client. It does not apply to server-initiated URL imports, where the backend fetches an image from a remote URL the client never holds as bytes. Proxying is unavoidable there (there is nothing on the client to presign), so these flows route image bytes through the backend by design and are not drift:
| Call site | Flow | Size cap |
|---|---|---|
LogoController.fetchImageFromUrl (POST /fetch-from-url) |
Import a company logo from a remote URL | 10 MB |
UserController profile-picture-from-URL path |
Import a profile picture from a remote URL | 5 MB |
FederatedAvatarImportService (Pattern 1c, Story 12.12) |
One-time Google avatar import on federated sign-in | 5 MB |
All three share the ImageUrlFetcher pipeline (company-user-management-service,
consolidated by the 12.12 code review): single HTTPS GET with Redirect.NEVER (so an
SSRF-validated host cannot 3xx the fetch onto an unvalidated one), content-type
allow-list, and a per-caller size cap. After the fetch, storage goes through the normal
entity-specific service (e.g. ProfilePictureService) and is served from
cdn.batbern.ch β remote URLs are never hotlinked.
Rule of thumb: client holds the bytes β presigned URL, never proxy. Server must
obtain the bytes from a third party β ImageUrlFetcher, never a hand-rolled fetch.
Consequences
Positive
-
β Eliminates Circular Dependency: Files can be uploaded before entities exist
- Logo upload is completely independent of company creation
- User can upload file first, then create company
- Single atomic operation from userβs perspective
-
β Highly Reusable Architecture: One service works for all entity types
- Company logos (immediate)
- User profile pictures (future)
- Event banners (future)
- Partner logos (future)
- Speaker photos (future)
-
β Better User Experience: Single seamless operation
- User provides all data (including logo) in one form
- No confusing multi-step workflow
- Immediate visual feedback (logo preview)
-
β Scalable Design: Direct client-to-S3 uploads
- Backend never proxies file data (5MB files)
- Reduced backend bandwidth costs
- Better performance for large files
- Easy progress tracking on client
-
β Clean Separation of Concerns: Upload logic independent of business entities
- GenericLogoService has no knowledge of Company/User/Event
- Business services (CompanyService) decide when to associate
- Clear state machine (PENDING β CONFIRMED β ASSOCIATED)
-
β Type-Safe State Management: Explicit lifecycle states
- PENDING: Upload initiated, file may not exist in S3 yet
- CONFIRMED: File successfully uploaded to S3 and verified
- ASSOCIATED: Linked to entity, file in final location
-
β Automatic Resource Cleanup: Scheduled job prevents S3 bloat
- PENDING uploads > 24 hours old: Removed (user abandoned upload)
- CONFIRMED uploads > 7 days old: Removed (entity never created)
- ASSOCIATED uploads: Kept indefinitely (in use)
- S3 storage costs minimized
-
β Future-Proof Design: Ready for additional entity types
- Add User profile pictures: No changes to GenericLogoService
- Add Event banners: No changes to GenericLogoService
- Just update entity services to use logoUploadId pattern
Negative
-
β οΈ Increased System Complexity: More moving parts
- New service (GenericLogoService)
- New database table (logos)
- New scheduled job (LogoCleanupService)
- New state machine to understand
- More code to maintain
-
β οΈ Temporary Storage Costs: Orphaned uploads accumulate
- Users may upload files but never create company
- Files sit in S3 temp/ for up to 24 hours before cleanup
- Minimal cost impact (estimate: <$1/month for 1000 orphans)
-
β οΈ Breaking Change: Old endpoints removed
- Cannot deploy backend without frontend update
- Requires coordinated deployment
- No backward compatibility with old clients
-
β οΈ Additional S3 Operations: Copy operation on association
- Extra S3 API call (copy temp β final)
- Adds ~500ms latency to company creation
- Acceptable tradeoff for architectural benefits
-
β οΈ Database Growth: Logos table accumulates records
- One record per upload attempt (including abandoned)
- Cleanup job runs daily (not real-time)
- Estimate: 10-100 GB over 5 years (negligible)
-
β οΈ More Complex Testing: Multiple phases to test
- Unit tests for each phase (generate, confirm, associate)
- Integration tests for full flow
- Test cleanup job behavior
- More test code required
Neutral
-
Migration Required: Frontend and backend must be updated together
- Coordinated deployment necessary
- Rollback requires reverting both frontend and backend
- Not an issue with proper CI/CD
-
Monitoring Needed: Track system health
- Orphaned upload rate (should be low)
- Cleanup job success rate (should be 100%)
- S3 temp/ directory size (should be small)
- CloudWatch alarms recommended
-
Database Records Persist: Logos table grows over time
- Associated logos kept indefinitely (business requirement)
- Soft delete if entity deleted (for audit trail)
- Periodic archive to cold storage (future consideration)
Implementation Status
Completed
- β Architectural design documented (ADR-002)
- β Story created (1.16.3)
- β Database schema designed
In Progress (Story 1.16.3)
- β³ V6 Flyway migration creation
- β³ Logo entity and repository
- β³ GenericLogoService implementation
- β³ LogoCleanupService implementation
- β³ LogoController REST endpoints
- β³ Frontend FileUpload component
- β³ Frontend useFileUpload hook
- β³ CompanyService integration
- β³ Test suite
Planned (Post-Implementation)
- β³ User profile picture support (Story TBD)
- β³ Event banner support (Story TBD)
- β³ CloudWatch dashboards for monitoring
- β³ S3 lifecycle policies for automated archival
Alternatives Considered
Alternative 1: Keep Entity-Specific Endpoints (Status Quo)
Decision: REJECTED
Approach: Continue using POST /companies/{name}/logo/presigned-url and workarounds
Why Rejected:
- Does not solve circular dependency
- Poor user experience (multi-step process)
- Not reusable for other entities (User, Event, etc.)
- Technical debt accumulates
- Same problem will recur for every entity type
Alternative 2: Maintain Backward Compatibility
Decision: REJECTED per user feedback
Approach: Keep old company-specific endpoints alongside new generic endpoints
Why Rejected:
- Adds complexity maintaining two upload flows
- Confuses developers about which flow to use
- Delays deprecation indefinitely
- More test coverage required
- User explicitly requested clean break
Alternative 3: Base64 Inline Upload in Request
Decision: REJECTED
Approach: Send logo as base64 string in CreateCompanyRequest JSON
{
"name": "Swisscom AG",
"website": "https://swisscom.ch",
"logoBase64": "data:image/png;base64,iVBORw0KGgo..."
}
Why Rejected:
- 33% larger payload: 5MB file becomes ~6.6MB base64
- Backend becomes proxy: Must decode and upload to S3 (bandwidth cost)
- Slower uploads: No parallel upload, no progress tracking
- Request size limits: Many API gateways limit request to 6MB
- Poor performance: Single synchronous operation
Alternative 4: Multipart Form Upload
Decision: REJECTED
Approach: Accept logo file directly in POST /companies as multipart/form-data
POST /api/v1/companies
Content-Type: multipart/form-data
--boundary
Content-Disposition: form-data; name="name"
Swisscom AG
--boundary
Content-Disposition: form-data; name="logo"; filename="logo.png"
Content-Type: image/png
[binary data]
--boundary--
Why Rejected:
- Backend proxies files: 5MB files flow through backend (bandwidth cost)
- Loses presigned URL benefits: Direct-to-S3 upload is more scalable
- Harder progress tracking: Cannot use XHR progress events easily
- Synchronous operation: Blocks request until upload completes
- Less flexible: Cannot reuse upload across multiple operations
Alternative 5: Pre-allocation Pattern (Token-Based Upload)
Decision: CONSIDERED but chose Generic Service instead
Approach: Generate temporary upload token, associate after entity creation
1. POST /upload-tokens β {token, presignedUrl}
2. Upload to S3 using presignedUrl
3. POST /companies {name, uploadToken}
Why Not Chosen:
- Similar complexity to generic service
- Less flexible (tokens tied to specific use cases)
- Generic service is more reusable
- Tokens add another concept to understand
- Generic service has clearer semantics (uploadId vs token)
Note: Pre-allocation pattern is a valid alternative and was close runner-up. Generic service was chosen for its broader reusability.
References
-
AWS Documentation:
-
Related Stories:
- Story 1.16.3: Generic File Upload Service (this implementation)
- Story 2.5.1: Company Management Frontend
- Story 1.16.2: Dual-identifier pattern (UUID internal, name public)
-
Related ADRs:
- ADR-001: Invitation-Based User Registration Architecture
-
Architecture Docs:
- 03-data-architecture.md: Database schemas and patterns
- 04-api-design.md: RESTful API patterns
- 06-backend-architecture.md: Spring Boot service patterns
- 05-frontend-architecture.md: React component patterns
Related Files
Backend (Story 1.16.3)
New Files:
services/company-user-management-service/src/main/resources/db/migration/V6__Create_logos_table.sql- Database schemaservices/company-user-management-service/src/main/java/ch/batbern/companyuser/domain/Logo.java- Entityservices/company-user-management-service/src/main/java/ch/batbern/companyuser/domain/LogoStatus.java- Status enumservices/company-user-management-service/src/main/java/ch/batbern/companyuser/repository/LogoRepository.java- Repositoryservices/company-user-management-service/src/main/java/ch/batbern/companyuser/service/GenericLogoService.java- Core serviceservices/company-user-management-service/src/main/java/ch/batbern/companyuser/service/LogoCleanupService.java- Cleanup jobservices/company-user-management-service/src/main/java/ch/batbern/companyuser/controller/LogoController.java- REST API
Modified Files:
services/company-user-management-service/src/main/java/ch/batbern/companyuser/dto/CreateCompanyRequest.java- Add logoUploadIdservices/company-user-management-service/src/main/java/ch/batbern/companyuser/service/CompanyService.java- Logo associationservices/company-user-management-service/src/main/java/ch/batbern/companyuser/controller/CompanyController.java- Remove old endpoints
Deleted Files:
services/company-user-management-service/src/main/java/ch/batbern/companyuser/service/CompanyLogoService.java- Replaced by GenericLogoService
Frontend (Story 1.16.3)
New Files:
web-frontend/src/components/shared/FileUpload/FileUpload.tsx- Reusable upload componentweb-frontend/src/components/shared/FileUpload/FileUpload.test.tsx- Component testsweb-frontend/src/hooks/useFileUpload/useFileUpload.ts- Upload hookweb-frontend/src/hooks/useFileUpload/useFileUpload.test.ts- Hook tests
Modified Files:
web-frontend/src/components/shared/Company/CompanyForm.tsx- Use new FileUpload
Deleted Files:
web-frontend/src/components/shared/Company/LogoUpload.tsx- Replaced by generic FileUploadweb-frontend/src/components/shared/Company/__tests__/LogoUpload.test.tsx- Obsolete tests
Documentation
New Files:
docs/architecture/ADR-002-generic-file-upload-service.md- This documentdocs/stories/1.16.3-generic-file-upload-service.md- Implementation story
Notes
This decision represents a significant architectural improvement from entity-coupled to entity-agnostic file upload architecture.
Key Architectural Benefits
- Decoupling: File uploads completely independent of entity lifecycle
- Reusability: Single service handles all entity types (COMPANY, USER, EVENT, etc.)
- Scalability: Direct client-to-S3 uploads (no backend proxy)
- Maintainability: Clear state machine, explicit lifecycle, automatic cleanup
- User Experience: Single atomic operation from userβs perspective
State Machine Summary
βββββββββββ generatePresignedUrl() βββββββββββββ
β (Start) βββββββββββββββββββββββββββ>β PENDING β
βββββββββββ βββββββ¬ββββββ
β
β confirmUpload()
β
v
βββββββββββββ
β CONFIRMED β
βββββββ¬ββββββ
β
β associateLogoWithEntity()
β
v
βββββββββββββββ
β ASSOCIATED β
β (terminal) β
βββββββββββββββ
Cleanup transitions:
- PENDING > 24h β Deleted
- CONFIRMED > 7 days β Deleted
- ASSOCIATED β Kept indefinitely
S3 Storage Strategy
Temporary Location (PENDING/CONFIRMED):
- Path:
logos/temp/{uploadId}/ - Lifetime: 24 hours (PENDING) or 7 days (CONFIRMED)
- Cleaned up automatically
Final Location (ASSOCIATED):
- Path:
logos/{year}/{entity-type}/{entity-name}/ - Lifetime: Indefinite (business data)
- Served via CloudFront CDN
Success Metrics
Post-implementation monitoring should track:
Performance Metrics:
- Presigned URL generation: < 100ms (P95)
- S3 copy operation: < 2s (P95)
- Cleanup job duration: < 30s for 1000+ logos
Operational Metrics:
- Orphaned upload rate: < 10% of total uploads
- Cleanup job success rate: > 99%
- S3 temp/ directory size: < 1GB at any time
User Experience Metrics:
- Company creation with logo: Single operation success
- Upload progress feedback: Real-time updates
- Error rate: < 1% of upload attempts
The Generic File Upload Service provides a solid, scalable foundation for all file upload needs across the BATbern platform, eliminating circular dependencies while maintaining excellent user experience.