Skip to content

refactor: lib/ is adapters only, domain lives in features/ - #366

Merged
pratyush618 merged 50 commits into
mainfrom
refactor/features-over-lib
Sep 22, 2026
Merged

pratyush618 merged 50 commits into
mainfrom
refactor/features-over-lib

Conversation

@kartikeya-27

Copy link
Copy Markdown
Contributor

Closes #361.

lib/ now holds only adapters to things outside the process — Resend, ioredis, the GitHub REST API, package-registry HTTP, process.env. Everything else lives in features/<name>/, server code beside its own UI. apps/admin/src/views/ is gone.

What moved

App Before After
admin 88 files under lib/, 47 under views/ 18 adapter files under lib/, 12 features, no views/
academy pricing.ts, quote.ts, inventory.ts, orders/ in lib/ lib/ is env.ts + site.ts; 5 features, all with barrels
flexiq six cross-feature deep imports, one feature without a front door on the doors
main types.ts/utils.ts at feature roots, export * barrels on the doors

flexiq and main were not in the issue's scope. They came in because the alternative was exempting two apps from rules the other three follow, and an app the rules don't describe is an app the rules don't hold.

The shape

app/ → features/ → lib/ + shared/. One file vocabulary in every feature: model.ts (client-safe types, constants, pure rules) · queries.ts (reads) · store.ts (writes) · actions.ts ("use server") · service.ts (orchestration) · events.ts (realtime channels) · components/ · index.ts.

The server/client boundary is structural rather than remembered, because @byteveda/db drags pg into any bundle that touches it. The root index.ts is the server front door and carries no components; the client-safe doors are model, actions and components; a route reaches its page or layout by that component's own path.

That last rule is load-bearing rather than tidy. A feature barrel re-exporting a page had already dragged the console's entire client-component graph into a webhook route handler, and produced a broadcasts → subscribers → page → broadcasts import cycle. Both are gone.

Enforcement

Rules that only exist in prose last one quarter. CI's verify job now runs pnpm lint and pnpm check:arch:

  • Biome noRestrictedImports owns the aliased spellings — cross-feature reach past a door, lib/ importing a feature, shared/ importing anything of ours.
  • scripts/check-architecture.mjs (Node built-ins only, no new dependency) owns what Biome structurally cannot: the client boundary, the front-door requirement, the route whitelist, model.ts's own client-safety, curated barrels, and the relative-path spelling of every rule above.

48 fixture tests, each verified able to fail. check:arch:test runs before check:arch in CI, so a check that stops checking fails loudly rather than passing silently.

Behaviour changes

Three, all isolated to their own commits, each with a test that was red first:

  1. listBroadcasts had no permission check where all four siblings did — an exported "use server" function is a public endpoint. An operator without broadcasts.send now gets [] instead of every broadcast's subject and body. The console UI is unchanged; it only renders the composer under the same grant.
  2. origin() was defined three times, twice byte-identically. One definition in lib/env.ts. Link output is unchanged — both URL builders already stripped the trailing slash.
  3. The attachment picker validated against the default limit while the server used the configured one. With ADMIN_MAX_ATTACHMENT_BYTES raised, the browser refused files the server would have accepted, while its own hint promised the higher number. The server-resolved limit is now passed to the picker as a required prop.

Everything else on the branch is a path, an import, or a file split along a seam that already existed.

Verification

pnpm build 5/5 · pnpm lint 498 files, 0 errors · pnpm check:arch 319 files, 0 violations · pnpm typecheck 10/10 · pnpm test — admin 26 files/256 tests, academy 3/32, arch fixtures 48/48.

@byteveda/main's Playwright suite fails two /news mobile-layout cases on a React #418 hydration mismatch. This is pre-existing: the same two cases fail identically at 917a99d, the branch point, verified in a clean worktree.

Every commit is individually green on both tools, so the branch bisects.

