The lie I told myself for two years
I called myself a full-stack developer. I could build a React frontend and a Node.js API. But for a long time, I was really just writing the same code twice — once on each side of the network boundary — without a coherent model for how the pieces connected.
The bugs I struggled with were almost always at the seams: the place where frontend assumptions about data shape met the actual shape coming from the API. The place where I changed a database schema but forgot which API response it touched. The place where a loading state existed on one path but not another.
The mental model that fixed this is simple, but it took me embarrassingly long to find it.
Think in layers, not in files
A full-stack application is a pipeline. Data starts in a database with one shape, passes through several transformation layers, and ends up in a UI component with a completely different shape. Each layer is responsible for a specific transformation.
When you think about your app this way, a few things become immediately clear:
- Each layer has a contract with the layer above and below it. The ORM produces typed objects from the database. The repository transforms those into domain objects your business logic understands. The API serializes domain objects into JSON. The client cache deserializes JSON back into typed objects. Any mismatch in these contracts is a bug.
- Types should flow in one direction. Your database schema drives your ORM types. Your ORM types drive your domain objects. Your domain objects drive your API response types. Your API response types drive your frontend types. If your frontend type is drifting from the API response type, something is wrong.
- State lives in exactly one place. The database is the source of truth. The React Query cache is a projection of that truth. Your component state is a projection of the cache. When they diverge, you have a stale cache problem — not a state management problem.
The actual request lifecycle
Let me trace a real request so this isn't abstract. User navigates to their dashboard:
Notice a few things about this flow:
The CDN serves the static HTML and JS bundle. This is not your server's job and should happen before your server ever gets involved. If your server is involved in serving JS files, you're paying for latency you don't need to pay.
The data fetch is a separate request — typed end-to-end if you're using tRPC, loosely coupled if you're using REST. The server does the DB query, transforms the result, and returns JSON. The client doesn't know how the data was fetched, and it doesn't need to.
React Query caches the response. Next time the user visits this page (or if another component on the same page requests the same data), it comes from the cache. The cache-invalidation question becomes: when do I tell React Query this data is stale? Usually the answer is: after a mutation succeeds.
Where bugs actually live
Here's what I've noticed after debugging hundreds of full-stack issues:
Most bugs are data shape mismatches between layers. Your API returns user.name, but your frontend expects user.fullName. Your database has a created_attimestamp as a string, but your frontend treats it as a Date object. Your API returns an empty array, but your component expects null for the empty state.
TypeScript catches a lot of these, but only if your types flow end-to-end. A hand-written frontend type that doesn't come from your API schema is a lie — it will drift. Use code generation (tRPC, OpenAPI codegen, GraphQL codegen) to keep types synchronized.
The practical rules I follow now
One source of truth per data concept. If user.role is checked in the API handler, the middleware, and the frontend component, you have three places to update when roles change. Put it in the API, derive everywhere else.
Don't put business logic in API handlers. An API handler should validate input, call a repository method, and serialize the response. If there's business logic in the handler, it can't be reused by other handlers, can't be easily tested, and tends to grow into a monster. Repository layer is where logic lives.
Handle all three states: loading, error, data. Every data fetch has three possible outcomes. Components that only handle the "data" case will crash or show nothing in the other two. Handling loading and error states isn't optional — it's the majority of the user experience for slow connections.
Invalidate aggressively after mutations. It's better to refetch data you didn't need to than to show stale data. The cost of an extra network request is almost always lower than the cost of a user trusting a number that's wrong.
The shift that changed things
The shift isn't technical — it's conceptual. Once I started thinking about my app as a data pipeline with clear layer responsibilities, I stopped asking "where should I put this code?" and started asking "at which layer does this transformation belong?" Those are different questions, and the second one has a much more obvious answer.
The bugs didn't disappear, but they got easier to find. When something goes wrong, you trace the data from the layer where it breaks back toward the source. Most of the time you'll find a contract violation somewhere in the middle.