Pular para o conteúdo principal

Migração: Next.js com TyForge

Guia completo para migrar projetos Next.js para os padrões TyForge aplicando Clean Architecture, Clean Code e SOLID.


Visão Geral​

TyForge é um framework TypeScript que fornece building blocks para DDD (Domain-Driven Design), validação type-safe e Result pattern. Este guia mostra como migrar um projeto Next.js existente para os padrões TyForge de nível empresarial.

O que TyForge resolve​

Problema comum em Next.jsSolução TyForge
Validação espalhada em controllers/pagesSchemaBuilder + TypeFields centralizam validação
Lógica de negócio em API routesAggregate + Entity encapsulam regras
Erros inconsistentes entre API routesExceptions (RFC 7807) padronizam respostas
Tipos manuais que divergem do runtimeInferProps / InferJson infere tipos do schema
try/catch aninhados sem padrãoResult<T, E> para error handling funcional
Dados sensíveis vazando em responsesexpose: "redacted" / "private" no schema
Input não validado chegando no domainDtoReq valida na fronteira HTTP

Estrutura de Pastas​

src/
├── app/ # Next.js App Router
│ ├── api/
│ │ └── users/
│ │ ├── route.ts # HTTP handler (controller)
│ │ └── [id]/
│ │ └── route.ts
│ ├── layout.tsx
│ └── page.tsx
│
├── modules/ # Módulos de domínio
│ └── user/
│ ├── domain/ # Camada de domínio (puro, sem deps externas)
│ │ ├── user.aggregate.ts
│ │ ├── user.schema.ts
│ │ ├── user.types.ts
│ │ └── events/
│ │ └── event-user-registered.ts
│ │
│ ├── application/ # Camada de aplicação (orquestra domínio)
│ │ ├── use-cases/
│ │ │ ├── register-user.use-case.ts
│ │ │ └── get-user.use-case.ts
│ │ ├── dtos/
│ │ │ ├── dto-req-create-user.ts
│ │ │ └── dto-res-user-profile.ts
│ │ └── mapper-user.ts
│ │
│ └── infrastructure/ # Camada de infraestrutura (implementações)
│ └── repository-user-prisma.ts
│
├── shared/ # Código compartilhado entre módulos
│ ├── exceptions/
│ │ └── http-error-handler.ts
│ └── middleware/
│ └── validate-request.ts
│
└── config/
└── tyforge.setup.ts # Configuração opcional do TyForge

Regras de dependência (Clean Architecture)​

app/api/ (controllers) → application/ → domain/
→ infrastructure/
↑
infrastructure/ implementa interfaces do domain/
  • Domain não importa de nenhuma outra camada
  • Application importa do domain
  • Infrastructure implementa interfaces definidas no domain
  • Controllers (app/api/) compõem application + infrastructure

Instalação​

npm install tyforge

Criar tyforge.config.json na raiz do projeto:

{
"schema": {
"validate": {
"create": "full",
"assign": "type"
}
},
"lint": {
"root": "src",
"strict": true,
"exclude": ["**/__tests__/**"]
}
}

Passo a Passo: Módulo de Usuário​

1. Schema e Tipos (Domain)​

O schema é a fonte de verdade. Tipos são inferidos dele — nunca definidos manualmente.

// src/modules/user/domain/user.schema.ts
import {
FId, FString, FEmail, FInt, FAppStatus,
type ISchema, SchemaBuilder,
} from "tyforge";

export const userSchema = {
id: { type: FId, required: false },
name: { type: FString },
email: { type: FEmail, expose: "private" },
age: { type: FInt },
status: { type: FAppStatus },
} satisfies ISchema;

export const userValidator = SchemaBuilder.compile(userSchema);
// src/modules/user/domain/user.types.ts
import type { InferProps, InferJson } from "tyforge";
import type { userSchema } from "./user.schema";

export type TUserProps = InferProps<typeof userSchema>;
export type TUserJson = InferJson<typeof userSchema>;

