DEV Community

luis-botelho
luis-botelho

Posted on

Building LIA (Part 1 Implementation): Clean Architecture and Argon2id in a Real Fastify + Prisma Registration Flow

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.

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

  1. 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 });
Enter fullscreen mode Exit fullscreen mode

}
}

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.

  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.

  1. 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',
  });
}
Enter fullscreen mode Exit fullscreen mode

}
}

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)