DEV Community

Leonardo Camargo
Leonardo Camargo

Posted on

Arquitetura hexagonal

Acredito que todos já lidamos com sistemas ou parte de sistemas muito acoplados, onde fica simplesmente impossível de se testar, e há uma grande invasão de regras externas a regra de negócio.

A arquitetura hexagonal (hexagonal architecture), ou seu nome alternativo como arquitetura portas e adaptadores (ports and adapters architecture), criada por Alistair Cockburn nos anos 2000, mais precisamente em 2005 como uma alternativa à arquitetura tradicional em camadas (um grande exemplo disso seria o MVC, onde divide o sistema em três camadas) essa arquitetura visa separar as diferentes preocupações, principalmente a separação entre regras de negócio e complexidade técnica.

Dessa forma permitir que um aplicativo seja igualmente conduzido por usuários, programas, testes automatizados, e que seja desenvolvido e testado isoladamente.

Essa divisão ajuda a manter as regras de negócio independentes das tecnologias específicas e das complexidades técnicas, permitindo que elas sejam mais facilmente testadas e modificadas sem afetar as partes técnicas.

Regra de Negócio: As regras de negócio são a lógica central que define como o sistema funciona para atingir seus objetivos.

Complexidade Técnica: A complexidade técnica inclui todas as decisões e implementações relacionadas a aspectos técnicos, como comunicação com bancos de dados, interfaces de usuário, integração com serviços externos, gerenciamento de estado, entre outros.

Uma arquitetura da qual temos ouvido muito falar atualmente é a arquitetura limpa. Acredito que a arquitetura hexagonal seja sua precursora, pois é mais simplista, não determinando nomes de pastas ou padrões mais robustos de implementação. Ela segue apenas uma regra bem clara: separar a regra de negócio da complexidade técnica por meio de portas e adaptadores.

E a escolha do nome hexagonal não é por acaso, isso porque no "centro" temos as regras de negócio, enquanto os "lados" do hexágono representam as "portas" de entrada e saída que possibilitam a comunicação da lógica de negócios com o mundo exterior.

E para isso funcionar, talvez o conceito mais importante é a inversão de dependência (DI ou dependency inversion), onde minha aplicação não deve depender de adaptadores, em vez disso, ela deve depender de uma abstração, que geralmente é uma interface, e essa abstração que fala com o adaptador, por isso portas e adaptadores.

Pra que isso fique mais claro, nada melhor que um exemplo pratico. Vamos começar pelo centro da nossa aplicação, criando nossa entidade.

Vamos ter a entidade "Customer", será composta por três atributos principais: ID, nome e estado de ativação. E teremos métodos para mudar o nome do cliente, ativar e desativar. Muito simples.

export class Customer {
  private readonly _id: string
  private _name: string=''
  private _active: boolean=false

  constructor (id: string, name: string) {
    this._id = id
    this._name = name
    this.validate()
  }

  validate (): void {
    if (this._id.length === 0) {
      throw new Error('id is required')
    }
    if (this._name.length === 0) {
      throw new Error('name is required')
    }
  }

  get id (): string {
    return this._id
  }

  get name (): string {
    return this._name
  }

  isActive (): boolean {
    return this._active
  }

  changeName (name: string): void {
    this._name = name
    this.validate()
  }

  activate (): void {
    this._active = true
  }

  deactivate (): void {
    this._active = false
  }
}
Enter fullscreen mode Exit fullscreen mode

Perceba que a nossa entidade é algo único, já que possui um identificador. Ela segue o princípio da autovalidação, garantindo que os dados do cliente estejam consistentes o tempo todo.

Agora que temos nossa entidade, queremos criar um novo cliente e salvá-lo em algum lugar.

Vamos começar criando a interface do nosso repositório.

export interface ICustomerRepository {
  create: (customer: Customer) => Promise<Customer>
}
Enter fullscreen mode Exit fullscreen mode

Agora, vamos implementar o nosso repositório.

export class CustomerRepository implements ICustomerRepository {
  async create (customer: Customer): Promise<Customer> {
    const customerCollection = await MongoHelper.getCollection('customer')
    const record = await customerCollection.insertOne(customer)
    const data = record.ops[0]
    return new Customer(data.id, data.name)
  }
}
Enter fullscreen mode Exit fullscreen mode

Agora, vamos criar o componente que vai orquestrar todas essas partes: o nosso Customer Service.