Por que assim:

  • O schema define validação E tipos ao mesmo tempo
  • InferProps gera o tipo com TypeFields (FString, FEmail, etc.)
  • InferJson gera o tipo primitivo (string, number, etc.) — usado para persistência
  • expose: "private" esconde o email em respostas públicas
  • Nenhum tipo manual — se o schema muda, os tipos mudam junto

2. Aggregate (Domain)​

O Aggregate encapsula as regras de negócio. Nenhuma regra fica no controller ou use case.

// src/modules/user/domain/user.aggregate.ts
import {
Aggregate, FId, OAppStatus,
ok, err, isFailure,
ExceptionBusiness,
type Result, type Exceptions,
} from "tyforge";
import { userSchema, userValidator } from "./user.schema";
import type { TUserProps, TUserJson } from "./user.types";
import { EventUserRegistered } from "./events/event-user-registered";

export class User extends Aggregate<TUserProps, TUserJson> implements TUserProps {
readonly id: FId | undefined;
readonly name: FString;
readonly email: FEmail;
readonly age: FInt;
readonly status: FAppStatus;

protected readonly _classInfo = { name: "User", version: "1.0.0", description: "User aggregate" };
protected readonly _schema = userSchema;

private constructor(props: TUserProps) {
super(props);
this.id = props.id;
this.name = props.name;
this.email = props.email;
this.age = props.age;
this.status = props.status;
}

static create(data: TUserJson): Result<User, Exceptions> {
const result = userValidator.create(data);
if (isFailure(result)) return result;

// Regras de negócio
if (result.value.age.getValue() < 18) {
return err(ExceptionBusiness.invalidBusinessRule("User must be at least 18 years old"));
}

const user = new User({
id: FId.generate(),
...result.value,
status: FAppStatus.createOrThrow(OAppStatus.ACTIVE),
});

user.addDomainEvent(
EventUserRegistered.create({
userId: user.id!.getValue(),
email: user.email.getValue(),
})
);

return ok(user);
}

static assign(data: TUserJson): Result<User, Exceptions> {
const result = userValidator.assign(data);
if (isFailure(result)) return result;
return ok(new User(result.value));
}
}

Por que assim:

  • implements TUserProps garante que a classe tem todos os campos do schema
  • _schema = userSchema habilita expose/redaction no toJSON()
  • create() retorna Result — nunca throw no domain
  • Regras de negócio (idade >= 18) ficam AQUI, não no controller
  • assign() é para hidratação do banco — validação leve, sem regras de negócio
  • Domain Event emitido no create — registra o que aconteceu

3. Domain Event​

// src/modules/user/domain/events/event-user-registered.ts
import { DomainEvent } from "tyforge";

interface TEventUserRegisteredPayload {
userId: string;
email: string;
}

export class EventUserRegistered extends DomainEvent<TEventUserRegisteredPayload> {
readonly queueName = "user-events";

static create(payload: TEventUserRegisteredPayload): EventUserRegistered {
return new EventUserRegistered("user.registered", payload);
}
}

Por que assim:

  • static create() — nunca new direto (convenção TyForge)
  • queueName define para onde o evento vai (fila, tópico, etc.)
  • Payload tipado — sem Record<string, unknown> genérico
  • Prefixo Event no nome da classe

4. DTO de Request (Application)​

O DTO recebe dados brutos da request HTTP e valida.

// src/modules/user/application/dtos/dto-req-create-user.ts
import {
Dto, FString, FEmail, FInt,
SchemaBuilder, ok, isFailure,
type ISchema, type InferProps, type InferJson,
type Result, type Exceptions,
} from "tyforge";

const createUserSchema = {
name: { type: FString },
email: { type: FEmail },
age: { type: FInt },
} satisfies ISchema;

const createUserValidator = SchemaBuilder.compile(createUserSchema);

type TDtoCreateUserProps = InferProps<typeof createUserSchema>;
type TDtoCreateUserJson = InferJson<typeof createUserSchema>;

