TypeScript Clean Architecture: Core, Adapters e Infra

Arquitetura ruim trava manutenção, testes e mudança. Esta derivação da Clean Architecture para backend TypeScript separa Core, Adapters e Infra — com dependências apontando para dentro.

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:

  1. Chamados pelo Core — implementam pelo menos um protocol.
  2. 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:

  1. Implementar B exigiria outra dependência externa.
  2. A e B trabalham entidades/escopos diferentes.
  3. Complexidade ciclomática (ou cognitiva) sobe demais.
  4. 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).