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.

NestJS Core Clean — one domain, two APIs

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

LayerChoice
RuntimeNode.js 22 · pnpm 10
FrameworkNestJS 11 / Express
LanguageTypeScript 5.9 (strict)
DatabasePostgreSQL 15+
ORMPrisma 7 + driver adapter
AuthJWT + Argon2id
GraphQLApollo Server 5 (code-first)
TestingJest 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 inputs

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

REST and GraphQL both feeding into a single shared LoginUseCase
Figure 1.REST and GraphQL stay at the edge. LoginUseCase is shared.

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

Four capsules showing the refresh token lifecycle: login, issue, hash, rotate
Figure 2.Issue tokens, store a hash, rotate on refresh.

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:

  1. Argon2id for passwords — and for hashing the refresh token before it hits PostgreSQL.
  2. Refresh rotation — a successful refresh issues a new pair and updates the stored hash.
  3. Device binding — a SHA-256 of User-Agent becomes deviceId inside the JWT and is checked on login and refresh.
  4. 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.
  5. 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.

Stacked layers — Presentation, Application, Domain, Infrastructure, PostgreSQL — with a request flowing down through them
Figure 3.A request walks down the layers. Exceptions come back through one filter.
  1. Global token guard verifies the JWT and device fingerprint when present.
  2. Validation pipe transforms and allow-lists the query.
  3. Admin guard loads the current user from the database — must be active and Admin.
  4. Controller maps the DTO into a use-case input.
  5. Use case talks to a repository port.
  6. Prisma adapter applies filters, pagination, and sorting, then maps rows to domain objects.
  7. 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.gql and 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:dev

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

  • NestJS
  • Clean Architecture
  • GraphQL