Primeiro, vamos criar a interface que define os contratos que nosso serviço precisará seguir.

export interface ICustomerService {
  create: (id: string, name: string) => Promise<Customer>
}
Enter fullscreen mode Exit fullscreen mode

Em seguida, vamos implementar a classe de serviço.

Neste ponto, podemos perceber a importância de um conceito-chave na Arquitetura Hexagonal: a inversão de dependência.

A inversão de dependência significa que o nosso módulo de alto nível não depende diretamente de um módulo de baixo nível, mas sim de uma abstração. Isso resulta em um baixo acoplamento entre os diferentes componentes da nossa aplicação. Em outras palavras, o nosso código não está vinculado a detalhes específicos de implementação.

Isso significa que, no contexto do nosso Customer Service, o módulo de alto nível não precisa se preocupar com os detalhes de como os dados do cliente são salvos, seja em um banco de dados relacional, não relacional, em memória, ou em qualquer outra forma de armazenamento.

export class CustomerService implements ICustomerService {
  private readonly _customerRepository: ICustomerRepository

  constructor (customerRepository: ICustomerRepository) {
    this._customerRepository = customerRepository
  }

  async create (id: string, name: string): Promise<Customer> {
    const customer = new Customer(id, name)
    return await this._customerRepository.create(customer)
  }
}
Enter fullscreen mode Exit fullscreen mode

Ah além de proporcionar flexibilidade e facilidade de manutenção, a Arquitetura Hexagonal também oferece uma grande vantagem pela facilidade em testar.

Basta criar um modulo stub e injetar em minha classe.

interface SutTypes {
  sut: ICustomerService
  customerRepository: ICustomerRepository
}

class CustomerRepositoryStub implements ICustomerRepository {
  async create (customer: Customer): Promise<Customer> {
    return new Promise((resolve, reject) => {
      resolve(new Customer('123', 'John'))
    })
  }
}

const makeSut = (): SutTypes => {
  const customerRepository = new CustomerRepositoryStub()
  return {
    sut: new CustomerService(customerRepository),
    customerRepository
  }
}

describe('customer service', () => {
  it('should create new customer', async () => {
    const { sut } = makeSut()
    const result = await sut.create('123', 'John')
    expect(result.name).toBe('John')
  })

  it('should throw new error, if customer invalid', async () => {
    const { sut, customerRepository } = makeSut()
    const promise = sut.create('', 'John')
    await expect(promise).rejects.toThrowError('id is required')
  })

  it('should throw new error', async () => {
    const { sut, customerRepository } = makeSut()
    jest.spyOn(customerRepository, 'create').mockImplementation(async () => {
      throw new Error('any error')
    })
    const promise = sut.create('123', 'John')
    await expect(promise).rejects.toThrowError('any error')
  })
})
Enter fullscreen mode Exit fullscreen mode

Agora que já criamos a entidade, o módulo responsável por salvar os dados e o módulo que lida com a regra de criação do usuário, é hora de criar o cliente que interagirá com a nossa aplicação.

Para este exemplo, vamos desenvolver uma API REST utilizando o framework Express. Vamos criar uma rota específica para a criação de um cliente.

Para lidar com a criação do cliente, teremos um 'handler' (gerenciador) responsável por receber uma requisição HTTP e retornar o usuário como resposta. Este 'handler' será a ponte entre a nossa aplicação e o mundo exterior, garantindo que os clientes possam interagir com os nossos serviços de forma simples e eficaz.

Para manter a comunicação entre a nossa API e os clientes bem estruturada, vamos criar interfaces para as requisições (requests) e respostas (responses).

export interface IHttpRequest {
  params?: Record<string, string>
  body?: any
}

export interface IHttpResponse {
  status: number
  body: any
}
Enter fullscreen mode Exit fullscreen mode

Vamos criar uma interface chamada IHandler que descreverá os métodos e comportamentos esperados de um handler.

export interface IHandler {
  handle: (request: IHttpRequest) => Promise<IHttpResponse>
}
Enter fullscreen mode Exit fullscreen mode

Agora é hora de implementar o nosso 'handler' para criar um novo usuário com base nos dados fornecidos na requisição.

export class CreateCustomerHandler implements IHandler {
  private readonly _customerService: ICustomerService

  constructor (customerService: ICustomerService) {
    this._customerService = customerService
  }

