Arquitetura hexagonal
Leonardo Camargo
Posted on October 12, 2023
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
}
}
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>
}
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)
}
}
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>
}
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)
}
}
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')
})
})
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
}
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>
}
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)
}
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()
})
}
}
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
})
}
}
}
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)
Está tudo pronto!
Agora, podemos criar um comando de inicialização para facilitar o processo:
"dev": "nodemon ./src/server.ts",
E ao executar o comando deve inciar a aplicação:
npm run dev
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
E, como resultado, obtivemos a seguinte resposta da nossa API:
{"id":"061c4938-6599-4540-b7b0-64cfa4b307a5","name":"leonardo camargo","active":false}
E conferindo no nosso banco, temos o cliente salvo ;)
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."
Posted on October 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.