Worth knowing before merge

  • Issue Restructure: lib/ is adapters only, domain lives in features/ #361's third criterion names "the Resend adapter" as staying in academy's lib/. No such module exists — the Resend client is constructed inline inside orders/service.ts, which is domain orchestration by the repo's own test ("if it knows what an order is, it is not an adapter"). Both it and email/templates.ts moved to features/orders/, leaving lib/ as env.ts and site.ts. The issue text may want an editor's note.
  • One exemption exists, by name, in both tools: apps/academy/src/lib/site.ts value-imports PRICING to put a price in a meta description. Nothing behaviour-preserving removes it; the real fix — site metadata taking the price rather than importing the feature — is written beside the exemption.
  • AGENTS.md is honest about what no tool checks, including a client file reaching @/lib/* and getting undefined from process.env, which is the class of bug fix 3 above corrects. That one cannot be a blanket rule: 15 client components across three apps legitimately import @/lib/{site,docs,highlight,version}.

Follow-ups, deliberately not in this PR

Four dead exports (pruneExpiredSessions, getRevision, listBroadcastAttachments, postsChanged); flexiq's three blog readers carrying three different database-failure policies; academy/features/sample/components/store.tsx colliding with the vocabulary's reserved store. Deleting dead code and reconciling failure policies are decisions of their own, not restructure side effects.

Moves cors/format/log/nav/request into src/shared, and reshapes
lib into GitHub, revalidate, and registries adapters. Prepares
the adapter floor before features/ lands in later tasks.
session.ts splits into model/queries/store/service; the node:crypto and
cookie primitives stay in session.ts so model.ts remains importable from
client components and the edge proxy. nav.ts moves to components/ because
it carries lucide icons and a Permission type.
lib/email/client.ts keeps only the Resend calls: the outbound_messages
insert moves to features/mail/store.ts, and service.ts calls the adapter
and then the store, in that order.
The auth front door no longer reaches the UI barrel, so a webhook handler
that wants refuse() stops pulling the console's client components. Names
follow: session.ts is crypto.ts, and mail's test files say model.
maxFileBytes() reads the environment, so it stays out of the client-safe
model: limits.ts resolves it and checkAttachment takes the ceiling as an
argument, defaulting to what the browser already resolved.
queries.ts declared every inbox type beside its six reads; the types,
the URL builder and the preview rule are now model.ts, which is what the
thread list and the composers import.
Entirely pure, so the feature is model.ts plus a barrel. The editor's SEO
panel is a client file and reaches it through model, not the barrel.
markdown.ts and slug.ts fold into model.ts: both are pure and imported by
the "use client" editor, and model is the only pure door open to it.
registry.ts was kept import-free so the form could render labels without
pulling `pg` into the bundle; that is what a model.ts is, so it is one now.
settings-actions.ts came in from the lib/ root, and the single store.ts
split into the read and the write.
views/members/types.ts was already a model.ts in all but name — the role
shape a dropdown reads, declared so no client component imports the
database package — so it is one now.
The broadcast composer comes out of the subscribers view into the new feature; the subscribers page now composes it through the front door.
The console's list and the public confirm/unsubscribe pages are the same rows and the same consent, so views/subscribers and views/subscription land in one feature.
`catalogue.ts` and `hints.ts` are pure rules and fold into `model.ts`; `collect.ts` orchestrates the registry adapters and the database, which is `service.ts`.
The overview reads from four features at once, so it is a surface rather than a slice of any one of them. It owns no server files, so it has no barrel — the route reaches its page by the page's own path, and `src/views/` is gone.
The three channel names were the one place a `lib/` file named a domain; each moves to the feature that owns it. The name strings are unchanged, so running instances still hear each other.
Routes reach their page by the page's own path, and the composer moves behind the broadcasts client door, which breaks the broadcasts/subscribers cycle.
The dashboard layout's composition moves verbatim into features/overview and the error boundary's UI into src/components; both routes are now re-export shells.
The price list is the feature's model — types, constants, pure rules — so
it takes the vocabulary's name and client code reaches it through `model`.
The catalogue file is the feature's model, so it joins its component under
`features/inventory` and its test keeps covering it as `model.test.ts`.
Made-to-order pricing is the custom feature's model, so it lands beside the
request form as `model.ts` and its test follows it.
Leaves `lib/` holding only the env boundary, the site config and the Resend
adapter, which is what issue #361 asks of it.
A root `index.ts` may not carry components, so the UI gets its own door and
`features/sample`, which is all client, has only that one. Putting `store.tsx`
under `components/` also stops it reading as the vocabulary's server `store.ts`.
Feature internals, lib/ reaching up into features/ and shared/ reaching anywhere are now errors. The exemptions are named per file rather than per app: narrowing a rule to one app would leave a hole, a named file is itself a rule.
Biome keys off file paths; the client boundary, the feature front door and the lawful deep reach key off file contents and directory shape. Fixture tests run each rule against a tree that breaks it.
Naming queries/store/service/events left every other feature-root file a free deep import — auth/allowlist, auth/urls and attachments/limits all walked through. The group now bans everything under a feature and re-opens model, actions and components.
Relative specifiers are resolved to their @/ form, so a cross-feature ../inbox/queries no longer walks past a rule Biome cannot see. Comments are stripped and a from-clause only counts inside an open module statement, so a commented-out import and a SQL template literal stop being imports.
home, hero-fx and playground are UI and nothing else, so each becomes a components/ folder with a client-safe barrel and no front door. blog has real server reads, so posts.ts is its queries.ts behind an index.ts.
flexiq is behind its doors now, so the biome override and the client-door/front-door exemption have nothing left to spare. With the list empty the exempt() mechanism goes too, and its fixture test flips to asserting no app is spared.
The per-app AGENTS.md files are generated by next dev and stay ignored; the root one is the committed guide, so .gitignore negates it.
types.ts and utils.ts were the features' model all along; the barrels were three export * lines carrying components. contribute's URLs now take GITHUB_URL from @byteveda/utils, which is the same string site.githubUrl was built from.
Nothing checked that a model.ts is client-safe, that a barrel is curated, or that a feature stays off the app-wide component barrel — all three are in the docs and all three passed both tools when broken.
Its whole public surface is one component, so the barrel it had was eight dead exports. The model goes beside the component that reads it, the way academy's sample and flexiq's playground keep theirs.
A model could value-import its own ./queries, which imports the driver, and both tools passed. The model and barrel checks also only saw .ts, so a model.tsx was invisible to them.
The off-vocabulary list is six entries across all four apps with features, not five in admin; and three gaps survive the new rules, including the relative spelling of the @/components ban.
Biome's paths entry only sees the aliased spelling, so ../../../components walked past it from inside a feature. Same division as feature-doors: Biome owns the alias, the script resolves the relative form.
A relative reach out of shared/ walked past Biome, and the aliased bare barrels @/lib, @/features and @/components were never in its group either. The script now mirrors the last overrides entry that lacked one.
An exported "use server" function is a URL anyone can post to, and this
one handed back every broadcast's subject and body without asking.
The three copies disagreed on the trailing-slash strip and on the
request fallback. The shared definition keeps the strip, which the link
builders in subscribers/service.ts already applied, so no link changes.
The allowlist and the PyPI source go through lib/env like everything
else, and the picker now checks against the ceiling the server resolved
rather than the default a bundle is stuck with.
Biome could only say it with a paths entry, which is exact-match and so missed @/components/index, and the overrides entry carrying it duplicated the feature-doors glob list. The script now catches every spelling, and the override is gone.
It still listed the relative @/components reach as an open hole two commits after the rule that closed it.
client-door only ever inspected @/features/ specifiers, so a "use client"
file could value-import @byteveda/db directly and pass. Not @/lib/*: the
marketing sites legitimately render @/lib/site client-side, and what that
costs is documented instead.
"The shape" claimed every app for a layout only admin reached. Names the
constants still sitting in the marketing sites' lib/ and the route files
that are not a few lines, as direction of travel rather than description.
submitOrder is the one-free-sample rule and templates.ts builds HTML from
an OrderLine; neither is an adapter, and the exemption that called them
"the Resend adapter" retires with them. Academy's lib/ is env and site.
Its last importer was the (dashboard) layout, which 1a33d08 moved into
features/overview; 2fbc868 then banned the barrel for anything under
features/. Nothing may open it and nothing does.
@vercel

vercel Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
byteveda-site Ready Ready Preview Sep 22, 2026 4:40pm UTC
byteveda-site-academy Ready Ready Preview Sep 22, 2026 4:40pm UTC
byteveda-site-admin Ready Ready Preview Sep 22, 2026 4:40pm UTC
byteveda-site-flexiq Ready Ready Preview Sep 22, 2026 4:40pm UTC

@pratyush618
pratyush618 merged commit 9a472c6 into main Sep 22, 2026
16 checks passed
@pratyush618
pratyush618 deleted the refactor/features-over-lib branch September 22, 2026 16:44

This branch was successfully deployed

4 active deployments
Preview – byteveda-site-admin — 68aee3d4 Deployed Sep 22, 2026 by vercel[bot]
Preview – byteveda-site — 68aee3d4 Deployed Sep 22, 2026 by vercel[bot]
Preview – byteveda-site-flexiq — 68aee3d4 Deployed Sep 22, 2026 by vercel[bot]
Preview – byteveda-site-academy — 68aee3d4 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restructure: lib/ is adapters only, domain lives in features/

2 participants