The problem with framework-coupled code
When your business logic is woven through Express routes and Prisma calls, testing is hard (you need the whole stack running), migrating to a new ORM or framework is a rewrite, and understanding what the app actually does requires reading through HTTP plumbing.
The dependency rule
Source code dependencies point inward. The innermost layer (domain) knows nothing about Express, Prisma, or any I/O. The outer layers depend on the inner ones, never the reverse.
Layer 1 (innermost): Domain — entities, business rules, no imports
Layer 2: Use Cases — application-specific business logic
Layer 3: Interface Adapters — controllers, presenters, gateways
Layer 4 (outermost): Frameworks & Drivers — Express, Prisma, RedisThe domain layer
Pure TypeScript. No database, no HTTP, no logging. Just types and functions.
// domain/user.ts
export type User = {
id: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
};
export function canCreateProject(user: User): boolean {
return user.role === 'admin' || Date.now() - user.createdAt.getTime() > 86400000;
}Use cases
Orchestrate domain objects and call external systems through interfaces (ports).
// use-cases/create-project.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
export interface ProjectRepository {
create(data: CreateProjectData): Promise<Project>;
}
export async function createProject(
userId: string,
data: CreateProjectData,
userRepo: UserRepository,
projectRepo: ProjectRepository,
): Promise<Project> {
const user = await userRepo.findById(userId);
if (!user) throw new Error('User not found');
if (!canCreateProject(user)) throw new Error('Not authorized');
return projectRepo.create(data);
}Adapters — Prisma implementation
// adapters/prisma-user-repo.ts
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
}The controller
The controller's only job: translate HTTP to use case call, translate result to HTTP response.
// adapters/create-project-controller.ts
app.post('/projects', async (req, res) => {
try {
const project = await createProject(
req.user.id,
req.body,
new PrismaUserRepository(prisma),
new PrismaProjectRepository(prisma),
);
res.status(201).json(project);
} catch (err) {
if (err.message === 'Not authorized') return res.status(403).json({ error: err.message });
res.status(500).json({ error: 'Internal error' });
}
});The payoff
Unit tests for use cases require zero mocking of HTTP or databases — just pass in fake repository implementations. Swapping Prisma for a different ORM only requires rewriting adapters. The business logic is completely readable without understanding any framework details.