1. Server-managed fields
When the API already knows how to create and return tasks, it is tempting to think: “Well, if the client sends JSON, let it send whatever it wants, and we will sort it out later.” In practice, that ends with the server gradually losing control over its own state. To prevent that, we introduce a simple idea: some resource fields are server-managed, meaning they are set by the server, not by the client.
After narrow request DTOs and explicit mapping, the next question becomes very practical: which fields does the client have the right to write at all, and which fields must the server keep under its control?
Server-managed fields are not necessarily “secret” fields. In fact, the server can perfectly well return them in the response, and the client will see them. But seeing and controlling are different things. Classic examples of server-managed fields in our Task Tracker API are id, the initial status, as well as system timestamps such as createdAt and updatedAt. Even if it is useful for the client to see status in the response, that does not mean it should be allowed to send it during creation and say: “And let us make it DONE right away.”
If we use an everyday analogy, a request DTO is like a form saying “what you are asking us to do,” while server-managed fields are like “what the system fills in on its own.” You can ask for a passport to be issued to you, but you cannot write your own passport number and issue date directly into the application. Of course, you can try. But that is exactly why we build a proper contract — so that an attempt does not turn into a successful change in system state.
2. Mass assignment and over-posting
Mass assignment (often discussed together with over-posting) sounds like a term from the world of “scary security people,” but in reality it is a very ordinary problem. It starts with a simple thing: Spring MVC reads the request body through HttpMessageConverter, and in our JSON baseline that means Jackson deserializes JSON into a Java object. After that, Jackson does exactly what it was told to do: it sees a field and tries to put the value into the object. And if you gave it a “wide” model, it will honestly fill it in.
To see the problem, a minimal example of an internal model is enough. Suppose we have Task in the domain layer. It may contain fields that the server wants to control itself:
package com.example.tasktracker.domain.model;
import java.time.Instant;
public class Task {
// These fields exist in the domain model, but must not come from the client in create/update
private String id; // server-managed
private TaskStatus status; // server-managed
private Instant createdAt; // server-managed
// Important: public setters (or open fields) make them "writable" for Jackson
// getters/setters
}
Now imagine a “naive” controller that accepts the internal model directly:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.domain.model.Task;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
@PostMapping
public Task create(@RequestBody Task task) {
// Dangerous: we have already allowed Jackson to populate the object with JSON data
// At this point id/status/createdAt can be "slipped in" by the client
return task; // "let it be for now, as long as it works"
}
}
Yes, this endpoint “works.” But now the contract says: “Client, send me the entire Task, and I will return the entire Task.” In other words, the client has effectively been given the right to fill all writable fields of the Task object — everything Jackson can set.
For example, the client may send JSON like this:
{
"id": "i-made-this-up",
"status": "DONE",
"createdAt": "1999-12-31T23:59:59Z"
}
And if your internal model allows these fields to be set (through setters or accessible fields), they will end up in the object even before you reach the service logic. At that point, the server is no longer “choosing” what to accept: you asked Spring/Jackson to build the object from the input data, and it obediently did exactly that.
It is important to feel the difference here: the bug is not in Jackson and not in Spring. This is pure contract logic. If you accept too wide a model, you are expanding the writable surface yourself. The client does not have to be malicious — an “overly smart” frontend or a tester with Postman who decides to “send everything just in case” is enough.
Below is a simplified diagram of what this looks like in a real request:
flowchart TD
A[Client JSON] --> B["@RequestBody + Jackson"]
B --> C[Task object]
C --> D[Service]
D --> E[Repository]
If the Task object is already filled with id/status/createdAt from the request, then the service must either re-check and rewrite them later, or (which often happens) “save as is.” And now you have built an API where the server naively trusts the client.
3. Over-posting and the “friendly frontend”
When people hear about mass assignment, many immediately imagine a “malicious attacker.” But real life is usually less dramatic and more annoying. A very common source of over-posting is a perfectly normal frontend developer who wants to make life easier. They do this: “Here I have a task edit form, I keep the form state as a task object, and I will send that object back to the server.”
If the server accepts the internal model or an overly broad request DTO, the frontend really can send more fields than necessary. And it will do that not because it wants to hack you, but because it is easier that way. The problem is that life became easier for the frontend, while the server will pay the price: data will start living in strange states, and then you will be debugging bugs like “why did createdAt suddenly change?” or “why did the status of a newly created task end up as DONE?”
That is why over-posting is not only about security, but also about data integrity. Even in our educational project without authorization, it is important to keep the server as the “source of truth.” Today it is a training in-memory repository, and tomorrow (in real work) it is a database, integrations, audit logs, reports, analytics. And then “the client accidentally overwrote a system field” becomes a very expensive joke.
Another non-obvious point: over-posting destroys the agreement about how the API evolves. If the client can write any fields, you no longer control the “official” creation scenario. The service stops being the author of behavior. And the API turns into a “thin wrapper over a JSON warehouse,” where anything can be dumped in and we just hope it somehow fits together.
4. Writable surface and narrow request DTOs
To stop this story, we need a very practical term: writable surface. This is “the set of fields the client can change through the API.” Not “the fields that exist in the system,” not “the fields in the Task object,” but specifically “the fields the client can send and the server will accept as a state change.” The wider the writable surface, the greater the chance that the client will accidentally (or intentionally) break your invariants.
This is where DTOs give us iron discipline. We make the request DTO narrow, meaning we include only the fields that the client truly provides in this operation. For creating a task, that is usually title, description, assigneeName (and possibly tags). But id, status, createdAt, updatedAt are server-managed fields; they must not come in the input.
For clarity, it is useful to keep a table in front of you. It does not claim to be the final model for the whole system, but it shows the principle perfectly: “visible in the response” does not equal “allowed in the request”:
| Task field | Internal Task model | TaskCreateRequest (input) | TaskDetailsResponse (output) |
|---|---|---|---|
| id | yes | no | yes |
| title | yes | yes | yes |
| description | yes | yes | yes |
| assigneeName | yes | yes | yes |
| status | yes | no | yes |
| createdAt | yes | no | (can be, but does not have to be) |
| updatedAt | yes | no | (can be, but does not have to be) |
Important idea: a DTO is not the “application data model,” it is the model of a specific operation. That is why TaskCreateRequest does not have to know everything that exists in a task. It only has to know what the user enters when creating it. This makes the contract clear and does not give the client extra leverage.
Example of a narrow request DTO for creation:
package com.example.tasktracker.api.dto.request;
public class TaskCreateRequest {
// Only what the client really enters during creation
private String title;
private String description;
private String assigneeName;
// No id/status/createdAt on purpose: these are server-managed and not part of the input contract
public String getTitle() { return title; }
public String getDescription() { return description; }
public String getAssigneeName() { return assigneeName; }
}
Yes, it looks “modest.” But that is exactly the point: the fewer opportunities the client has, the fewer unexpected states we get. If a field is not in the request DTO, it is not part of the input contract. And the server can live calmly with the thought, “I am responsible for id, status, and system fields.”
5. Creating Task without field substitution
Now let us put this together into a practical flow inside our project. The route is the same: TaskCreateRequest first becomes a domain draft, and then the service turns it into a real Task and assigns the server-managed fields. It is important that these decisions are made where they belong: not in the DTO and not in the controller, but in the service layer, because that is what controls entity creation as an operation.
Let us start with a simple status enum. We use a fixed set, so the client must not control it during create:
package com.example.tasktracker.domain.model;
public enum TaskStatus {
// The client does not choose the initial status on create: the server sets it itself
TODO, IN_PROGRESS, BLOCKED, DONE, ARCHIVED
}
To avoid getting lost in fields, let us keep only those where the boundary between client-managed and server-managed values is visible.
Next, let us show a fragment of the domain model. In a real project there are more fields, but now the idea matters most: id/status/createdAt/updatedAt exist inside and are set by the server:
package com.example.tasktracker.domain.model;
import java.time.Instant;
public class Task {
// server-managed fields (assigned in the service)
private String id;
private TaskStatus status;
private Instant createdAt;
// client-managed fields (come from the request DTO through the mapper)
private String title;
private String description;
private String assigneeName;
// getters/setters/constructors
}
Now the service. It takes the domain draft and assigns server-managed values itself:
package com.example.tasktracker.domain.service;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
import java.time.Instant;
import java.util.UUID;
public class TaskService {
public Task create(Task draft) {
Task task = new Task();
// Server-managed values: generate and assign on the server side
task.setId(UUID.randomUUID().toString());
task.setStatus(TaskStatus.TODO);
task.setCreatedAt(Instant.now());
// Values from the client: take them from the domain draft
task.setTitle(draft.getTitle());
task.setDescription(draft.getDescription());
task.setAssigneeName(draft.getAssigneeName());
return task;
}
}
Notice an important psychological detail: the client cannot even theoretically “slip in” id and status as part of the create contract. They do not appear in draft from the request DTO, and the service still assigns these fields itself. This is not “hope for discipline,” but a code-level contract.
Next comes the controller. It accepts TaskCreateRequest (a narrow request DTO), builds a domain draft from it, and passes it to the service. On the way out, we return a response DTO:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.mapper.TaskMapper;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
@PostMapping
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
Task draft = taskMapper.toDraftTask(request);
Task task = taskService.create(draft);
return taskMapper.toDetailsResponse(task);
}
private final TaskService taskService;
private final TaskMapper taskMapper;
public TaskController(TaskService taskService, TaskMapper taskMapper) {
this.taskService = taskService;
this.taskMapper = taskMapper;
}
}
This is where the benefit of a narrow request DTO becomes obvious: in draft there is simply nowhere for a foreign id or status to come from. And if the service assigns system fields itself anyway, then the server remains the owner of its state, rather than “politely accepting whatever the client sent.”
And finally, the mapper that builds the outward response. Here it is important not to get confused: the client may see id and status, but must not write them in the input.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.model.Task;
public class TaskMapper {
public TaskDetailsResponse toDetailsResponse(Task task) {
// The mapper just transfers the needed fields into the response format
// There must be no id generation or status assignment here: that is the service layer's job
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus()
);
}
}
Here we can safely return id and status to the client, because they are useful as part of the “read model.” But we do not accept them in the input. That is the main discipline of server-managed fields: the server shows them, but does not give away control.
6. Where to set server-managed fields
When you first encounter server-managed fields, it is easy to mix up the layers. You may want to do it “quickly”: generate a UUID in the controller, set the status directly in the mapper, and update the timestamp somewhere in the repository. Technically, all of that is possible, but you will very quickly end up with a project where it is impossible to explain who is responsible for what.
When the route request DTO -> mapper -> draft -> service -> response DTO is already in place, the place for decisions becomes much clearer.
In our educational scaffold, the idea is simple: the controller is the place where we speak HTTP and DTO; the service is the place where the domain model state is created and changed; the repository is the place where the state is stored and retrieved. If id is part of the task creation lifecycle, then it naturally appears in the service. If the initial status is a creation rule, that is also the service layer’s responsibility. If createdAt is the system trace of the “task created” event, then it also makes sense to keep it in the service, because that is where creation actually happens.
The mapper, meanwhile, should remain as “dumb” as possible (in the good sense): it is responsible for the shape of the data, not for making decisions. When a mapper starts generating UUID or choosing a status, it turns into a hidden service with a bad name. This is the kind of case where the code compiles, but explaining to a beginner why the status is assigned in the mapper will be difficult. And if it is hard to explain, it is almost certainly hard to maintain.
It is convenient to reinforce this with a small diagram showing where server-managed fields appear:
flowchart TD
A[TaskCreateRequest] --> B[Controller]
B --> C[TaskMapper.toDraftTask]
C --> D[Task draft]
D --> E["TaskService.create(draft)"]
E --> F[Task with id/status/timestamps]
F --> G[Repository.save]
F --> H[TaskMapper.toDetailsResponse]
H --> I[TaskDetailsResponse]
If you keep this diagram in mind, decisions become almost automatic. The input model is narrow, the mapper assembles a domain draft, the service assigns what belongs to the server, and outward we return what we promised in the response. And the client has no accidental way to “convince” the server to accept extra data.
7. Typical mistakes with server-managed fields
In this topic, the mistakes are especially tricky because on a small project “everything works,” and the problem appears later when new fields and scenarios show up. So it is useful to know in advance where the contract is usually broken, even if it now seems “too strict for an educational project.”
Mistake #1: accepting the internal model in @RequestBody because “it is less code.”
There really is less code... right up until a new field appears in the internal model. After that, the API suddenly starts accepting and storing that field, even if you never planned to make it part of the contract. Clients (and testers) quickly get used to the “unofficial option,” and you end up with a contract that lives on its own and does not follow the design.
Mistake #2: adding server-managed fields to the request DTO “for the future” or “to make the frontend easier.”
People often do this with id and status: it seems that it will be easier for the frontend to “send everything as one object.” But that is exactly what expands the writable surface. Short-term convenience for the frontend turns into the server losing the right to make the final decision. And if the server is not the boss, then who is — the browser?
Mistake #3: generating UUID and assigning status directly in the controller.
Technically, this will work, but over time you turn the controller into a decision-making place. And the controller should be an entry/exit point for the contract, not a factory for domain objects. As soon as there are different creation variants, different default values, or different logging formats, the controller will bloat and become hard to read.
Mistake #4: assuming that “if a field is returned in the response, then it can be accepted in the request.”
This is one of the most common logical traps. id almost always appears in the response, but that does not mean the client can dictate it during creation. status is useful for the client to see, but that does not mean it can set it however it wants. APIs often have many read-only fields, and that is perfectly normal.
Mistake #5: hiding server-managed field decisions inside mappers because “the mapper copies fields anyway.”
A mapper should copy and transform the shape of data, but it should not be the place where entities are born and rules are assigned. If the mapper generates a UUID and sets the status, then business logic appears in a layer that, by name, looks like “just a transformer.” That almost certainly leads to confusion: where do you look when the status turns out wrong?
GO TO FULL VERSION