SG-002Mục đích: Thiết kế

Feature-based module — fullstack layout

#typescript#tanstack-start#tanstack-router#nitro#pnpm#nx#monorepo#prisma#zustand
Kiểm mục
  • 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ục

Verify 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ản

A 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

Seam

Each 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.ts inside lib/billing/
  • .server.ts suffix marks server-only modules (TanStack Start / Vite SSR boundary)
  • .fns.ts holds createServerFn exports — the browser-safe entry for loaders and components
  • .types.ts holds 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

Seam

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

FnscreateServerFn 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

Seam

The same feature logic serves multiple transports. Each transport is an adapter — it translates wire format to service calls, never duplicates business rules.

TransportEntry fileCalls
React loaders / components{feature}.fns.tsservice
TanStack Router API routessrc/routes/api/<feature>/…service (direct import)
MCP toolssrc/lib/mcp.server.ts or {feature}.mcp.tsservice (direct import)
Background jobs / cron{feature}.job.server.tsservice

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

Seam

The 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.ts with 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ản

Prisma 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 folderssrc/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.