1. Attachments as a subresource of Task
If you build attachments “by inspiration,” you almost always end up with an API where the client has to guess: is it a separate resource, part of the task, or some kind of “special file magic”? We need to model attachments as a proper subresource of Task, so that URIs, statuses, and responses are predictable. That saves time for you, for clients, and for future tests that do not like surprises.
We have already separated metadata and content, and storage for content has also appeared. Now it is important to assemble a proper task subresource from these pieces so that the client sees not a random set of endpoints, but a coherent attachment model.
In Task Tracker API, attachments live in the context of a task, so the base path looks like this: /api/v1/tasks/{taskId}/attachments. This immediately tells the client two things. First, an attachment cannot exist without a task (or, at least, it has no meaning in our contract). Second, all not-found scenarios must account for the parent: if the task does not exist, then this is not an “empty list of attachments,” but a legitimate 404 Not Found for the resource that the subresource depends on.
And this is where the mature REST idea shows up: we do not build a “super-endpoint that does everything.” Instead, we build a small set of consistent operations, each of which does one clear thing: list metadata, detail metadata, download content, delete. And together, it all looks like a single subresource lifecycle.
Attachment endpoint map
Before writing code, it is useful to pin down the “map of the territory” once, so that you do not accidentally add GET /attachments/downloadById later (yes, this really happens in the wild, and it is sad). In our project, attachments are a supporting subresource, so the operation set is small but complete.
Below is a compact table of contracts. It matters not as “memorisation,” but as a consistency check: if you changed one endpoint, did you break the overall picture?
| Operation | Endpoint | What we return | Successful status | If taskId is not found | If attachmentId is not found |
|---|---|---|---|---|---|
| List metadata | GET /api/v1/tasks/{taskId}/attachments | JSON: list of AttachmentResponse | 200 OK | 404 Not Found | — |
| Detail metadata | GET /api/v1/tasks/{taskId} /attachments/{attachmentId} | JSON: one AttachmentResponse | 200 OK | 404 Not Found | 404 Not Found |
| Download content | GET /api/v1/tasks/{taskId} /attachments/{attachmentId}/download | binary body (Resource) + headers | 200 OK | 404 Not Found | 404 Not Found |
| Delete attachment | DELETE /api/v1/tasks/{taskId} /attachments/{attachmentId} | empty body | 204 No Content | 404 Not Found | 404 Not Found |
Pay attention to one very subtle point: GET /tasks/{taskId}/attachments for an existing task may return an empty list — and that is normal. An empty collection is not an error. The error is when the parent resource that the collection depends on does not exist. In other words, “the task does not have attachments yet” is 200 and [], while “the task does not exist” is 404 and ProblemDetail.
2. AttachmentController: contract and endpoints
The controller is where we describe the HTTP contract: paths, methods, statuses, and headers. If the controller starts knowing about Path, Files, directories, and “where the uploads folder sits on disk,” it turns into a combine harvester that is hard to test and even harder to maintain. It is useful to make it almost boring. A boring controller is a compliment.
Let us start with the basics: one controller for the subresource, one base @RequestMapping, and then methods that neatly lay out the operations. Even the constructor is “just for show” here — to make it clear that we work through a service.
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
public class AttachmentController {
// The controller does not keep state and does not know about the file system:
// it only accepts an HTTP request and delegates to the service.
private final AttachmentService attachmentService;
public AttachmentController(AttachmentService attachmentService) {
// Inject the service through the constructor — easier to test and the dependency is explicit.
this.attachmentService = attachmentService;
}
}
List: metadata without content
The attachments list is almost always “show the user what is actually attached.” For that, you need a lightweight JSON payload: name, type, size, description, upload time. The file itself is not needed in the list (and honestly, if you return the file in the list, you will not have a list, but a small DDoS against yourself).
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping
public List<AttachmentResponse> list(@PathVariable String taskId) {
// Return only metadata: the list must be "lightweight," without binary content.
return attachmentService.getTaskAttachments(taskId);
}
If the task has no attachments, the client will see []. And that is great: the client does not need to write complicated logic around “is 404 an empty list or a real error?” It sees 200 OK and a predictable JSON array.
Detail: metadata by attachmentId
The detail endpoint is needed when the client opens the attachment card, wants to see the description, size, content type, and so on. This is still JSON, no binary data.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{attachmentId}")
public AttachmentResponse getOne(@PathVariable String taskId,
@PathVariable String attachmentId) {
return attachmentService.getAttachment(taskId, attachmentId);
}
Discipline matters here: if attachmentId does not belong to taskId, we return 404 Not Found, not “well, we did find an attachment by id somewhere in the system.” An attachment is a subresource, and the parent context matters.
Download: content endpoint
Within the overall attachment model, download remains a separate content endpoint: metadata and binary response are not mixed. The controller needs metadata for headers and Resource for the response body.
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{attachmentId}/download")
public ResponseEntity<Resource> download(@PathVariable String taskId,
@PathVariable String attachmentId) {
// 1) First fetch metadata: we need content type, length, and the original file name.
AttachmentResponse meta = attachmentService.getAttachment(taskId, attachmentId);
// 2) Then separately load the binary content (the file itself) from storage.
Resource content = attachmentService.loadAttachmentContent(taskId, attachmentId);
return ResponseEntity.ok()
// The same two mandatory headers: content type and the "download as a file" hint.
.contentType(resolveMediaType(meta.contentType()))
// contentLength is a useful extra hint to the client, if the size is already known.
.contentLength(meta.size())
.header(HttpHeaders.CONTENT_DISPOSITION,
buildDisposition(meta.originalFileName()))
.body(content);
}
The helper methods here are small: one keeps a fallback to application/octet-stream, and the other carefully builds Content-Disposition.
Delete: 204 No Content
Deletion is a write operation, but a successful response does not have to return JSON. On the contrary, good REST style here is simple: delete it, return 204 No Content. No "deleted": true, please. Let the API not talk to the client in a “I really did everything, honest” style.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/{attachmentId}")
public ResponseEntity<Void> delete(@PathVariable String taskId,
@PathVariable String attachmentId) {
attachmentService.delete(taskId, attachmentId);
return ResponseEntity.noContent().build();
}
And notice: the controller does not say a word about how the physical file is deleted. The controller is not interested in that, just as a waiter is not interested in exactly where the pan sits in the kitchen. It should take the order and bring back the result.
3. AttachmentService: tying metadata and storage together
The service layer in our project is where application-level coordination lives: verify that the task exists, find metadata, talk to storage, and apply the correct order of operations during delete. There should be no HTTP details here (headers, statuses), but there should be “scenario awareness.” It is like a director: they do not go on stage, but without them the play falls apart.
Checking that the task exists as part of subresource semantics
Since attachments are a subresource, almost all operations start the same way: make sure the parent (taskId) exists. The simplest way is to have an existsById method on the task repository (or task service) and throw TaskNotFoundException, which already knows how to turn into ProblemDetail through your global handler.
import com.example.tasktracker.domain.exception.TaskNotFoundException;
private void requireTaskExists(String taskId) {
// A subresource without a parent makes no sense: first check that the task exists.
if (!taskRepository.existsById(taskId)) {
// The exception will later become a unified ProblemDetail at the API level.
throw new TaskNotFoundException(taskId);
}
}
It looks boring — and that is great. The more boring the checks are, the less likely you are to spend a day debugging “why are attachments being returned even for tasks that do not exist?”
List: existing task → list of metadata (possibly empty)
Now for the list itself. Note the nuance: if the task exists, we return the attachments list even if it is empty. And that is important: the API layer should not turn “no elements” into an error.
import java.util.List;
public List<AttachmentResponse> getTaskAttachments(String taskId) {
// First confirm that the parent exists — otherwise this is a 404 for the task.
requireTaskExists(taskId);
// Return DTOs, hiding the internal model/storage.
return attachmentRepository.findAllByTaskId(taskId).stream()
.map(attachmentMapper::toResponse)
.toList();
}
If you want to add a deterministic order (for example, by uploadedAt from newest to oldest), you can insert .sorted(...). But even without that, in an in-memory educational project the main thing is a stable contract and a clear structure.
Detail: existing task + existing attachment → metadata
Detail is the same logic, only we fetch one item and carefully throw AttachmentNotFoundException if it is missing.
public AttachmentResponse getAttachment(String taskId, String attachmentId) {
requireTaskExists(taskId);
return attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.map(attachmentMapper::toResponse)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
}
Notice that we search for the attachment specifically “inside the task,” not “in the system in general.” Otherwise, one day you will end up with a situation where the user requested /tasks/A/attachments/X, and you returned an attachment from task B. The client will be happy (temporarily), you will not (for a long time).
Download: metadata found → storage returns Resource → pass it up
For content, we have a storage key inside the metadata. The service only needs to find the metadata and ask storage to “give me the resource.” The controller will decide which headers to set.
import org.springframework.core.io.Resource;
public Resource loadAttachmentContent(String taskId, String attachmentId) {
requireTaskExists(taskId);
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
return attachmentStorage.loadAsResource(meta.getStorageKey());
}
Delete: remove the attachment as one operation (file + metadata)
Deletion is the most “coordination-heavy” part. Our goal is to delete both metadata and the physical file, treating it as one action. Usually it is simpler to delete the file first and then the metadata. Then, if file deletion fails, the metadata remains, and you can at least diagnose the problem (for example, try deleting again).
public void delete(String taskId, String attachmentId) {
// You cannot delete an attachment for a task that does not exist — that is not "nothing happened," it is a 404.
requireTaskExists(taskId);
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
// First delete the physical file: if this fails, the metadata remains for diagnostics.
attachmentStorage.delete(meta.getStorageKey());
// Then delete the metadata from the repository.
attachmentRepository.delete(taskId, attachmentId);
}
If storage throws an exception, your global error handling (which already exists) should return a proper ProblemDetail without the “absolute path on disk” in the message. The client does not need to know that the file was stored in C:\Users\... or /home/.... The client needs to understand: this is a technical server error, and it is not their fault.
4. In-memory AttachmentRepository for metadata
Since the course is intentionally database-free, we store metadata in memory. That is normal and methodologically correct as long as we do not start pretending that this is “almost PostgreSQL.” The repository here is needed to preserve the boundary: the service does not know where the metadata lives, and the controller definitely should not know that it is stored in a Map.
A minimal repository interface for the operations we need in this lecture may look like this:
import java.util.List;
import java.util.Optional;
public interface AttachmentRepository {
// List metadata by task: no attachments is an empty list, not an error.
List<AttachmentMetadata> findAllByTaskId(String taskId);
// Find a specific attachment strictly in the context of a task (taskId + attachmentId).
Optional<AttachmentMetadata> findByTaskIdAndId(String taskId, String attachmentId);
// Delete metadata (the physical file is deleted by storage, not by the repository).
void delete(String taskId, String attachmentId);
}
An implementation can store data as Map<taskId, Map<attachmentId, AttachmentMetadata>>, because that is natural for a “subresource” model. It is not the only option, but it is very transparent: a task contains a “basket” of attachments.
Here is a small fragment of the findAllByTaskId method, which returns a list and does not fail if the task has no attachments (because having no attachments is not an exception).
import java.util.List;
import java.util.Map;
public List<AttachmentMetadata> findAllByTaskId(String taskId) {
// In the in-memory implementation, a missing key = no attachments, so return an empty collection.
Map<String, AttachmentMetadata> byId = storage.get(taskId);
if (byId == null) {
return List.of(); // empty collection, no drama
}
// Return a copy so external code cannot "accidentally" mutate the internal storage.
return List.copyOf(byId.values());
}
And one more important point: this repository stores storageKey inside AttachmentMetadata, but nobody exposes it outside. The mapping to AttachmentResponse should ignore this key, and it is best if your mapper does not even know that it is a “disk key,” but treats it as an internal model field.
5. Response semantics: 200, 404, 204
A strong API differs from a merely “working” one in that the client does not have to guess. This is especially noticeable for attachments, because we have a collection, individual items, binary download, and delete. The mistake here is usually not in code, but in semantics: someone wrote the endpoint, it even “returns something,” but it is awkward to use.
Let us pin down a few “common-sense rules” that make the client’s life easier. For the list endpoint, we always return 200 OK when the task exists. If there are no attachments, that is not an “error,” it is “empty for now.” The client then just renders an empty state and does not go chasing exceptions.
For any endpoint where {taskId} appears in the URI, the absence of the task is a legitimate 404 Not Found. And here it is important not to try to hide that with a 200 OK and a message like "task not found". In our project, we already have global error handling and ProblemDetail, so this behaviour simply becomes part of a unified error contract.
For delete, we use 204 No Content. That is convenient both for the client and for us: no body means nothing to parse. If the client wants to verify that the attachment is gone, they can request the list or metadata again and get 404 there.
Here is what an “empty attachments list” looks like at the JSON level (the list endpoint response):
[]
And here is an example of “deleted successfully” at the HTTP level: there is a status, but no body.
HTTP/1.1 204 No Content
If the attachment or task is not found, your ProblemDetail response remains unified, as it does throughout the rest of the API. The client sees the same code field and can handle it consistently across the entire project — and that is one of the reasons we bothered with a unified error contract back in module 5.
A single flow: from request to file
Sometimes it is useful to step out of the code for a moment and look at the scenario through the client’s eyes. The client does not know about services and repositories. It just sends a request and expects a predictable result. If the API is assembled properly, the client can go through the entire attachment lifecycle with one simple logic: upload → see it in the list → view metadata → download → delete.
Below is a very simplified download scenario diagram. It shows not all the details of Spring MVC, but the application flow inside our project.
sequenceDiagram
participant C as Client
participant AC as AttachmentController
participant AS as AttachmentService
participant AR as AttachmentRepository
participant ST as AttachmentStorage
C->>AC: "GET /tasks/{taskId}/attachments/{attachmentId}/download" %% (8)
AC->>AS: "getAttachment(taskId, attachmentId)" %% (8)
AS->>AR: "findByTaskIdAndId(...)" %% (8)
AR-->>AS: AttachmentMetadata
AS-->>AC: "AttachmentResponse (metadata)"
AC->>AS: "loadAttachmentContent(taskId, attachmentId)" %% (8)
AS->>AR: "findByTaskIdAndId(...)" %% (8)
AR-->>AS: AttachmentMetadata
AS->>ST: "loadAsResource(storageKey)" %% (8)
ST-->>AS: Resource
AS-->>AC: Resource
AC-->>C: "200 OK + headers + binary body" %% (8)
Yes, you can see “two trips to the repository” here. For in-memory storage, that is not a problem at all, and from a readability perspective in a teaching project it is even a plus: the steps are as explicit as possible. If you ever want to optimise, you can make a service method that returns both metadata and the resource in a single call. But in the educational version, it is better to be clear than clever.
6. Typical mistakes with the attachments subresource
Mistake #1: returning 404 when the task has no attachments.
This is a very common confusion between “collection vs item.” No elements in a collection is a normal state, and it is easier for the client to receive 200 and an empty list than to figure out every time whether this is an “error” or “just empty for now.” 404 is appropriate when the parent task or the specific attachment does not exist.
Mistake #2: mixing metadata and content in one endpoint or one DTO.
When one endpoint “sometimes returns JSON and sometimes a file,” it becomes hard for the client to live with: it has to guess from the headers how to parse the response. And when you put byte[] into AttachmentResponse, you turn a normal attachments list into an elephant carrier. Separating /attachments/{id} and /attachments/{id}/download is simple and reliable.
Mistake #3: turning the controller into a file manager.
As soon as Paths.get(...), Files.delete(...), and the logic for “how to build the file name” appear in the controller, you are almost guaranteed to start copying that code in several places later. The controller should describe the HTTP contract and call the service. Everything related to storage should live in storage/infrastructure.
Mistake #4: exposing storageKey or the file path to the outside world.
storageKey is an internal identifier. The client does not need it, and more importantly, it is harmful: it reveals the structure of your storage and can become an accidental client dependency on your internal implementation. The public API should contain only business-friendly values: originalFileName, contentType, size, uploadedAt.
Mistake #5: returning 200 OK and the text "deleted" after a successful delete.
This may seem “friendly,” but in practice it adds noise. For delete operations in a REST API, 204 No Content is usually enough. It is simpler for the client: no body to parse, no “delete response format” to store, and your contract becomes cleaner.
GO TO FULL VERSION