Backend Boundaries for Frontend Developers
Route-scoped, client-scoped, domain-scoped: a map for the backend decisions frontend developers make every day.
Nick Daniel – En Dash · endash.us
back-end-boundaries
Three tickets from the n–brew backlog
None of these tickets says "architecture." All three ask where the logic should live.
Three buckets
Sorted by why the code exists, not where it runs.
Sort by why it exists
Route-scoped
Exists because this URL exists.
- Lives in
loader,action- Does
- Parses the request, shapes the response
- Dies when
- The screen is deleted
Client-scoped
Exists because this client app exists.
- Lives in
*.server.ts, middleware, a BFF- Does
- Sessions, joining APIs, caching
- Dies when
- The web app is retired
Domain-scoped
Exists because the business exists.
- Lives in
- A package, a service, an API
- Does
- Pricing, inventory, who may refund
- Dies when
- The café closes
Client-scoped means this client app, not the browser. A BFF is client-scoped code on a server.
Three questions sort any line of code
Sort the checkout action
n–brew is a React Router app: a loader runs on the server before render, an action handles the form post. Eleven statements from the checkout action as it ships today. Sort each one. Keys 1 2 3 answer.
Same idea for reads: a loader, x-rayed
Route owns the request and the response: URL, params, headers, status, the shape this screen renders.
Client owns the session and anything every screen in this app shares.
Domain owns the facts: what is on the menu, what this user favorited.
The loader calls the other two buckets. It shouldn't contain them.
The second consumer
One rule: members get 10% off. Choose where it lives, add consumers, then change the rule.
Rule lives in
Consumers
Cost
Where do we check the session?
"Is there a user?" and "may this user do this?" are different questions in different buckets.
Two questions, two buckets, one redirect
Is there a user?
Cookie → session → user. Belongs to this web client. The iOS app has its own.
client ·requireUser, middlewareMay this user refund order 4411?
A business rule. Every consumer needs the same answer.
domain ·orders.refund() checks policyWhere do they go when the answer is no?
/login?next=… or a 403. Up to this screen.
redirect(), status codesNB-421's redirect worked and the query still ran. The check itself was correct. It ran too late.
Where the check runs changes what runs
Six steps. Each one changes where the check runs, or what request comes in, and shows which loaders actually ran.
What actually ran
In defense of "just put it in the route"
It's the right call when
- One consumer: this screen
- No rule a product manager would recognize
- Deleting the screen should delete the code
Five smells, in the order they usually arrive
- A business rule appears
- You want a test without a Request
- A second consumer shows up
- It must run without a request
- It touches two aggregates
One smell: note it in the PR. Two: extract this sprint. Three: you should have already.
Whose codebase is it?
The bucket says what the code is, not which repo or which team it belongs to.
Five homes
The browser
BuysInstant feedback.
CostsAnyone can bypass it.
route (UI)The route file
BuysOne PR.
CostsInvisible to other routes.
routeThis app's server
BuysShared by this app's screens.
CostsOther clients bypass it.
clientA domain package
BuysOne rule. No hop.
CostsOnly this repo can call it.
domainA service someone runs
BuysAny language, any client.
CostsA hop, and another team's roadmap.
domainMove up a rung when the one you're on no longer holds.
When it should leave your codebase
Leave
- Another team owns the rule. Call their API.
- Regulated or audited. One place, one owner.
- Consumers in other languages.
- Its own release cadence or secrets.
Stay
- One team owns the feature end to end.
- One deploy. Web is the only consumer.
- The rule is still changing.
Middle path: a domain package in your repo. Move it out when one of the reasons on the left shows up.
The browser and the BFF apply decisions. Only the domain makes them.
Where should it live?
Describe the logic. Each home gets a verdict and a reason.
What's true about this logic?
The decision card (photo this one)
- Delete the route. Logic gone too? Route-scoped.
- Would a native app need it as written? Sessions or shape: client. A rule: domain.
- Authentication is client. Authorization is domain. The redirect is route.
- Two smells, extract. Rule, test, second consumer, no request, two aggregates.
- Start the domain as a package. Make it a service when a second deploy, language, or team needs it.
- Browser and BFF apply decisions. The domain makes them.
Let the tools ask the three questions
A prompt for whatever assistant you use
Review this route module. For each statement,
name its bucket:
route-scoped exists because this URL exists
client-scoped exists because this web app exists
domain-scoped exists because the business exists
Flag business rules, second consumers, and anything
that needs a Request to test. Propose the split.
Or make it a scan
src/domainimports nothing fromreact-routerornode:http. A lint rule.- A route file over 40 lines, or one that mentions
user.is…, gets a comment on the PR. - n-dx SourceVision, the scanner you already have, or a 40-line script. Use whatever already runs on every PR.
Put it in the route.
Then know when to move it.
Route-scoped near the screen. Client-scoped in this app's server. Domain-scoped in a package or service somebody owns.
Go deeper
Read
- React Router docs: Data Loading, Actions, Middleware
- Sam Newman, Pattern: Backends For Frontends
- Vaughn Vernon, Domain-Driven Design Distilled
- Skelton & Pais, Team Topologies
Take home
- This deck, the demos, and the n–brew repo: endash.us/toolkit
- The decision card as a one-pager
- The import-boundary lint config for
src/domain
Before you go – three quick asks
Say hi
Questions after today, or a route handler you want a second opinion on.
nick@endash.usConnect
LinkedIn and the rest of my links are on the site.
endash.usSlides and demos
The deck, the demos, the n–brew repo, and the recording once it's posted.
endash.us/apps/slides/ijs/back-end-boundaries