export class DtoReqCreateUser extends Dto<TDtoCreateUserProps, TDtoCreateUserJson> {
readonly name: FString;
readonly email: FEmail;
readonly age: FInt;

protected readonly _classInfo = { name: "DtoReqCreateUser", version: "1.0.0", description: "Create user request" };

private constructor(props: TDtoCreateUserProps) {
super(props);
this.name = props.name;
this.email = props.email;
this.age = props.age;
}

static create(body: unknown): Result<DtoReqCreateUser, Exceptions> {
const result = createUserValidator.createUnknown(body);
if (isFailure(result)) return result;
return ok(new DtoReqCreateUser(result.value));
}
}

Por que assim:

  • create(body: unknown) — o controller passa req.body sem tipar
  • SchemaBuilder.compile().createUnknown() aceita unknown e valida tudo
  • Se a validação falha, retorna Result com erro descritivo — nunca throw
  • O DTO não contém lógica de negócio — só valida formato
  • Prefixo DtoReq para DTOs de request

5. DTO de Response (Application)​

// src/modules/user/application/dtos/dto-res-user-profile.ts
import {
Dto, FId, FString, FEmail, FInt, FBoolean, FAppStatus,
SchemaBuilder, ok, isFailure,
type ISchema, type InferProps, type InferJson,
type Result, type Exceptions,
} from "tyforge";
import type { User } from "../../domain/user.aggregate";

const userProfileSchema = {
id: { type: FId },
name: { type: FString },
email: { type: FEmail, expose: "private" },
age: { type: FInt },
status: { type: FAppStatus },
} satisfies ISchema;

const userProfileValidator = SchemaBuilder.compile(userProfileSchema);

type TDtoResUserProfileProps = InferProps<typeof userProfileSchema>;
type TDtoResUserProfileJson = InferJson<typeof userProfileSchema>;

export class DtoResUserProfile extends Dto<TDtoResUserProfileProps, TDtoResUserProfileJson> {
readonly id: FId;
readonly name: FString;
readonly email: FEmail;
readonly age: FInt;
readonly status: FAppStatus;

protected readonly _classInfo = { name: "DtoResUserProfile", version: "1.0.0", description: "User profile response" };
protected readonly _schema = userProfileSchema;

private constructor(props: TDtoResUserProfileProps) {
super(props);
this.id = props.id;
this.name = props.name;
this.email = props.email;
this.age = props.age;
this.status = props.status;
}

static fromDomain(user: User): Result<DtoResUserProfile, Exceptions> {
const json = user.toJSON();
const result = userProfileValidator.create(json);
if (isFailure(result)) return result;
return ok(new DtoResUserProfile(result.value));
}
}

Por que assim:

  • fromDomain(user) converte Aggregate → DTO de resposta
  • _schema = userProfileSchema habilita redação: toJSON(config, "public") esconde email
  • O controller decide qual nível de expose usar
  • Prefixo DtoRes para DTOs de response

6. Use Case (Application)​

// src/modules/user/application/use-cases/register-user.use-case.ts
import { UseCase, isFailure } from "tyforge";
import { User } from "../../domain/user.aggregate";
import type { DtoReqCreateUser } from "../dtos/dto-req-create-user";
import type { IRepositoryUser } from "../../domain/repository-user.interface";

export class RegisterUserUseCase extends UseCase<DtoReqCreateUser, User> {
protected readonly _classInfo = {
name: "RegisterUserUseCase",
version: "1.0.0",
description: "Registers a new user",
};

constructor(private readonly repository: IRepositoryUser) {
super();
}

async execute(dto: DtoReqCreateUser): Promise<User> {
const userResult = User.create({
name: dto.name.getValue(),
email: dto.email.getValue(),
age: dto.age.getValue(),
});
if (isFailure(userResult)) throw userResult.error;

const user = userResult.value;

const saveResult = await this.repository.create(user);
if (isFailure(saveResult)) throw saveResult.error;

return user;
}
}

Por que assim:

  • Recebe DTO já validado — nunca primitivos
  • Retorna domain model — nunca primitivos
  • throw na fronteira da aplicação — converte Result error em exception
  • Repository injetado via constructor (Dependency Inversion)
  • _classInfo obrigatório em toda classe concreta

7. Mapper (Application)​

