Feature-based module — fullstack layout
- One folder per feature under apps/{project}/src/lib/<feature>/
- Dot-suffix files: {feature}.fns.ts → {feature}.service.server.ts → {feature}.repository.server.ts
- UI and routes import only {feature}.fns.ts (+ {feature}.types.ts for props)
- Service owns business logic — the read seam for all transports
- Routes, server functions, and MCP tools are transport adapters — no duplicated logic
- No ORM client imports in React components or route files
- Co-locate {feature}.*.test.ts next to the layer under test
- Split a new feature module when a vertical slice has its own persistence or API surface
Adoption checklist
Kiểm mụcVerify a feature module follows the fullstack layout before shipping.
- Create folder apps/{project}/src/lib/<feature>/ with dot-suffix files
- Add {feature}.types.ts for shared DTOs and prop shapes
- Implement {feature}.repository.server.ts — ORM queries only, no business rules
- Implement {feature}.service.server.ts — business logic, calls repository
- Expose {feature}.fns.ts — createServerFn wrappers that call service
- Route loaders import {feature}.fns.ts only — page components stay thin
- If MCP tools exist, they import service directly — same logic, different transport
- Add tests at service seam; mock repository in service tests
- Confirm no Prisma/ORM imports in src/routes/ or src/components/
Why feature modules
Văn bảnA feature module is a vertical slice of behaviour — one domain concern with its own persistence, business rules, and transport adapters. It lives under apps/{project}/src/lib/<feature>/ and replaces layer-first folders (services/, repositories/, hooks/ spread across the app).
Split by feature, not by tier. When {billing} needs invoices, payment methods, and webhooks, those files sit together — not scattered across src/services/ and src/repositories/. Locality wins: a bug in billing is found in one folder.
One external seam per feature. Callers (routes, other features, MCP tools) cross {feature}.fns.ts or {feature}.service.server.ts — never reach into repository or ORM details. This matches the deep-module goal: small interface, rich implementation behind it.
When to create a new module: the slice has its own tables/models, its own business invariants, or its own transport surface (dedicated route tree, MCP tool group). Shared utilities (src/lib/icons.tsx, src/stores/) stay outside feature folders — they are cross-cutting, not vertical slices.
Folder layout & naming
SeamEach feature gets one directory:
apps/{project}/src/lib/<feature>/
├── {feature}.types.ts
├── {feature}.fns.ts
├── {feature}.service.server.ts
├── {feature}.repository.server.ts
├── {feature}.service.server.test.ts
└── {feature}.repository.server.test.ts # optional
Naming rules:
- Folder name = feature slug (
billing,catalog,auth) - File prefix matches folder:
billing.fns.tsinsidelib/billing/ .server.tssuffix marks server-only modules (TanStack Start / Vite SSR boundary).fns.tsholdscreateServerFnexports — the browser-safe entry for loaders and components.types.tsholds serializable DTOs shared across layers and React props
Optional siblings inside the folder when the feature grows: {feature}.embedding.server.ts, {feature}.catalog-search.ts, {feature}.mutation.server.ts — still prefixed, still co-located.
UI components for a feature live in src/components/<feature>/ (presentation only). They import {feature}.fns.ts or receive loader data — never service or repository.
Layer responsibilities
SeamThree layers, strict dependency direction — each layer only imports from the layer below:
{feature}.fns.ts ← transport adapter (server functions)
↓
{feature}.service.server.ts ← business logic (the read seam)
↓
{feature}.repository.server.ts ← persistence (ORM queries)
Repository — thin ORM wrapper. Accepts typed filters, returns typed rows/DTOs. No business rules, no HTTP concerns, no auth checks beyond row-level filters passed in.
Service — owns invariants, orchestration, mapping repository rows to domain DTOs, error shaping. This is where tests focus: mock the repository, exercise behaviour through the service interface.
Fns — createServerFn wrappers. Validate input (Zod), call service, return serializable results. One fn per use-case the UI needs (search{Feature}Fn, get{Feature}Fn). Keep them thin — if logic appears here, move it to service.
Dependency injection: service receives repository via import (same process) or constructor param (when testing). Avoid new PrismaClient() inside service — import a shared singleton from src/lib/db.server.ts or inject through repository.
Transport adapters
SeamThe same feature logic serves multiple transports. Each transport is an adapter — it translates wire format to service calls, never duplicates business rules.
| Transport | Entry file | Calls |
|---|---|---|
| React loaders / components | {feature}.fns.ts | service |
| TanStack Router API routes | src/routes/api/<feature>/… | service (direct import) |
| MCP tools | src/lib/mcp.server.ts or {feature}.mcp.ts | service (direct import) |
| Background jobs / cron | {feature}.job.server.ts | service |
Route boundary pattern:
// src/routes/{route}.tsx — loader only
export const Route = createFileRoute('/{route}')({
loader: ({ search }) => search{Feature}Fn({ data: { q: search.q } }),
component: {Feature}Page,
})
MCP pattern: register tools in {feature}.mcp.ts; handler imports service functions in-process — no HTTP hop to your own API.
Rule: if two transports need the same operation, it lives in service once. Adapters differ only in input parsing and response wrapping.
Test surface
SeamThe service interface is the primary test surface — same principle as deep modules in /arch.
Service tests ({feature}.service.server.test.ts):
- Mock
{feature}.repository.server.tswith vi.mock or injected fake - Exercise business rules, error paths, edge cases
- No React, no HTTP, no database required
Repository tests (optional, integration):
- Use in-memory stand-in (PGLite, test DB) when query correctness matters
- Keep narrow — one test per non-trivial query shape
Fns tests (light):
- Validate Zod schemas and that fn delegates to service
- Do not re-test business logic already covered at service layer
Route/MCP tests:
- Smoke-test wiring (tool registered, loader returns shape)
- Behaviour belongs in service tests
Co-location: test files sit next to the module they test — never a top-level __tests__/ mirror tree.
Anti-patterns
Văn bảnPrisma in components or routes — ORM leaks past the repository seam. Fix: add a repository method, call through service → fns.
Fat fns with business logic — server functions become untestable god-functions. Fix: move logic to service; fn stays a one-liner delegate.
Cross-feature repository imports — {billing} reaching into {catalog} repository. Fix: call {catalog}.service.server.ts or extract shared logic to a neutral src/lib/shared/ utility (not another feature's repository).
Layer-first folders — src/services/billing.ts + src/repositories/billing.ts separated. Fix: merge into src/lib/billing/.
Duplicated logic across MCP and REST — two copies of the same validation. Fix: single service function, two adapters.
Giant feature folder — 20+ files with unrelated concerns mixed in. Fix: split sub-features (billing/invoices/, billing/payments/) each with their own dot-suffix stack, or extract when invariants diverge.
Thứ tự đọc · 7