System Design 02 - Designing APIs, Data Models, and State Transitions
文章目录
Imagine you're building a task management app.
A team creates a project called Website Redesign. They invite a few teammates, create tasks, assign work, and move tasks across a Kanban board.
The board has three columns: To Do, In Progress, and Done.
Someone creates a task called Fix the login bug, assigns it to Marcus, and moves it to In Progress. Later, Marcus finishes the work and moves it to Done.
Everything works as expected.
You have a frontend, a backend, and a database. The frontend sends requests, the backend processes them, and Prisma makes it easy to read and update records.
Then someone reports a strange bug.
A task has been assigned to someone who isn't even a member of that project.
You check the database. The task exists. The project exists. The assigned user exists.
Every record looks valid.
So how did an invalid assignment make it into the system?
The application might already check whether an assignee belongs to the project. But that check only protects the operations that actually perform it. An internal import script, a future endpoint, or another write path could create the same assignment without running that validation.
The database can verify that the project and user exist. It doesn't automatically know that the user must belong to that particular project.
The system can contain perfectly valid records that, together, represent a decision the product should never have allowed.
To understand why, let's look at what we've built and what the data actually means.
1. Start with the model we already have
Our first version needs only a few things: users, projects, and tasks.
A project has a name. A task has a title, belongs to a project, and has a status. A task can also be assigned to a user.
In Prisma, that might look like this:
There's nothing unusual here.
When someone creates a task, the frontend sends a request to the backend. The backend validates the input, creates the task through Prisma, and returns the result.
When someone moves a task to another column, the frontend sends an update, and the backend saves the new status.
The application works because our data model represents the things we need to store.
But look at the task model again.
It knows which project the task belongs to. It knows which user is assigned to it. The relations ensure that the referenced project and user exist.
What it doesn't know is whether the assigned user belongs to that project.
That's the missing relationship.
A user can exist without belonging to every project. A task can belong to one project while its assignee belongs to another. Our current model can represent both facts independently, but it doesn't express the rule connecting them.
This is an important distinction in data modeling.
We're not simply deciding which fields a task needs. We're deciding which relationships must exist for the data to make sense.
2. Represent the relationship the product depends on
A project can have many members, and a user can belong to many projects.
For example, Marcus might work on both Website Redesign and Mobile App. Another teammate might only belong to Website Redesign.
We need to represent those memberships explicitly.
Each ProjectMember record represents one membership.
If Marcus belongs to both projects, we store two membership records. If another teammate belongs only to Website Redesign, we store one.
The composite primary key, @@id([projectId, userId]), prevents the same user from being added to the same project twice.
Now our data model represents three distinct facts:
-
The user exists.
-
The project exists.
-
The user belongs to the project.
That third fact was missing from our original design.
But there's a subtle problem: representing a rule isn't the same as enforcing it.
We've given membership a place in the database, but our task still stores projectId and assigneeId separately. The ordinary foreign keys ensure that both referenced records exist. They don't guarantee that the corresponding membership exists.
We should check membership in the application service before creating or assigning a task. That gives us a chance to return a useful error if someone tries to assign work to a non-member.
But we can also enforce this particular invariant in PostgreSQL with a composite foreign key. The pair (projectId, assigneeId) on a task must match a pair (projectId, userId) in ProjectMember.
Our composite primary key already makes that membership pair unique. The foreign key itself may require a custom database migration, because Prisma's standard relation declarations don't express every possible composite-foreign-key design directly.
With the constraint in place, the database rejects an assignment to a user who doesn't belong to the task's project. A null assigneeId can still represent an unassigned task.
Why protect the rule in two places?
Because the application and database have different responsibilities.
The application validates requests and explains errors to users. The database protects the integrity of stored data, including writes that arrive through paths that don't run the same application validation.
We're not assuming developers will forget basic checks. We're making sure an important rule doesn't depend entirely on every current and future code path remembering to enforce it.
And this decision affects how we design the API.
3. Decide what clients are allowed to ask for
Imagine the frontend creates a task in Website Redesign.
It sends:
The project ID is part of the URL, so the backend knows which project the task belongs to. The request body contains the information the client wants to provide.
The server validates the input, checks the caller's permissions, verifies that the assignee belongs to the project, and creates the task.
Notice what the client doesn't provide.
It doesn't choose the task's ID. It doesn't decide when the task was created. It shouldn't be allowed to pretend that someone else created the task by supplying an arbitrary creator ID.
Those values are controlled by the server.
This is the purpose of an API contract: it defines what a client can request and what behavior the system promises in return.
The API shouldn't expose every database field simply because the field exists.
That becomes especially important when updating a task.
Suppose the frontend sends:
The user only wants to change the title. The assignee and status should remain untouched.
For a partial update, an omitted field normally means the client isn't asking to change it. An explicit null can mean the client wants to clear a field, if the API defines that behavior.
These semantics might seem obvious, but they're part of the contract. If they're ambiguous, one client might accidentally unassign a task simply because it didn't include assigneeId in an update.
Now suppose the client sends:
This request is different. It asks the system to change the task's state.
Should any client be allowed to make that change? Is the transition allowed from the task's current status? Does completing the task require additional work?
The API contract needs to define the supported operation, while the backend enforces the rules behind it.
We now have a clear separation:
The API defines what clients can ask for. The application validates whether the request is allowed. The data model represents the relationships and information the system must preserve.
But status updates introduce another distinction we haven't addressed yet.
4. A valid status isn't necessarily a valid transition
Our schema defines three possible statuses:
This restricts the status to recognized values.
But it doesn't define the workflow.
Suppose a task is currently in To Do. Someone sends a request to move it directly to Done.
The requested value is valid. The transition might not be.
Perhaps the team allows tasks to be completed directly. Perhaps every task must pass through In Progress first. The enum can't tell us which behavior the product expects.
It describes the states that exist, not the rules governing movement between them.
Let's define a workflow for our application:
-
A task can move from To Do to In Progress.
-
A task can move from In Progress to Done.
-
A task can move from Done back to In Progress when work needs to be reopened.
Now the backend has to evaluate a requested change against the task's current state.
If a task is in In Progress and someone marks it Done, the transition is allowed. If the task is already Done, sending the same status again doesn't represent a new transition. If the task is in To Do and the product requires work to start before completion, the backend should reject the request.
The same status value can have different outcomes depending on the current state and the rules of the workflow.
That's why a status field alone isn't enough to define behavior.
We need to make the transitions explicit in our application logic. For more complex workflows, we might represent the transition rules in a dedicated state machine. For a small task application, a clear service function may be sufficient.
The implementation can vary. The important thing is that the rules aren't scattered unpredictably across frontend components and unrelated API handlers.
There's one more detail worth considering.
What if two requests try to change the same task at nearly the same time?
One request reads the task as In Progress. Another request also reads it as In Progress. The first marks it Done, while the second tries to change it based on the earlier state it read.
If the backend checks the state and updates the task as two separate operations, the second request may act on outdated information.
We can protect against that by making the expected current state part of the database update itself:
The update succeeds only if the task is still In Progress when the database executes it. If another request has already changed the status, the update affects zero rows.
This example handles one specific transition. A production implementation would also check permissions and any other business rules, and it would return an appropriate error rather than exposing a generic exception.
The point isn't that every task update needs a complicated concurrency mechanism. It's that the rule governing a transition must remain true when the update actually happens, not merely when the backend first reads the task.
Now our API, model, and transition logic are working together to preserve the intended behavior.
5. Bring the decisions together
Let's return to the original problem.
A task was assigned to someone who wasn't a member of its project.
We discovered that the model represented the project and user independently but didn't adequately represent their membership. We introduced a membership model and protected the assignment rule with application validation and a database constraint.
Then we examined what the API should expose. We defined what clients can request, which fields they can control, and what partial updates mean.
Finally, we looked at task statuses. We distinguished between valid status values and valid transitions, and considered how to enforce those transitions consistently.
Each decision answers a different question.
The API contract answers: What can a client ask the system to do, and what behavior can it rely on?
The data model answers: What information and relationships must the system represent and preserve?
The transition rules answer: Which changes are allowed, and under what conditions?
These decisions are connected. The API exposes operations that depend on the model. The model represents the facts those operations rely on. The transition logic determines how the system is allowed to change, while database constraints and transactions protect the rules they can enforce.
You don't need to design every future feature before writing the first line of code. The original task model was reasonable for the initial requirements.
But as the product grows, you need to recognize when a new requirement changes what the system must represent, what its API must promise, or which state changes it must allow.
That's the difference between a collection of endpoints and tables that happen to work today, and a system whose behavior remains predictable as the product evolves.
Good system design isn't about making every component complicated. It's about making the system's rules explicit, then choosing the simplest design that reliably preserves them.










