Article · NestJS
NestJS Clean Architecture: One Domain, Two APIs
A NestJS starter where REST and GraphQL share the same use cases — with Prisma 7, rotated refresh tokens, and ports that stay honest.

This is my first article, and I want it to be useful, not flashy. Below is what I learned while building NestJS Core Clean — an open-source, MIT-licensed foundation for NestJS backends.
Why I built it
Most NestJS "starters" fall into one of two traps.
Either everything lives in controllers and the project becomes hard to grow, or someone draws a Perfect Architecture Diagram and never wires real authentication, Docker, tests, or a second API style.
I wanted something in between:
- Clean Architecture that runs
- Auth that is stronger than "return a JWT"
- One business core exposed through REST and GraphQL
- Enough honesty in the README that adopters are not surprised in production
If you only need a toy CRUD demo, this is more than you need. If you keep restarting backends because structure collapses after the third module, this might save you weeks.
What's in the box
| Layer | Choice |
|---|---|
| Runtime | Node.js 22 · pnpm 10 |
| Framework | NestJS 11 / Express |
| Language | TypeScript 5.9 (strict) |
| Database | PostgreSQL 15+ |
| ORM | Prisma 7 + driver adapter |
| Auth | JWT + Argon2id |
| GraphQL | Apollo Server 5 (code-first) |
| Testing | Jest unit + REST / GraphQL e2e |
Architecture that shows up in the folders
Dependency rule, inward:
Presentation → Application (use cases) → Domain (ports) → Infrastructure (adapters) → PostgreSQL
Folder layout mirrors that:
src/
├── domain/ Entities, ports, domain errors
├── application/ Use cases + application ports
├── infrastructure/ Prisma, config, seed, JWT / Argon2
└── presentation/
├── http/ REST controllers, guards, filters
└── graphql/ Resolvers and GraphQL inputsNest DI binds ports as Symbol(...) tokens. Controllers and resolvers inject ports, not concrete
classes. That sounds academic until you mock a use case in a unit test or swap an adapter without
rewriting presentation code.
One honesty check: this is Clean Architecture with a light DDD flavor — not full aggregates, domain events, or CQRS. I did not pretend otherwise.
The part I care about most: one use case, two protocols
REST and GraphQL are thin adapters. The login path makes that obvious.

HTTP:
@Post("logIn")
@UseGuards(ThrottlerGuard)
async logIn(
@Body() data: LoginRequestDto,
@GetDeviceFingerprint() deviceId: string,
): Promise<LoginResponseDto> {
return await this.loginUseCase.execute({ ...data, deviceId })
}GraphQL:
@Mutation(() => LoginResponseDto)
@UseGuards(GqlThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
async logIn(
@Args("data") data: LoginRequestDto,
@GetDeviceFingerprint() deviceId: string,
): Promise<LoginResponseDto> {
return await this.loginUseCase.execute({ ...data, deviceId })
}Same LOGIN_USE_CASE token. Same port. Same rules.
Change password verification, inactive-user handling, token issuance, or refresh-hash storage — change it once. That is the architectural win many "we also have GraphQL" tutorials skip.
Auth I would actually reuse

The login use case stays short on purpose:
async execute(input: LoginInput): Promise<LoginOutput> {
const username = input.username.toLowerCase()
const user = await this.usersRepo.findByUsername(username)
if (!user) throw new DomainException(AuthErrors.IncorrectUsernameOrPassword)
if (!user.active) throw new DomainException(AuthErrors.InactiveUser)
const valid = await this.hasher.verify(user.passwordHash, input.password)
if (!valid) throw new DomainException(AuthErrors.IncorrectUsernameOrPassword)
const tokens = await this.tokenService.signTokenPair({
id: user.id,
username: user.username,
deviceId: input.deviceId,
role: user.role,
})
const refreshTokenHash = await this.hasher.hash(tokens.refreshToken)
await this.usersRepo.setRefreshTokenHash(user.id, refreshTokenHash)
return tokens
}What I optimized for:
- Argon2id for passwords — and for hashing the refresh token before it hits PostgreSQL.
- Refresh rotation — a successful refresh issues a new pair and updates the stored hash.
- Device binding — a SHA-256 of
User-AgentbecomesdeviceIdinside the JWT and is checked on login and refresh. - Optional authentication, strict authorization — a global token guard can attach the user when the JWT is valid, but route guards re-check active state and role in the database. Deactivating a user matters on the next protected call.
- Logout, password change, and soft-delete clear the refresh hash.
Those limits are documented in the repo on purpose. Hiding them would make a prettier article and a worse starter.
Errors as a contract
Domain errors carry a stable (module, code) pair, an English message, a translated message, and an
HTTP status.
REST returns one JSON shape. GraphQL puts the same body in error extensions. Swagger can document
the exact domain errors next to an endpoint.
If you ship products for mixed-language teams — or any bilingual product — stable codes beat string-matching error messages every time.
A quick walkthrough of a protected request
Example: an admin calls GET /user?role=Member&take=20 with a bearer token.

- Global token guard verifies the JWT and device fingerprint when present.
- Validation pipe transforms and allow-lists the query.
- Admin guard loads the current user from the database — must be active and Admin.
- Controller maps the DTO into a use-case input.
- Use case talks to a repository port.
- Prisma adapter applies filters, pagination, and sorting, then maps rows to domain objects.
- Response DTO goes back to the client.
Any domain or unexpected error goes through CoreExceptionFilter into one format. GraphQL follows
the same application path; only the adapter and error wrapping differ.
Details teams usually add months later
Already included:
- Fail-fast environment validation
- Auth throttling (stricter limits on GraphQL login/register)
- Health check with a real database ping
- Swagger + OpenAPI outside production
- Code-first
schema.gqland a committed Zeus client for GraphQL e2e - Docker Compose for local development
- Unit tests and REST/GraphQL end-to-end tests
- CI for lint, typecheck, Zeus drift, and tests
Boot seeding exists for development. Keep SEED_ON_BOOT off in production and replace every sample
secret.
Run it
git clone https://github.com/Arash3f/nestJs-core-clean.git
cd nestJs-core-clean
pnpm install --frozen-lockfile
cp .env.sample .env.dev
pnpm run prisma:generate:dev
pnpm run prisma:migrate:dev
pnpm run start:devLocal defaults: API on http://localhost:3000, Swagger at /api_docs, GraphQL at /graphql,
health at /health. Docker Compose is also available if you prefer containers (API on port 3006
in the compose file).
Who this is for
Good fit if you want:
- a NestJS 11 + Prisma 7 baseline where Clean Architecture is enforced in code, not just in a diagram
- REST and GraphQL without forking business rules
- auth hygiene (Argon2id, rotation, hashed refresh) from day one
Not a fit if you need:
- full DDD / CQRS / event sourcing out of the box
- multi-device sessions and enterprise identity on day one
- a turnkey production platform with Kubernetes manifests and full observability
Before a public deploy you still need things like production containerization, secrets management, a session strategy for concurrent devices, GraphQL depth and complexity limits, and the account-security features your product requires. The README lists those explicitly.
Closing
Clean Architecture tutorials often stop at diagrams. JWT tutorials often stop at "here is a token." GraphQL tutorials often grow a second business layer beside resolvers.
I tried to connect those pieces into something you can clone, break, and grow — with enough production thinking to respect future-you, and enough humility to say what is still unfinished.
If that sounds useful: github.com/Arash3f/nestJs-core-clean. Clone it. Change a use case. Add a module. Keep the ports pointed inward.
If you try it, I would genuinely like feedback — especially the uncomfortable kind. This is my first article, and I am treating both the repo and the writing as things that should improve in public.