The Problem With "Just Make It Work"
I've read plenty of NestJS tutorials that end with a controller doing database queries directly, a service file that's really just a repository, and business logic scattered across three layers that aren't actually layers at all. It works for a demo. It doesn't survive six months and a second developer.
Clean Architecture is the answer I keep coming back to — not because a book told me to, but because every project where I skipped it became painful at exactly the moment it mattered most.
This is how I structure NestJS applications into four strict layers, what the rules are, and why the constraints are the whole point.
The Four Layers
domain/ → entities, value objects, ports (interfaces)
application/ → use-cases that orchestrate domain logic
infrastructure/ → adapters: Prisma, Redis, HTTP clients, LLM providers
presentation/ → controllers, DTOs, Swagger decorators
The rule: dependencies only point inward. The domain knows nothing about Prisma. The application knows nothing about HTTP. The controller knows nothing about business rules — it validates input, calls a use-case, and serializes the response.
Domain Layer: Pure TypeScript, No Framework
// domain/entities/experience.entity.ts
export class Experience {
constructor(
public readonly id: string,
public readonly company: string,
public readonly role: string,
public readonly startDate: Date,
public readonly endDate: Date | null,
public readonly highlights: string[],
) {}
get isCurrent(): boolean {
return this.endDate === null;
}
}
// domain/ports/experience.repository.ts
export interface ExperienceRepository {
findAll(): Promise<Experience[]>;
findById(id: string): Promise<Experience | null>;
save(experience: Experience): Promise<Experience>;
delete(id: string): Promise<void>;
}
No @Injectable(), no Prisma types, no NestJS. The domain is plain TypeScript. If you need to spin up a NestJS app to test your business rules, something is wrong.
ExperienceRepository is a port — an interface that describes what the application needs, without specifying how it's provided. The infrastructure layer provides the implementation.
Application Layer: Use-Cases With One Job Each
// application/use-cases/get-all-experiences.use-case.ts
@Injectable()
export class GetAllExperiencesUseCase {
constructor(
@Inject(EXPERIENCE_REPOSITORY)
private readonly repo: ExperienceRepository,
) {}
async execute(): Promise<Experience[]> {
return this.repo.findAll();
}
}
Use-cases should be boring. This one is embarrassingly simple — but that simplicity is the point. When the business rule changes (say, only return experiences from the last 10 years), the change happens in one place. Not scattered across controllers and repositories.
The use-case depends on the ExperienceRepository interface, not the Prisma implementation. The EXPERIENCE_REPOSITORY token is a DI symbol. The actual Prisma adapter is wired up in the module.
Infrastructure Layer: Where Prisma Lives
// infrastructure/repositories/prisma-experience.repository.ts
@Injectable()
export class PrismaExperienceRepository implements ExperienceRepository {
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise<Experience[]> {
const rows = await this.prisma.experience.findMany({
orderBy: { sortOrder: 'asc' },
});
return rows.map(this.toEntity);
}
private toEntity(row: PrismaExperience): Experience {
return new Experience(
row.id, row.company, row.role,
row.startDate, row.endDate, row.highlights,
);
}
}
The toEntity mapper translates between the Prisma model (tied to your database schema) and the domain entity (tied to your business rules). If you rename a Prisma field, the TypeScript compiler tells you exactly where the mapper breaks. Nothing upstream changes.
Presentation Layer: Thin Controllers
// presentation/controllers/experiences.controller.ts
@Controller('content/experiences')
export class ExperiencesController {
constructor(private readonly getAllExperiences: GetAllExperiencesUseCase) {}
@Get()
@ApiOkResponse({ type: [ExperienceResponseDto] })
async findAll(): Promise<ExperienceResponseDto[]> {
const experiences = await this.getAllExperiences.execute();
return experiences.map(ExperienceResponseDto.fromEntity);
}
}
The controller doesn't know what the database looks like. It doesn't know how data is fetched. It knows one thing: call the use-case, serialize the result.
The Real Payoff: Swappable Infrastructure
For my portfolio's AI chatbot, the LLM provider is defined as a port:
// domain/ports/llm-provider.port.ts
export interface LLMProvider {
complete(prompt: string, context: string[]): Promise<string>;
stream(prompt: string, context: string[]): AsyncIterable<string>;
}
The chat use-case depends on this interface. The DeepSeekAdapter implements it. When I want to swap to a different model, I write a new adapter — zero changes to the use-case, zero changes to the controller. The architecture makes the swap a one-file operation.
This is the portfolio narrative: not "I used X library," but "I designed the system so the model is swappable."
Wiring It Together
@Module({
imports: [PrismaModule],
providers: [
GetAllExperiencesUseCase,
{
provide: EXPERIENCE_REPOSITORY,
useClass: PrismaExperienceRepository,
},
],
controllers: [ExperiencesController],
})
export class ContentModule {}
The use-case is a provider. The repository is registered under a DI token. To test the use-case in isolation, swap PrismaExperienceRepository for an InMemoryExperienceRepository. The use-case never knows.
The Discipline Required
The hardest part isn't writing the layers — it's enforcing the rules when you're in a hurry. The temptation is always "just grab the Prisma client in the controller, it's faster." It is. For the next 20 minutes.
Rules I enforce on every PR:
- –No
PrismaClientimported inapplication/ordomain/ - –No
@nestjs/commonimports indomain/ - –No business logic in controllers
- –No inline
process.env— alwaysConfigService
Each rule prevents a specific class of pain I've hit before.
When to Skip This
If you're building a pure CRUD API with no real business logic, clean architecture is overhead. A service + repository is fine. The architecture pays off when your use-cases have real rules — "only the author can publish," "orders can't cancel after they ship," "a chatbot must only answer from its knowledge base."
When the business rules are complex enough to unit test independently, you want them isolated. That's exactly when the layers earn their keep.