Feature-Oriented Architecture and Dependency Boundaries
Feature-oriented architecture in a Next.js application means arranging code around product capabilities, such as checkout, courses, or admin-users, instead of around technical layers such as components, hooks, and services. Dependency boundaries are the rules that keep one feature from reaching through another feature’s private implementation. The outcome is practical: a developer can change a course enrollment workflow without accidentally coupling it to billing internals, and the App Router can still render server and client code from the right places.
In this course, the topic matters because full-stack Next.js concentrates routing, rendering, data loading, mutations, caching, and UI composition in one repository. Without boundaries, a small import from a Client Component into a database module can produce bundle leaks or build failures. Without feature ownership, shared folders become dumping grounds. This lesson shows how to draw boundaries that match the way Next.js actually executes code.
Purpose and Outcome
A feature-oriented folder should answer three questions quickly: what route exposes the feature, what domain operations belong to it, and what API is safe for other features to call? A dependency boundary answers a fourth question: which imports are forbidden even if TypeScript can resolve them?
The goal is not to create many folders. The goal is to make change local. A feature can contain server actions, route handlers, validation schemas, UI components, tests, and persistence adapters when those files change for the same business reason. Cross-feature access then flows through a small public module such as features/courses/index.ts, not through private files like features/courses/data/queries.ts.
How the Mechanism Works
Next.js App Router starts from the app directory. Route segments define layouts, pages, loading UI, error UI, route handlers, and metadata. A feature architecture usually keeps app thin: route files import orchestration functions and UI from features. The feature owns the domain vocabulary, while the route owns the URL and segment-level rendering behavior.
React Server Components make boundaries more than style. Files are Server Components by default. A file with "use client" starts a client graph: everything imported by that file must be safe to ship to the browser. A file with "server-only" marks a module as server-only and causes a build-time error if it enters a client graph. Server Actions add another edge: a function marked with "use server" can be called from a form or client transition, but its implementation runs on the server and must validate identity, input, and authorization there.
A useful dependency direction is: app may import feature public APIs; feature public APIs may import their own internal files and shared primitives; feature internals may import infrastructure adapters; shared code must not import features. That direction prevents circular product dependencies and keeps reusable primitives genuinely reusable.
Folder Anatomy
One common shape is shown below. The exact names are less important than the dependency rule they express.
src/
app/
courses/[slug]/page.tsx
dashboard/enrollments/page.tsx
features/
courses/
index.ts
ui/CourseSummary.tsx
data/get-course.ts
model/course.ts
enrollments/
index.ts
actions/enroll.ts
ui/EnrollButton.tsx
data/create-enrollment.ts
shared/
db.ts
auth.ts
ui/Button.tsx
Expected behavior: route files can import from features/courses or features/enrollments. They should not import from features/courses/data/get-course. The enrollments feature should call a public course query only if it truly needs course data; it should not open the course feature’s database files directly.
Example 1: Thin Route, Public Feature API
The first example keeps the route segment focused on URL-level rendering. It calls a public function and renders a public component from the course feature.
// src/app/courses/[slug]/page.tsx
import { CourseSummary, getCourseBySlug } from "@/features/courses";
export default async function CoursePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const course = await getCourseBySlug(slug);
return <CourseSummary course={course} />;
}
The route does not know whether courses come from a database, CMS, mock file, or cached fetch. That decision stays inside the course feature. If getCourseBySlug cannot find a course, it can call notFound() or return a typed result, but the route should not bypass the public API to inspect storage details.
Example 2: Server-Only Feature Internals
The next example makes the server boundary explicit. The public feature module exports a server query, while the private data file prevents accidental client imports.
// src/features/courses/data/get-course.ts
import "server-only";
import { db } from "@/shared/db";
export async function getCourseBySlug(slug: string) {
const course = await db.course.findUnique({ where: { slug } });
if (!course) throw new Error(`course not found: ${slug}`);
return course;
}
// src/features/courses/index.ts
export { getCourseBySlug } from "./data/get-course";
export { CourseSummary } from "./ui/CourseSummary";
export type { Course } from "./model/course";
Expected behavior: a Server Component can import getCourseBySlug. A Client Component that imports it directly or indirectly should fail during build because server-only is in the dependency graph. That failure is useful; it catches a data leak before browser JavaScript is produced.
Example 3: Client UI Calling a Server Action
Client Components can still participate in a feature when they depend on a server-safe public action instead of importing persistence code. Here the interactive button remains small and the mutation lives in the enrollment feature.
// src/features/enrollments/actions/enroll.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireUser } from "@/shared/auth";
import { db } from "@/shared/db";
const EnrollInput = z.object({ courseId: z.string().min(1) });
export async function enrollInCourse(input: unknown) {
const user = await requireUser();
const { courseId } = EnrollInput.parse(input);
const enrollment = await db.enrollment.upsert({
where: { userId_courseId: { userId: user.id, courseId } },
update: {},
create: { userId: user.id, courseId },
});
revalidatePath("/dashboard/enrollments");
return { enrollmentId: enrollment.id };
}
// src/features/enrollments/ui/EnrollButton.tsx
"use client";
import { useTransition } from "react";
import { enrollInCourse } from "../actions/enroll";
export function EnrollButton({ courseId }: { courseId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await enrollInCourse({ courseId });
console.log(result.enrollmentId);
})
}
>
{pending ? "Enrolling..." : "Enroll"}
</button>
);
}
Expected behavior: clicking the button disables it while the transition is pending. The server action authenticates the user, validates the course id, creates or reuses the enrollment, revalidates the dashboard path, and returns a deterministic shape: an object with enrollmentId. The upsert expresses the business invariant that a user should not get duplicate enrollments for one course.
Example 4: Enforcing Imports with Tooling
Conventions drift unless a tool checks them. TypeScript path aliases make imports readable, but they do not enforce architecture. A rule engine such as ESLint import restrictions or dependency-cruiser can reject private cross-feature imports in CI.
// dependency-cruiser.config.cjs
module.exports = {
forbidden: [
{
name: "no-feature-private-imports",
comment: "Import another feature only through its index.ts public API.",
severity: "error",
from: { path: "^src/features/([^/]+)/" },
to: {
path: "^src/features/([^/]+)/(?!index\\.ts$)",
pathNot: "^src/features/$1/",
},
},
],
};
Expected behavior: src/features/enrollments/actions/enroll.ts importing @/features/courses/data/get-course should fail the dependency check. Importing @/features/courses should pass, assuming the public API exports the operation intentionally.
Design Choices and Trade-Offs
Feature folders improve locality, but they can hide duplication if every feature creates its own slightly different button, date formatter, or authorization helper. Promote code to shared only after at least two features need the same stable primitive. Shared code should be boring: UI atoms, auth helpers, database clients, formatting, and test builders. Product decisions usually stay inside features.
A strict public API creates friction. That friction is valuable when it forces an explicit dependency, but wasteful when tiny teams spend more time moving exports than delivering behavior. Start with the boundaries that protect server-only code, data access, and cross-feature privacy. Tighten the rest when the repository has enough size for the rule to pay rent.
Barrel files such as index.ts make imports clean, but careless barrels can pull server-only modules into client graphs. Split public APIs when necessary: features/courses/server.ts for server-only exports, features/courses/client.ts for client-safe UI, and features/courses/index.ts only for exports safe in both environments.
Failure Modes and Troubleshooting
Symptom: the build fails with an error mentioning a server-only module imported from a Client Component. Cause: a client file imported a barrel that re-exported database or filesystem code. Diagnose: inspect the import chain from the client file to the feature index and look for server-only. Correct: split server and client exports, then make the Client Component receive data through props or call a server action.
Symptom: changing one feature unexpectedly breaks another feature’s tests. Cause: the second feature imported private implementation details instead of the public API. Diagnose: run the dependency rule and search for imports containing another feature’s internal folder names. Correct: move the needed capability into the owning feature’s public API, or extract a neutral primitive to shared if neither feature should own it.
Symptom: enrollment clicks create duplicate rows during fast repeated submissions. Cause: the boundary was organized by UI files, but the data invariant was not enforced inside the server operation. Diagnose: inspect the database constraint and run two concurrent calls to the action. Correct: add a unique key such as userId_courseId and use an idempotent operation like upsert.
Security, Performance, and Reliability
Security improves when feature boundaries align with server boundaries. Keep secrets, database clients, filesystem access, and privileged SDKs behind server-only files. Validate in the server action or route handler even if the client form has validation, because the client is only a convenience layer.
Performance depends on import graphs. A Client Component that imports a broad feature barrel may drag unnecessary UI and utility code into the browser bundle. Prefer narrow client entry points and pass server-fetched data as serializable props. Reliability improves when mutations own their cache invalidation near the write, because the developer changing the write can see which paths or tags become stale.
Hands-On Lab
Prerequisites: a Next.js App Router project, TypeScript, a package manager, and permission to add one dependency-check script. Use a branch so cleanup is simple.
- Create
src/features/courses,src/features/enrollments, andsrc/shared. - Move an existing course page’s direct data query into
src/features/courses/data/get-course.tsand addimport "server-only";. - Export the intended route-facing API from
src/features/courses/index.ts. - Update the route file to import from
@/features/courses. - Create an enrollment server action and a small client button like the examples above.
- Add an import-boundary check with your chosen tool and include it in CI or the local test command.
Verification: run the type checker, production build, and dependency rule. Then deliberately import @/features/courses/data/get-course from the enrollment feature and confirm the dependency rule fails. Finally, import the server query from a "use client" component and confirm the build catches the server-only violation.
Cleanup: remove the deliberate bad imports, rerun the checks, and keep the branch diff focused on folders, exports, and tests related to the two features.
Assessment Exercises
- You have
features/billingandfeatures/enrollments. Enrollment needs to know whether a user has paid. Which feature should expose the query, and what should the public function return? - A Client Component imports
features/coursesand the build starts including a database error. Sketch a safer export layout. - Given a shared
formatCurrencyhelper used only by billing, should it live inshared? Explain the trade-off. - Design a dependency rule that allows imports within one feature but blocks imports into another feature’s
datadirectory. - Where should cache revalidation live for a mutation that changes enrollment state, and why?
Summary
Feature-oriented architecture in Next.js is strongest when it follows runtime facts: routes live in app, server code must stay out of client graphs, mutations validate on the server, and cross-feature access goes through intentional public APIs. Start with boundaries around data access and server-only modules, enforce them with tooling, and let shared code emerge from repeated stable needs rather than from guesswork.