// src/modules/user/application/mapper-user.ts
import { type IMapper, ok, isFailure, type Result, type Exceptions } from "tyforge";
import type { Paginated } from "tyforge";
import { User } from "../domain/user.aggregate";
import type { TUserJson } from "../domain/user.types";

export class MapperUser implements IMapper<User, TUserJson> {
toDomain(raw: TUserJson): Result<User, Exceptions> {
return User.assign(raw);
}

toDomainMany(raw: TUserJson[]): Result<User[], Exceptions> {
const users: User[] = [];
for (const item of raw) {
const result = this.toDomain(item);
if (isFailure(result)) return result;
users.push(result.value);
}
return ok(users);
}

toPersistence(domain: User): Result<TUserJson, Exceptions> {
return ok(domain.toJSON());
}

toPersistenceMany(domains: User[]): Result<TUserJson[], Exceptions> {
const items: TUserJson[] = [];
for (const domain of domains) {
const result = this.toPersistence(domain);
if (isFailure(result)) return result;
items.push(result.value);
}
return ok(items);
}

toDomainPaginated(raw: Paginated<TUserJson>): Result<Paginated<User>, Exceptions> {
const result = this.toDomainMany(raw.items);
if (isFailure(result)) return result;
return ok(new Paginated(result.value, raw.total, raw.page, raw.pageSize));
}
}

Por que assim:

  • toDomain usa User.assign() — hidratação do banco (validação leve)
  • toPersistence usa user.toJSON() — serializa para o banco
  • Prefixo Mapper (não UserMapper)
  • Mapper NÃO monta DTOs de response — isso é responsabilidade do controller

8. Repository (Infrastructure)​

// src/modules/user/infrastructure/repository-user-prisma.ts
import { ok, err, type ResultPromise, type Exceptions, ExceptionDb, FId } from "tyforge";
import type { Paginated, IPaginationParams } from "tyforge";
import type { PrismaClient } from "@prisma/client";
import type { IRepositoryUser } from "../domain/repository-user.interface";
import { User } from "../domain/user.aggregate";
import { MapperUser } from "../application/mapper-user";

export class RepositoryUserPrisma implements IRepositoryUser {
private readonly mapper = new MapperUser();

constructor(private readonly prisma: PrismaClient) {}

async findById(id: FId): ResultPromise<User | null, Exceptions> {
try {
const raw = await this.prisma.user.findUnique({ where: { id: id.getValue() } });
if (!raw) return ok(null);
return this.mapper.toDomain(raw);
} catch (error) {
return err(ExceptionDb.create("Failed to find user", error));
}
}

async create(entity: User): ResultPromise<User, Exceptions> {
try {
const persistence = this.mapper.toPersistence(entity);
if (!persistence.success) return persistence;
await this.prisma.user.create({ data: persistence.value });
return ok(entity);
} catch (error) {
return err(ExceptionDb.create("Failed to create user", error));
}
}

// ... demais métodos do IRepositoryBase
}

Por que assim:

  • Implementa interface do domain (Dependency Inversion)
  • Usa Mapper para converter entre domain e persistência
  • Erros de banco viram ExceptionDb — nunca throw genérico
  • ResultPromise — async + Result pattern
  • Prisma é detalhe de infraestrutura — domain não sabe que existe

9. API Route / Controller (Next.js)​

// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Exceptions, isFailure } from "tyforge";
import { DtoReqCreateUser } from "@/modules/user/application/dtos/dto-req-create-user";
import { DtoResUserProfile } from "@/modules/user/application/dtos/dto-res-user-profile";
import { RegisterUserUseCase } from "@/modules/user/application/use-cases/register-user.use-case";
import { RepositoryUserPrisma } from "@/modules/user/infrastructure/repository-user-prisma";
import { prisma } from "@/config/prisma";

