iJS New York · devmio New York Week · October 2026

Backend Boundaries for Frontend Developers

Route-scoped, client-scoped, domain-scoped: a map for the backend decisions frontend developers make every day.

endash.us/apps/slides/ijs/
back-end-boundaries

Three tickets from the n–brew backlog

NB-418feature
Add a tip field to checkout.
Where does this mutation live?
NB-421bug
/account/orders redirects when logged out, but the orders query still runs.
Where do we check the session?
NB-430feature
Members get 10% off. "Can we just do it in the checkout action?"
How much logic is too much for a route?

None of these tickets says "architecture." All three ask where the logic should live.

Nick Daniel

Founder, En Dash · Richmond, Virginia

endash.us

Nick Daniel outdoors in an En Dash t-shirt, smiling
01 · The map

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

1
Delete this route. Does the logic still need to exist?No means route-scoped.
route
2
Would a native app need this, exactly as written?Yes, for sessions or payload shape: client. Yes, because it's a rule: domain.
clientdomain
3
The rule changes. How many files change?More than one means domain logic is drifting.
drift

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.

Diagram of consumers, the placement of the pricing rule, and the database

Rule lives in

Consumers

Cost

1copies of the rule
1hops for web checkout
02 · NB-421

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, middleware

May this user refund order 4411?

A business rule. Every consumer needs the same answer.

domain · orders.refund() checks policy

Where do they go when the answer is no?

/login?next=… or a 403. Up to this screen.

route · redirect(), status codes

NB-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.

1 / 6

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.

03 · The other axis

Whose codebase is it?

The bucket says what the code is, not which repo or which team it belongs to.

Five homes

1

The browser

Buys

Instant feedback.

Costs

Anyone can bypass it.

route (UI)
2

The route file

Buys

One PR.

Costs

Invisible to other routes.

route
3

This app's server

Buys

Shared by this app's screens.

Costs

Other clients bypass it.

client
4

A domain package

Buys

One rule. No hop.

Costs

Only this repo can call it.

domain
5

A service someone runs

Buys

Any language, any client.

Costs

A hop, and another team's roadmap.

domain

Move 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)

  1. Delete the route. Logic gone too? Route-scoped.
  2. Would a native app need it as written? Sessions or shape: client. A rule: domain.
  3. Authentication is client. Authorization is domain. The redirect is route.
  4. Two smells, extract. Rule, test, second consumer, no request, two aggregates.
  5. Start the domain as a package. Make it a service when a second deploy, language, or team needs it.
  6. 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/domain imports nothing from react-router or node: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.us

Connect

LinkedIn and the rest of my links are on the site.

endash.us

Slides 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
We ask for your feedback. Please vote now.
00:00 1 / 1

Keyboard shortcuts

→ SpaceNext slide
←Previous slide
Home / EndFirst / last
1 2 3Answer in the sorting demo
NSpeaker notes
EscOverview grid
FFullscreen
TReset timer
PPrint view (PDF)

Speaker notes