Desenvolvimento de software muda o tempo todo. Arquitetura fraca vira manutenção cara, feature lenta, teste difícil e bug difícil de isolar. Vale investir em uma estrutura que suporte evolução sem reescrever o sistema a cada pressão do negócio.
Um pouco de história
Clean Architecture é o nome que Robert C. Martin (Uncle Bob) deu, em 2012, no livro Clean Architecture: A Craftsman’s Guide to Software Structure and Design. A proposta foge da rigidez de arquiteturas acopladas a framework e banco: o núcleo fica estável; detalhes externos mudam. A ideia bebe de DDD, SOLID, Onion Architecture e Hexagonal Architecture.
Proposta geral
Este artigo descreve a Clean Architecture e uma derivação prática para backends em TypeScript: três camadas — Core, Adapters e Infra.
- Core — regra de negócio e entidades do domínio. Camada mais interna.
- Infra — conexões externas: repositórios concretos, controllers REST, módulos de DI, boilerplate de framework.
- Adapters — intermediação nos dois sentidos. Controller não chama usecase “cru”: passa por um serviço. Usecase não fala com o banco: fala com um protocolo que um adapter (repositório, connector, handler) implementa.
Cada camada tem capacidades e restrições diferentes; SOLID pesa mais no Core. Serve para CRUD HTTP e para sistemas com vários frameworks e canais.
Benefícios concretos: responsabilidades claras (leitura e manutenção), flexibilidade para trocar plugin sem reescrever regra, e testes isolados por camada.
Guia de camadas
Exemplo: CRUD de usuários via REST com NestJS. Detalhes de instalação ficam de fora. Escrita core-to-infra (de dentro para fora).
Core
No desenho clássico, domain e entities ficam muito próximas. Aqui elas formam o Core: tudo o que a regra de negócio é — funcionalidades e representações do domínio.
No exemplo, a entidade principal é Usuário (id, name), em core/entities.
Entities
// core/entities/UserEntity.ts
export interface UserEntityProps {
id?: string;
name: string;
}
export class UserEntity {
constructor(private readonly props: UserEntityProps) {}
get id(): string {
return this.props.id ?? "";
}
get name(): string {
return this.props.name;
}
}
A entidade recebe props tipadas e expõe getters. Depende de uma interface que qualquer DTO de transferência pode satisfazer depois.
Features e usecases
O CRUD precisa criar, buscar, atualizar e remover. No Core, cada usecase implementa um contrato (feature) com um único método público — alinhado a Liskov, aberto/fechado, segregação de interface e responsabilidade única. O usecase não acessa o banco: conhece protocols que descrevem a ação externa (inversão de dependência).
Cadastro: nome obrigatório; se já existir, erro; se não, retorna UserEntity.
- contrato
CreateUser - implementação
CreateUserUsecase
No TypeScript, classe abstrata com métodos abstratos funciona como contrato e valor — útil para DI (const createUserSymbol = CreateUser):
// core/features/CreateUser.ts
export abstract class CreateUser {
abstract execute(name: string): Promise<UserEntity>;
}
// core/usecases/CreateUserUsecase.ts
export class CreateUserUsecase implements CreateUser {
constructor(
private readonly createUserProtocol: CreateUserProtocol,
private readonly getByNameProtocol: GetUserByNameProtocol,
) {}
async execute(name: string): Promise<UserEntity> {
const existsName = await this.getByNameProtocol.getByName(name);
if (existsName) {
throw new UserAlreadyExistsException(
`the name ${name} already exists`,
);
}
return this.createUserProtocol.register(name);
}
}
O usecase define o quê (validar nome, registrar). Não define como buscar ou persistir. A regra fica independente de lib, framework e banco.
Cuidado: usecase que só delega ao protocol sem validar pode estar empurrando regra de negócio para o adapter. Em CreateUserUsecase, a checagem de nome duplicado é obrigação do Core.
Exceptions
UserAlreadyExistsException pertence ao Core: fluxo inválido da regra também é regra. Cada falha mapeada a uma exceção conhecida ajuda manutenção. Base com code (depois vira status HTTP na borda):
// core/exceptions/IBaseException.ts
export abstract class IBaseException extends Error {
code: number;
constructor(message: string) {
super(message);
}
}
// core/exceptions/UserAlreadyExistsException.ts
export class UserAlreadyExistsException extends IBaseException {
constructor(message?: string) {
super(message ?? "User already exists");
this.code = 400;
}
}
O usecase lança exceções; não as trata. Mapear tipo desconhecido → tipo conhecido fica em adapter ou infra.
Protocols
CreateUserProtocol e GetUserByNameProtocol são contratos de acesso a dispositivo externo. Protocol existe para informar ou disparar ação externa — não para processar regra de negócio. Preferência: um método público por protocol.
// core/protocols/CreateUserProtocol.ts
export abstract class CreateUserProtocol {
abstract register(name: string): Promise<UserEntity>;
}
// core/protocols/GetUserByNameProtocol.ts
export abstract class GetUserByNameProtocol {
abstract getByName(name: string): Promise<UserEntity | null>;
}
O Core é o centro; a camada de adaptação liga o resto.
Adapter
Adapters controlam o tráfego bidirecional: externo → regra e regra → externo. Adaptam objetos, parâmetros e exceções — o mesmo espírito do padrão Adapter.
Dois grupos:
- Chamados pelo Core — implementam pelo menos um protocol.
- Chamados pela Infra — em geral services.
Connectors, handlers e repositories
Classes que implementam protocols. Cada uma adapta um dispositivo externo (ORM, cliente HTTP, fila, filesystem).
Convenção de nomes:
- Repositories — protocol ligado a banco (vocabulário familiar).
- Connectors — retornam dados sem ser “tabela” (ex.:
ClientHttpFetchConnector,ClientHttpAxiosConnector). - Handlers — processam sem retorno síncrono (ex.: publicar em Kafka).
Outros nomes são válidos; o critério é um adapter por dispositivo.
No CRUD, só repository (mock):
// adapters/repositories/UsersMockRepository.ts
export class UsersMockRepository
implements
GetUserByIdProtocol,
GetUserByNameProtocol,
CreateUserProtocol,
UpdateUserProtocol,
DeleteUserProtocol
{
private db: DbConnector;
constructor() {
this.db = mockDbConnector;
}
async getById(id: string): Promise<UserEntity> {
return this.db.users.getById(id);
}
async getByName(name: string): Promise<UserEntity | null> {
return this.db.users.getByName(name);
}
async register(name: string): Promise<UserEntity> {
return this.db.users.register(name);
}
async update(id: string, name: string): Promise<UserEntity> {
return this.db.users.update(id, name);
}
async delete(id: string): Promise<void> {
return this.db.users.delete(id);
}
}
Mock do conector:
export const mockDbConnector: DbConnector = {
users: {
getById: async (id: string) =>
Promise.resolve(new UserEntity({ id, name: "Test" })),
getByName: async (name: string) =>
Promise.resolve(new UserEntity({ id: "1", name })),
register: async (name: string) =>
Promise.resolve(new UserEntity({ id: "2", name })),
update: async (id: string, name: string) =>
Promise.resolve(new UserEntity({ id, name })),
delete: async (_id: string) => Promise.resolve(),
},
profiles: {
getById: async (_id: string) => Promise.resolve(null),
getByName: async (_name: string) => Promise.resolve(null),
register: async (_name: string) => Promise.resolve(null),
update: async (_id: string, _name: string) => Promise.resolve(null),
delete: async (_id: string) => Promise.resolve(),
},
};
UserEntity (Core) não é a tabela do banco. São coisas diferentes.
“Implementar vários protocols fere o S do SOLID?” Depende. Separar cada protocol em classe própria maximiza SRP. Manter um repository por entidade/agregado também é coerente: o escopo é “dados de User neste conector”. Critérios práticos para não juntar A e B na mesma classe:
- Implementar B exigiria outra dependência externa.
- A e B trabalham entidades/escopos diferentes.
- Complexidade ciclomática (ou cognitiva) sobe demais.
- A classe passa de ~500 linhas — limiar subjetivo; use senso de time.
Com DI e segregação de interface, CreateUserUsecase só conhece CreateUserProtocol e GetUserByNameProtocol. Em runtime pode receber a mesma instância de UsersMockRepository nos dois parâmetros — sem acoplar à classe concreta.
Services
Services adaptam chamadas da Infra para o Core: REST → service → usecase → protocol → repository → banco. O mesmo desenho vale para handler de fila ou resolver GraphQL: entrada diferente, service no meio.
export class UserService {
constructor(
private createUserUsecase: CreateUser,
private updateUserUsecase: UpdateUser,
private deleteUserUsecase: DeleteUser,
private getUserUsecase: GetUser,
) {}
async getUser(id: string): Promise<UserEntity> {
try {
return await this.getUserUsecase.execute(id);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async createUser(name: string): Promise<UserEntity> {
try {
return await this.createUserUsecase.execute(name);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async updateUser(id: string, name: string): Promise<UserEntity> {
try {
return await this.updateUserUsecase.execute(id, name);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async deleteUser(id: string): Promise<void> {
try {
return await this.deleteUserUsecase.execute(id);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
}
O service depende das features (contratos), não das classes concretas de usecase. Mapeia exceção de Core para RestError que a Infra traduz em resposta HTTP.
Boa prática: services só para Infra; um service por “instrumento” de entrada (controller HTTP ≠ handler de fila), salvo middleware do mesmo stack que reutiliza o mesmo service.
Infra
Framework, DI, controllers, DTOs e boilerplate que não é negócio nem adapter.
Controller NestJS:
// infra/controllers/UserController.ts
@Controller("users")
export class UserController {
constructor(private service: UserService) {}
@Get(":id")
async getUser(@Param("id") id: string): Promise<UserEntity> {
return this.service.getUser(id);
}
@Post()
async createUser(@Body() user: UserDto): Promise<UserEntity> {
return this.service.createUser(user.name);
}
@Put(":id")
async updateUser(
@Param("id") id: string,
@Body() user: UserDto,
): Promise<UserEntity> {
return this.service.updateUser(id, user.name);
}
@Delete(":id")
async deleteUser(@Param("id") id: string): Promise<void> {
return this.service.deleteUser(id);
}
}
DTO:
// infra/dtos/UserDto.ts
export class UserDto implements UserEntityProps {
id?: string;
name: string;
constructor(props: UserEntityProps) {
this.id = props.id;
this.name = props.name;
}
}
Módulo de DI:
// infra/modules/UserModule.ts
@Module({
imports: [],
controllers: [UserController],
providers: [
UserService,
{ provide: CreateUser, useClass: CreateUserUsecase },
{ provide: UpdateUser, useClass: UpdateUserUsecase },
{ provide: DeleteUser, useClass: DeleteUserUsecase },
{ provide: GetUser, useClass: GetUserUsecase },
{ provide: CreateUserProtocol, useClass: UserPrismaRepository },
{ provide: UpdateUserProtocol, useClass: UserPrismaRepository },
{ provide: DeleteUserProtocol, useClass: UserPrismaRepository },
{ provide: GetUserByNameProtocol, useClass: UserPrismaRepository },
{ provide: GetUserByIdProtocol, useClass: UserPrismaRepository },
],
})
export class UserModule {}
Infra é mais “livre”: depende do ferramental e existe para sustentar o Core.
Fluxo de execução
Controller (Infra) → Service (Adapter) → Usecase (Core) → Protocol (contrato) → Repository (Adapter) → banco (Infra). O usecase não importa o repository concreto; o service não importa a classe concreta do usecase. A seta de dependência aponta para dentro.
CRUD complementar
Além do cadastro:
Busca
// core/features/GetUser.ts
export abstract class GetUser {
abstract execute(id: string): Promise<UserEntity>;
}
// core/usecases/GetUserUsecase.ts
export class GetUserUsecase implements GetUser {
constructor(private readonly getProtocol: GetUserByIdProtocol) {}
async execute(id: string): Promise<UserEntity> {
return this.getProtocol.getById(id);
}
}
Atualização
// core/features/UpdateUser.ts
export abstract class UpdateUser {
abstract execute(id: string, name: string): Promise<UserEntity>;
}
// core/usecases/UpdateUserUsecase.ts
export class UpdateUserUsecase implements UpdateUser {
constructor(
private readonly updateProtocol: UpdateUserProtocol,
private readonly getByIdProtocol: GetUserByIdProtocol,
) {}
async execute(id: string, name: string): Promise<UserEntity> {
const exists = await this.getByIdProtocol.getById(id);
if (!exists) {
throw new UserNotExistsException(`User with id ${id} not exists`);
}
return this.updateProtocol.update(id, name);
}
}
Deleção
// core/features/DeleteUser.ts
export abstract class DeleteUser {
abstract execute(id: string): Promise<void>;
}
// core/usecases/DeleteUserUsecase.ts
export class DeleteUserUsecase implements DeleteUser {
constructor(
private readonly deleteProtocol: DeleteUserProtocol,
private readonly getByIdProtocol: GetUserByIdProtocol,
) {}
async execute(id: string): Promise<void> {
const exists = await this.getByIdProtocol.getById(id);
if (!exists) {
throw new UserNotExistsException(`User with id ${id} not exists`);
}
return this.deleteProtocol.delete(id);
}
}
Exception e protocols restantes
export class UserNotExistsException extends IBaseException {
constructor(message: string) {
super(message);
this.code = 404;
}
}
// core/protocols/GetUserByIdProtocol.ts
export abstract class GetUserByIdProtocol {
abstract getById(id: string): Promise<UserEntity>;
}
// core/protocols/UpdateUserProtocol.ts
export abstract class UpdateUserProtocol {
abstract update(id: string, name: string): Promise<UserEntity>;
}
// core/protocols/DeleteUserProtocol.ts
export abstract class DeleteUserProtocol {
abstract delete(id: string): Promise<void>;
}
Conclusão
Separar Core, Adapters e Infra deixa a regra de negócio testável sem Nest, Prisma ou HTTP. Trocar banco ou canal de entrada vira troca de adapter e wiring de DI — não reescrita do usecase. O custo é mais arquivos e disciplina de fronteira; o ganho aparece quando o sistema precisa mudar sem arrastar o domínio junto.
Publicado originalmente no Medium (15/03/2023).