export async function POST(request: NextRequest) {
// 1. Parse body
const body = await request.json();

// 2. Validate request via DTO
const dtoResult = DtoReqCreateUser.create(body);
if (isFailure(dtoResult)) {
return NextResponse.json(
{ error: dtoResult.error.detail },
{ status: 400 },
);
}

try {
// 3. Execute use case
const repository = new RepositoryUserPrisma(prisma);
const useCase = new RegisterUserUseCase(repository);
const user = await useCase.execute(dtoResult.value);

// 4. Map to response DTO
const responseResult = DtoResUserProfile.fromDomain(user);
if (isFailure(responseResult)) {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}

// 5. Serialize with expose level (email hidden in public)
const responseBody = responseResult.value.toJSON(undefined, "public");
return NextResponse.json(responseBody, { status: 201 });

} catch (error) {
// 6. Handle domain/application exceptions
if (error instanceof Exceptions) {
return NextResponse.json(
{ error: error.detail },
{ status: 400 },
);
}
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

Por que assim:

  • Controller é fino — só compõe: parse → validate → execute → respond
  • Zero lógica de negócio aqui
  • DtoReqCreateUser.create(body) valida input desconhecido
  • DtoResUserProfile.fromDomain(user) monta a resposta
  • toJSON(undefined, "public") esconde campos expose: "private" (email)
  • Exceptions do domain são capturadas e convertidas em HTTP response

Fluxo Completo de uma Request​

HTTP POST /api/users { name: "João", email: "joao@test.com", age: 25 }

1. route.ts (Controller)
├── request.json() → body: unknown
├── DtoReqCreateUser.create(body) → Result<DtoReqCreateUser>
│ └── SchemaBuilder validates: FString, FEmail, FInt
│ ├── TypeGuard.isString → narrowing + trim
│ └── validateRules → minLength, maxLength, regex
│
├── RegisterUserUseCase.execute(dto)
│ ├── User.create({ name, email, age })
│ │ ├── userValidator.create() → validates full schema
│ │ ├── Business rules: age >= 18
│ │ ├── FId.generate() → UUID v7
│ │ ├── new User(props) → private constructor
│ │ └── addDomainEvent(EventUserRegistered)
│ │
│ └── repository.create(user) → Prisma INSERT
│
├── DtoResUserProfile.fromDomain(user) → Result<DtoRes>
└── toJSON(config, "public") → { id, name, age, status }
(email hidden: expose "private")

HTTP 201 { id: "019d...", name: "João", age: 25, status: "active" }

Princípios Aplicados​

SOLID​

PrincípioComo TyForge aplica
S — Single ResponsibilityCada classe tem uma responsabilidade: TypeField valida, Aggregate tem regras, UseCase orquestra, Mapper converte
O — Open/ClosedNovos TypeFields estendem TypeField<T> sem modificar a base. Novas regras de lint estendem Rule
L — Liskov SubstitutionEntity substitui ValueObject em contextos que precisam de identidade. Aggregate substitui Entity
I — Interface SegregationIRepositoryRead separado de IRepositoryBase. IMapper com métodos coesos
D — Dependency InversionUseCase depende de IRepositoryUser (interface), não de RepositoryUserPrisma (implementação)

Clean Code​

RegraEnforcement
Sem anytyforge-lint rule: no-any
Sem as casttyforge-lint rule: no-cast
Sem ! assertiontyforge-lint rule: no-non-null
Sem export defaulttyforge-lint rule: no-export-default
Sem magic numbers HTTPtyforge-lint rule: no-magic-http-status
Nomes em inglêsConvenção do projeto
Prefixos consistentesF, T, I, O, Dto, DtoReq, DtoRes, Event, Exception, Repository, Mapper

Clean Architecture​

CamadaResponsabilidadeDepende de
DomainRegras de negócio, Aggregates, EventsNada (puro)
ApplicationUse Cases, DTOs, MappersDomain
InfrastructureRepositories (Prisma), APIs externasDomain (interfaces)
PresentationControllers (API routes)Application

Configuração do Linter​

# Verificar todos os arquivos
npx tyforge-lint --all

# Verificar apenas staged (pre-commit)
npx tyforge-lint --staged

# Auto-corrigir o que puder
npx tyforge-lint --fix

# Output JSON para CI
npx tyforge-lint --all --format json

# Configurar hooks de pre-commit
npx tyforge-lint --init

Checklist de Refatoração​

Fase 1: Estrutura​

  • Criar pasta src/modules/{modulo}/domain/
  • Criar pasta src/modules/{modulo}/application/
  • Criar pasta src/modules/{modulo}/infrastructure/
  • Mover lógica de negócio dos controllers para Aggregates
  • Mover validação de input para DTOs com SchemaBuilder

Fase 2: Domain​

  • Definir schemas com TypeFields (satisfies ISchema)
  • Inferir tipos via InferProps / InferJson (nunca manual)
  • Criar Aggregates com create() retornando Result
  • Criar assign() para hidratação do banco
  • Emitir Domain Events nos Aggregates
  • Mover todas as regras de negócio para o domain

Fase 3: Application​

  • Criar DTOs de request (DtoReqCreateUser)
  • Criar DTOs de response (DtoResUserProfile)
  • Criar Use Cases que recebem DTOs e retornam domain models
  • Criar Mappers para conversão Domain ↔ Persistência
  • Definir interfaces de Repository no domain

Fase 4: Infrastructure​

  • Implementar Repositories (Prisma, MongoDB, etc.)
  • Usar Mapper dentro do Repository
  • Wrapping de erros em ExceptionDb

Fase 5: Presentation​

  • Controllers finos: parse → validate → execute → respond
  • Usar toJSON(config, "public") para respostas
  • Capturar Exceptions e converter em HTTP status
  • Zero lógica de negócio nos controllers

Fase 6: Qualidade​

  • npm run typecheck — zero erros
  • npm run test — zero falhas
  • npx tyforge-lint --all — zero violações
  • Zero any, as, !, @ts-ignore, export default
  • Todos os TypeFields com prefixo F
  • Todos os tipos com prefixo T
  • Todas as interfaces com prefixo I
  • Todas as exceptions com prefixo Exception
  • Todos os events com prefixo Event

Erros Comuns a Evitar​

1. Lógica de negócio no controller​

// ERRADO
export async function POST(req: NextRequest) {
const body = await req.json();
if (body.age < 18) return NextResponse.json({ error: "Too young" }); // regra no controller!
}

// CORRETO — regra no Aggregate
static create(data: TUserJson): Result<User, Exceptions> {
if (result.value.age.getValue() < 18) {
return err(ExceptionBusiness.invalidBusinessRule("User must be at least 18"));
}
}

2. Tipos manuais em vez de inferidos​

// ERRADO
type TUserProps = { name: string; email: string; age: number };

// CORRETO — inferido do schema
type TUserProps = InferProps<typeof userSchema>;

3. Validação manual em vez de SchemaBuilder​

// ERRADO
if (typeof body.name !== "string") return error;
if (!body.email.includes("@")) return error;

// CORRETO
const result = validator.create(body);
if (isFailure(result)) return result;

4. new em TypeFields ou Domain Events​

// ERRADO
const id = new FId("...");
const event = new EventUserRegistered(...);

// CORRETO
const id = FId.create("...");
const event = EventUserRegistered.create({ ... });

5. throw no domain layer​

// ERRADO (dentro de Aggregate.create)
throw new Error("Invalid age");

// CORRETO
return err(ExceptionBusiness.invalidBusinessRule("Invalid age"));

6. Mapper montando DTO de response​

// ERRADO — mapper não deveria montar DTOs
class MapperUser {
toResponse(user: User): DtoResUserProfile { ... } // NÃO
}

// CORRETO — controller monta o DTO de response
const response = DtoResUserProfile.fromDomain(user);

Uso no Frontend (Client Components)​

TyForge funciona no browser. TypeFields, SchemaBuilder, Result pattern — tudo roda no client:

// Validação client-side com o mesmo schema do backend
"use client";
import { FString, FEmail, SchemaBuilder, isFailure, type ISchema } from "tyforge";

const formSchema = {
name: { type: FString },
email: { type: FEmail },
} satisfies ISchema;

const validator = SchemaBuilder.compile(formSchema);

export function validateForm(data: unknown) {
const result = validator.create(data);
if (isFailure(result)) {
return { valid: false, error: result.error.detail };
}
return { valid: true, data: result.value };
}

Vantagem: Mesma validação no client e no server. Zero divergência.


Referências​