LIA is a hyperlocal employability platform I'm building for an isolated coastal district in Brazil — think fixed retail jobs, gigs, and a reputation layer, all matched by proximity instead of routed through a national job board.
This post is about the implementation: the actual folder structure, the real RegisterUserUseCase, and the Argon2id decision — pulled straight from the repository, not reconstructed from memory.
The Clean Architecture folder structure
LIA's backend is organized in four layers, and the direction of dependency is non-negotiable: outer layers depend on inner layers, never the other way around.
backend/src/
├── domain/
│ ├── entities/
│ └── repositories/ # interfaces only
├── application/
│ ├── dto/
│ └── use-cases/
├── infrastructure/
│ ├── database/
│ └── repositories/ # Prisma implementations
├── presentation/
│ ├── controllers/
│ └── routes/
└── shared/
└── errors/
Let's walk through the registration feature end to end, following that exact order.
- Domain — the entity and the repository contract
The User entity is a plain interface. No decorators, no ORM annotations, no framework leaking in:
typescript// domain/entities/user.ts
export interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
The repository is defined as a contract, not an implementation. The domain doesn't know — and doesn't care — whether it's backed by PostgreSQL, an in-memory map, or something else entirely:
typescript// domain/repositories/user.repository.ts
import { RegisterUserDTO } from '../../application/dto/register-user.dto.js';
export interface UserRepository {
create(data: RegisterUserDTO): Promise<{
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}>;
findByEmail(email: string): Promise<{
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
} | null>;
}
Notice create() never returns the password hash. That's not an accident — it's the same "strip credentials from every response payload" rule from the code review checklist in Part 1, enforced at the type level before it can ever leak.
- Application — the use case
This is where the actual business rule lives: validate input, enforce uniqueness, hash the password, delegate persistence.
typescript// application/use-cases/register-user.use-case.ts
import { RegisterUserDTO } from '../dto/register-user.dto.js';
import { PrismaUserRepository } from '../../infrastructure/repositories/prisma-user.repository.js';
import argon2 from 'argon2';
import { AppError } from '../../shared/errors/app-error.js';
export class RegisterUserUseCase {
private readonly repository = new PrismaUserRepository();
async execute(data: RegisterUserDTO) {
if (!data.name.trim()) {
throw new AppError('Nome é obrigatório.', 400);
}
if (!data.email.trim()) {
throw new AppError('E-mail é obrigatório.', 400);
}
if (!data.password.trim()) {
throw new AppError('Senha é obrigatória.', 400);
}
const existingUser = await this.repository.findByEmail(data.email);
if (existingUser) {
throw new AppError('E-mail já cadastrado.', 409);
}
const hashedPassword = await argon2.hash(data.password);
return this.repository.create({ ...data, password: hashedPassword });
}
}
A few things worth calling out for readers coming from an Express/MVC background:
AppError is a custom error class carrying an HTTP status code, but it's thrown from the application layer with zero knowledge of HTTP itself. The presentation layer decides how to translate it into a response — the use case just says "this is a 409."
Argon2id is called with library defaults here — argon2.hash(data.password). That's a legitimate MVP trade-off, and one this codebase is explicit about: [ADR-013] documents Argon2id as the algorithm decision (chosen over bcrypt for GPU-attack resistance per current OWASP guidance), while parameter tuning (memory cost, iterations) is left as a follow-up, not silently skipped.
Validation is intentionally basic (non-empty checks) at this stage of the project — this is the foundation sprint, not the final input-validation layer. Being upfront about that is part of the same "document the why" discipline from Part 1.
- Infrastructure — the Prisma implementation
The repository interface gets implemented here, and only here. This is the piece that would get replaced if LIA ever moved off Prisma or off PostgreSQL — nothing above this layer would need to change.
typescript// infrastructure/repositories/prisma-user.repository.ts
import { UserRepository } from '../../domain/repositories/user.repository.js';
import { RegisterUserDTO } from '../../application/dto/register-user.dto.js';
import { prisma } from '../database/prisma.js';
export class PrismaUserRepository implements UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
}
async create(data: RegisterUserDTO) {
return prisma.user.create({
data,
select: {
id: true,
name: true,
email: true,
createdAt: true,
updatedAt: true,
},
});
}
}
That select clause on create() is doing real work: it's a second, infrastructure-level guarantee that the password hash never makes it into the returned object — even if someone changes the domain contract upstream later.
Prisma itself is wired up through the driver adapter pattern introduced in Prisma 7, connecting directly against pg:
typescript// infrastructure/database/prisma.ts
import 'dotenv/config';
import { PrismaClient } from '../../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
export const prisma = new PrismaClient({ adapter });
This is a small but real example of what an ADR buys you in practice. [ADR-011] exists specifically because Prisma 7 changed its configuration conventions from earlier versions — without that ADR on record, a future "helpful" refactor could silently reintroduce a deprecated config pattern.
- Presentation — controller, routes, and the server
The controller's only job is translating HTTP in and out. It has no business logic — it just calls the use case and maps AppError to a response:
typescript// presentation/controllers/register.controller.ts
import { FastifyReply, FastifyRequest } from 'fastify';
import { RegisterUserUseCase } from '../../application/use-cases/register-user.use-case.js';
import { AppError } from '../../shared/errors/app-error.js';
import { RegisterUserDTO } from '../../application/dto/register-user.dto.js';
export class RegisterController {
constructor(private readonly registerUserUseCase: RegisterUserUseCase) {}
async handle(request: FastifyRequest, reply: FastifyReply) {
try {
const body = request.body as RegisterUserDTO;
const user = await this.registerUserUseCase.execute(body);
return reply.status(201).send(user);
} catch (error) {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
message: error.message,
});
}
request.log.error(error);
return reply.status(500).send({
message: 'Internal Server Error',
});
}
}
}
The use case is constructor-injected rather than instantiated inline inside handle() — that's what makes it possible to swap it for a mock in a controller test without touching Fastify at all.
Routing wires the controller to Fastify with the bare minimum of ceremony:
typescript// presentation/routes/register.routes.ts
import { FastifyInstance } from 'fastify';
import { RegisterController } from '../controllers/register.controller.js';
import { RegisterUserUseCase } from '../../application/use-cases/register-user.use-case.js';
const registerUserUseCase = new RegisterUserUseCase();
const controller = new RegisterController(registerUserUseCase);
export async function registerRoutes(app: FastifyInstance) {
app.post('/register', controller.handle.bind(controller));
}
And the Fastify instance itself — [ADR-012] — stays intentionally thin at this stage: CORS, a health check, and the registration routes:
typescript// presentation/server.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { registerRoutes } from './routes/register.routes.js';
export const app = Fastify({
logger: true,
});
await app.register(cors, {
origin: true, // dev only — opens CORS to any origin, e.g. localhost:5173
methods: ['GET', 'POST', 'PUT', 'DELETE'],
});
app.get('/health', async () => ({
status: 'ok',
}));
app.register(registerRoutes);
The shared error class tying it together
One small class does a lot of quiet work across every layer above:
typescript// shared/errors/app-error.ts
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode = 400,
) {
super(message);
this.name = 'AppError';
}
}
It's what lets the use case say "this is a business rule violation, and here's the HTTP status that should map to it" without importing anything HTTP-related. The presentation layer is the only place that ever turns that status code into an actual response.
Why this is worth doing on a solo project
None of this is exotic engineering — it's the kind of layering most backend teams settle into eventually. The point of doing it explicitly, from the first registration endpoint, on a one-person project is that it turns architectural discipline into something enforceable instead of aspirational. The Definition of Done checklist behind this feature — password hashing validated at the persistence layer, credentials stripped from every response, centralized error handling — isn't a wishlist here. You can grep for it in this code and find it already true.
Questions about the trade-offs above — especially the Argon2id defaults or the validation approach — are welcome in the comments. This is an active build, and Part 2 will cover the frontend registration flow and where the current implementation gets hardened next.
Top comments (0)