  async handle (request: IHttpRequest): Promise<IHttpResponse> {
    const data = request.body

    const customer = await this._customerService.create(
      uuidv4(),
      data.name
    )

    const httpResponse = {
      status: 200,
      body: {
        id: customer.id,
        name: customer.name,
        active: customer.isActive()
      }
    }

    return httpResponse
  }
}

export const makeCreateCustomerHandler = (): IHandler => {
  const customerRepository = new CustomerRepository()
  const customerService = new CustomerService(customerRepository)
  return new CreateCustomerHandler(customerService)
}
Enter fullscreen mode Exit fullscreen mode

Agora, estamos prontos para criar o nosso servidor web com Express.

export class Server {
  private readonly server: Express
  private readonly port=3000

  constructor () {
    this.server = express()
  }

  setupMiddleware (): void {
    this.server.use(bodyParser.json())
  }

  setupRoutes (): void {
    const router = Router()
    const createCustomerHandler = makeCreateCustomerHandler()
    router.post('/customer', adapterRouter(createCustomerHandler))
    this.server.use(router)
  }

  public async run (): Promise<void> {
    await this.server.listen(this.port, () => {
      console.log(`The server is listening on port ${this.port}`)
      this.setupMiddleware()
      this.setupRoutes()
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Para garantir a consistência e a clareza em nossa aplicação, vamos converter as rotas definidas no Express em interfaces IRequest (requisição) e IResponse (resposta).

export const adapterRouter = (handler: IHandler): any => {
  return async (req: Request, res: Response) => {
    const httpRequest: IHttpRequest = {
      params: req.params,
      body: req.body
    }
    const httpResponse = await handler.handle(httpRequest)
    if (httpResponse.status === 200) {
      res.status(httpResponse.status).json(httpResponse.body)
    } else {
      res.status(httpResponse.status).json({
        error: httpResponse.body.message
      })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Agora, estamos no estágio final do nosso projeto: criar um módulo para iniciar a nossa aplicação.

MongoHelper.connect(process.env.MONGO_URL)
  .then(async () => {
    console.log('db connected')
    const server = makeServer()
    void server.run()
  })
  .catch(console.error)
Enter fullscreen mode Exit fullscreen mode

Está tudo pronto!

Agora, podemos criar um comando de inicialização para facilitar o processo:

"dev": "nodemon ./src/server.ts",
Enter fullscreen mode Exit fullscreen mode

E ao executar o comando deve inciar a aplicação:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Agora, com o coração da nossa aplicação, o banco de dados e o cliente que acessará os serviços, estamos prontos para concluir o processo.

Para finalizar, podemos criar um cliente de teste para interagir com a nossa API. Utilizaremos o comando curl para enviar uma solicitação POST e criar um novo cliente.

Execute o seguinte comando no seu terminal:

curl -X POST -H "Content-Type: application/json" -d '{"name": "leonardo camargo"}' http://localhost:3000/customer
Enter fullscreen mode Exit fullscreen mode

E, como resultado, obtivemos a seguinte resposta da nossa API:

{"id":"061c4938-6599-4540-b7b0-64cfa4b307a5","name":"leonardo camargo","active":false}
Enter fullscreen mode Exit fullscreen mode

E conferindo no nosso banco, temos o cliente salvo ;)
Resultado no banco de dados

Este foi um exemplo muito simples de como criar um cliente em nossa aplicação, mas perceba como a Arquitetura Hexagonal nos permite delimitar claramente onde estão as regras de negócio e a complexidade de implementação.

A estrutura da aplicação é organizada de forma que as camadas de alto nível não dependam das camadas de baixo nível, seguindo o princípio da inversão de dependência. Isso proporciona flexibilidade, facilidade de teste e manutenção, além de facilitar a troca de componentes como diferentes repositórios ou adicionar novos handlers para diferentes interfaces, como uma CLI para criar clientes.

Há muitas possibilidades para continuar a desenvolver a aplicação. Por exemplo, poderíamos implementar funcionalidades para ativar e desativar nossos clientes, criar autenticação e muito mais.

Lembrando que o objetivo deste conteúdo é apresentar a Arquitetura Hexagonal e compartilhar o conhecimento que tenho estudado. Se você tiver alguma dúvida, dica ou sugestão de melhoria, fique à vontade para compartilhar 😉.

Se você quiser ver o projeto completo, você pode encontrá-lo no GitHub aqui."

Top comments (0)