This is an opinionated Next.js template for a small Event Registration demo domain. It keeps the full stack in feature slices while preserving clean architecture boundaries: domain rules stay independent, application services orchestrate use cases, repositories adapt persistence, tRPC routers translate transport, and React UI stays out of the database.
The template uses:
- Next.js App Router
- Bun for dependency management and scripts
- Better Auth for authentication
- Prisma for persistence
- tRPC for type-safe APIs
- Zod for runtime schemas and inferred TypeScript types
- Tailwind CSS and shadcn-style UI primitives
- Vitest for fast domain and service tests
This template demonstrates full-stack vertical slices with clean architecture boundaries. A feature owns a cohesive business capability end to end: domain language, use-case services, repository contracts/adapters, API routes, and UI.
The demo intentionally replaces generic starter examples with a business workflow:
- create an Event
- register an Attendee
- enforce Capacity
- place attendees on a Waitlist Entry when full
- promote the next waitlisted attendee when a confirmed Registration is cancelled
- queue a Notification for cross-account waitlist promotion
The domain language is documented in CONTEXT.md. The deeper architecture rules are documented in docs/architecture.md and ADRs under docs/adr.
Feature folders are organized around business capabilities, not every database table or every technical concern.
src/features/
events/
domain/
services/
repositories/
api/
ui/
index.ts
notifications/
domain/
api/
index.ts
shared/
components/
hooks/
utils.tsIn this demo, Event, Registration, and Waitlist Entry live together in events/ because they form one workflow. Registration outcomes depend on event capacity and registration windows, so splitting them into separate features would add ceremony without a real boundary.
notifications/ is separate because message delivery reacts to event-registration outcomes without owning event-registration rules.
Domain code lives in domain/. It owns business concepts, invariants, domain events, and framework-free functions.
src/features/events/domain/
event.ts
registration.ts
tests/
registration.test.tsDomain code may use framework-free libraries such as Zod, but it must not import React, Next.js, tRPC, Prisma, Better Auth, server modules, generated persistence types, repositories, services, API routers, or UI.
Example domain responsibility:
export function getActiveWaitlistRank(
waitlistEntry: Pick<WaitlistEntrySummary, "id">,
activeWaitlistEntries: Array<Pick<WaitlistEntrySummary, "id" | "position">>
) {
const activeRank = [...activeWaitlistEntries]
.sort((left, right) => left.position - right.position)
.findIndex(
(activeWaitlistEntry) => activeWaitlistEntry.id === waitlistEntry.id
);
return activeRank === -1 ? null : activeRank + 1;
}Application services live in services/. They are TypeScript classes that orchestrate workflows using constructor-injected repository dependencies.
src/features/events/services/
event/
commands.ts
index.ts
service.ts
service.test.ts
registration/
commands.ts
index.ts
service.ts
service.test.tsServices are added when an operation has real business orchestration. They are not required for every tiny read endpoint.
Each service folder exposes a local index.ts barrel. Code outside that service folder imports from ../services/event or ../services/registration, while files inside the service folder can import concrete siblings such as ./commands and ./service.
Example service shape:
export interface CreateEventUseCase {
createEvent(input: CreateEventInput): Promise<CreateEventOutput>;
}
export class EventService implements CreateEventUseCase {
constructor(private readonly repositories: EventServiceRepositories) {}
}
export class RegistrationService
implements
RegisterForEventUseCase,
CancelRegistrationUseCase,
LeaveWaitlistUseCase
{
constructor(private readonly repositories: RegistrationServiceRepositories) {}
}Services depend on:
- domain schemas and functions
- repository contracts
Services do not depend on:
- Prisma
- tRPC
- Better Auth
- React
- Next.js request/session objects
- database clients
Expected business outcomes are returned as typed result states, not thrown as transport errors.
type RegisterForEventOutput =
| { status: "registered"; registration: RegistrationSummary }
| { status: "waitlisted"; waitlistEntry: WaitlistEntrySummary }
| { status: "rejected"; reason: RegisterForEventRejectionReason };Repository contracts live under concept-specific folders in repositories/. They define the persistence operations that services need without committing those services to Prisma, SQL, HTTP, or any other concrete technology.
src/features/events/repositories/
event/
index.ts
repository.ts
adapters/
prisma.ts
prisma-mappers.ts
registration/
index.ts
repository.ts
adapters/
prisma.ts
prisma-mappers.tsRepository interfaces are named after feature concepts, but methods are shaped by workflows rather than generic CRUD.
export interface EventRepository {
createEvent(input: CreateEventRepositoryInput): Promise<EventSummary>;
findRegistrationSnapshot(
eventId: string
): Promise<EventRegistrationSnapshot | null>;
listOpenEvents(input?: ListOpenEventsInput): Promise<EventSummary[]>;
}Concrete outbound adapters live beside the repository contract they implement, under repositories/<concept>/adapters/.
The Prisma adapter imports generated Prisma types and maps database records into domain summaries. Generated Prisma types stay in adapters, not in domain or service code.
Feature routers live in api/.
src/features/events/api/
events-router.ts
src/features/notifications/api/
notifications-router.tsRouters are intentionally thin. They:
- parse input with service schemas
- read the actor from
ctx.session - construct repository implementations
- call application services
- dispatch cross-feature reactions where appropriate
- return typed outputs to the client
The root router only composes feature routers in src/server/api/root.ts.
React screens and feature-specific UI live in ui/.
src/features/events/ui/
EventsPage.tsx
EventsView.tsx
EventsErrorBoundary.tsx
components/
CreateEventForm.tsx
EventCard.tsx
NotificationInbox.tsx
PublishedEventsSection.tsx
StatusPanel.tsx
hooks/
use-events-actions.tsUI can call the tRPC client and use shared UI primitives, but it must not import repositories, Prisma, generated persistence code, or src/server/db.
EventsView.tsx stays as the screen composition boundary. Form rendering, event cards, notification inbox, and status panels live in components/; tRPC queries, mutations, invalidation, form parsing, and toast side effects live in hooks/use-events-actions.ts.
Shared UI primitives live under:
src/features/shared/components/
src/features/shared/hooks/
src/features/shared/utils.tsThe App Router stays a composition layer. For example, src/app/page.tsx imports the feature page container and renders it.
Feature internals should not import their own index.ts barrel. Inside a feature, import the concrete module so the dependency is obvious.
// Good inside src/features/events/api/events-router.ts
// Avoid inside src/features/events/**
import { RegistrationService } from "~/features/events";
import { RegistrationService } from "../services/registration";Cross-feature imports should go through the other feature's public index.ts.
// Good: events reacts through notifications' public boundary
import { queueWaitlistPromotionNotification } from "~/features/notifications";This keeps feature boundaries narrow while preserving discoverability for other slices.
The clean architecture dependency direction is:
src/app
-> feature UI pages / root API composition
feature/ui
-> tRPC client
-> shared UI
-> feature display types
feature/api
-> feature services
-> feature repository factories
-> server API context
-> other feature public APIs for reactions
feature/services
-> feature domain
-> feature repository contracts
feature/repositories
-> feature domain
-> generated Prisma / persistence adapters from repositories/<concept>/adapters
feature/domain
-> framework-free libraries onlyVisualized:
src/app
route composition and mounting
|
v
+----------------+----------------+
| |
v v
feature/ui feature/api
React screens tRPC routers
user interaction transport boundary
| |
| v
| feature/services
| use-case workflows
| business orchestration
| |
| v
| feature/domain
| schemas, invariants,
| pure business rules
|
v
shared/ui
generic components,
hooks, utilitiesRepository adapters sit behind repository contracts:
feature/api
|
v
feature/services ---> feature/repositories/<concept>
| |
v v
feature/domain feature/repositories/<concept>/adapters/prisma
|
v
generated/prisma + databaseThe ESLint config dynamically discovers directories under src/features and applies generic restrictions for each feature. It blocks self-barrel imports and prevents inner layers from importing outer layers or infrastructure.
src/
app/
api/
auth/[...all]/route.ts
trpc/[trpc]/route.ts
page.tsx
features/
events/
domain/
event.ts
registration.ts
tests/
services/
event/
commands.ts
index.ts
service.ts
service.test.ts
registration/
commands.ts
index.ts
service.ts
service.test.ts
repositories/
event/
index.ts
repository.ts
adapters/
prisma.ts
prisma-mappers.ts
registration/
index.ts
repository.ts
adapters/
prisma.ts
prisma-mappers.ts
api/
events-router.ts
ui/
EventsPage.tsx
EventsView.tsx
EventsErrorBoundary.tsx
components/
CreateEventForm.tsx
EventCard.tsx
NotificationInbox.tsx
PublishedEventsSection.tsx
StatusPanel.tsx
hooks/
use-events-actions.ts
index.ts
notifications/
domain/
notification.ts
tests/
api/
notifications-router.ts
index.ts
shared/
components/
hooks/
utils.ts
server/
api/
root.ts
trpc.ts
auth/
db.ts
generated/
prisma/
zod/
prisma/
schema.prisma
models/The core workflow is registerForEvent.
It returns "registered" when:
- the event exists
- registration is open
- capacity is available
- the attendee has no active registration
It returns "waitlisted" when:
- registration is open
- the event is full
- the attendee has no active registration or active waitlist entry
It returns "rejected" for expected business rejections, such as:
- event not found
- registration closed
- already registered
- already waitlisted
Cancellation can promote the next active waitlist entry. That emits a WaitlistPromoted event-registration event, which the API layer dispatches to the notifications/ feature.
Better Auth infrastructure lives under src/server/auth. The route handler is mounted at src/app/api/auth/[...all]/route.ts.
Domain and service code should receive explicit domain identities, such as attendeeId, rather than Better Auth session objects.
In this template:
- User is auth infrastructure language
- Attendee is event-registration domain language
Prisma is configured through prisma.config.ts, with schema files under prisma.
The Prisma client and Prisma Zod schemas are generated into committed root-level output directories:
generated/
prisma/
zod/Regenerate after schema changes:
bun run db:generateGenerated imports should stay in repository or API-adjacent code:
import type { PrismaClient } from "generated/prisma/client";Domain and service code must not import generated Prisma or generated Zod modules.
Create a local .env file with:
DATABASE_URL="file:./dev.db"
BETTER_AUTH_SECRET="replace-me"
AUTH_GOOGLE_ID="replace-me"
AUTH_GOOGLE_SECRET="replace-me"
BETTER_AUTH_URL="http://localhost:3000"BETTER_AUTH_URL is optional in local development but recommended so Better Auth does not infer the base URL from incoming requests.
For Google OAuth, create an OAuth client in Google Cloud Console, then set:
AUTH_GOOGLE_IDto the OAuth client IDAUTH_GOOGLE_SECRETto the OAuth client secret
Install dependencies:
bun installGenerate Prisma output:
bun run db:generateApply the schema locally:
bun run db:pushRun the development server:
bun run devOpen http://localhost:3000.
bun run dev
bun run test
bun run typecheck
bun run lint
bun run build
bun run db:generate
bun run db:push
bun run db:migrate
bun run db:studioDomain and service tests run without Next.js, tRPC, Better Auth, Prisma, or a database. This keeps the core business rules fast and deterministic.
src/features/events/domain/tests/
src/features/events/services/event/service.test.ts
src/features/events/services/registration/service.test.ts
src/features/notifications/domain/tests/Run:
bun run testThe architecture makes it straightforward to add slower integration tests later around Prisma repositories, tRPC routers, and browser workflows without weakening the fast domain/service test layer.
- Business language is explicit: Event, Attendee, Registration, Waitlist Entry, Capacity, Registration Window, and Notification are documented and reflected in code.
- Use cases are testable: Services depend on repository contracts, so tests can run without infrastructure.
- Persistence is replaceable: Prisma details stay in repository adapters.
- Transport stays thin: tRPC routers translate requests into use-case calls instead of owning business rules.
- UI cannot reach into the database: React code talks through API/client boundaries.
- Feature boundaries are enforceable: ESLint blocks self-barrels, deep infrastructure imports, and layer violations.
- Small features stay small:
notifications/demonstrates a thin feature without empty ceremony folders.