Como funciona o DDD e a Clean Architecture - De maneira simples para inciantes.

joão burgarelli - Sep 21 - - Dev Community

Antes de tudo, é importante salientar que o intuito deste artigo não é ser completamente objetivo, mas sim ilustrar, por meio de um exemplo, como uma parte da aplicação do TCC Salus foi implementada utilizando DDD (Domain-Driven Design) e Clean Architecture. Pode-se observar que, por exemplo, na minha implementação da Clean Architecture, algumas nomenclaturas diferem ligeiramente, mas essas variações não afetam a lógica da arquitetura. Gostaria de enfatizar que serei um pouco prolixo, pois o objetivo aqui é deixar as explicações o mais claras possível.

DDD e Clean Architecture?

É importante dizer que Clean Architecture e DDD são coisas distintas. Enquanto Clean Architecture está relacionada à arquitetura de software e à capacidade de organizar o código de modo que ele seja independente, DDD está relacionado ao design de software, à modelagem e ao entendimento profundo do domínio do negócio. O principal objetivo do DDD é criar um modelo que reflita com precisão as regras, comportamentos e interações dentro do contexto da aplicação, centrado no entendimento e colaboração entre desenvolvedores e especialistas do domínio (domain experts).

Clean Architecture

Pense no software de maneira independente, assim como dirigir um carro. Quando você aprendeu a dirigir, independentemente de qual carro tenha sido, hoje, com esse conhecimento adquirido, qualquer carro que você for dirigir – desde que tenha um banco, pedais para acelerar, frear e embreagem, um volante e um câmbio – você é capaz de conduzir. Ou seja, sua habilidade de dirigir não está vinculada a um carro específico, mas sim aos elementos fundamentais que todos os carros possuem.

No desenvolvimento de software, buscamos esse mesmo grau de independência. Pense, por exemplo, nos bancos de dados relacionais como diferentes tipos de carros. Temos o PostgreSQL, o MySQL, o Oracle Database, entre outros. Inicialmente, você pode optar por um "carro" como o MySQL, mas, se precisar trocar para outro "modelo" como o PostgreSQL, essa mudança deve ser tão simples quanto trocar de carro. A essência de "dirigir" (ou, no caso, gerenciar dados) permanece a mesma, independentemente de qual banco de dados você esteja usando, contanto que os princípios fundamentais sejam seguidos.

Assim como trocar de um carro para outro não muda sua capacidade de dirigir, a troca de um banco de dados para outro não deve mudar a essência do seu sistema. O objetivo é que a transição entre diferentes tecnologias seja rápida e fácil. Isso nos traz independência tecnológica, manutenibilidade e flexibilidade, evitando que todo o sistema dependa diretamente de uma escolha específica de banco de dados, assim como dirigir não depende de um carro específico.

A Clean Architecture nos ajuda a atingir esse nível de abstração. Ele separa a lógica de negócios do software das decisões técnicas, como a escolha de qual banco de dados usar. Assim como sua habilidade de dirigir é independente do modelo de carro, a Clean Architecture permite que a lógica de negócios seja independente da implementação técnica, como qual banco de dados está sendo utilizado. O foco é garantir que, se você precisar trocar de tecnologia, seja um banco de dados, seja o framework utilizado, provedores de cloud, bibliotecas relacionadas à autenticação, QUALQUER COISA, isso seja algo natural, sem comprometer a eficiência e funcionalidade do sistema, promovendo flexibilidade e facilidade na manutenção. Em resumo, sua aplicação não deve estar vinculada a uma tecnologia, assim como sua habilidade em dirigir não deve estar vinculada a um modelo de carro especifico.

Image description

A imagem acima 'e muito difundida e conhecida ao se falar de Clean architecture. Quando se constroi um aplicacao utilizando esse tipo de arquitetura, costuma-se dividir essa determinada aplicacao em camada e vou explicar um pouco dela para ter-se mais embasamento do que esta sendo feito no Salus. O que esta entre parentese 'e o nome que foi dado para a arquitetura do Salus.

  1. Setas
    • Como pode-se ver do lado esquerdo ao meio da imagem, tem-se uma seta que está "entrando" em direção às camadas mais internas da aplicação. Essa seta representa a requisição do usuário (request) no nosso servidor. Essa requisição vai passar pelas respectivas camadas até chegar ao núcleo da aplicação e, ao final, obter um retorno.

Image description

  1. Camada Azul ( Infra )

    • A camada representada pela cor azul é a mais externa da aplicação. Nela, ocorrem conexões com serviços externos, banco de dados, frameworks, serviços de nuvem, UI, etc. É a porta de entrada da aplicação.
  2. Camada Verde ( Presentation )

    • A camada representada pela cor verde é reposavel, principalmente, por conter os Controllers da aplicacao. Quando um usuario faz uma requesicao para nossa aplicao, geralmente, esse endpoint, vai estar vinculado a um Controller que recebe essass requisições e é responsável por orquestrar o fluxo de dados, validando entrada da requisição e chamando o Use-Case adequado para executar a lógica de negócio.
  3. Camada Rosa ( Application )

    • É responsável, principalmente, pelos Use Cases, que representam a lógica de negócio da aplicação. Os Use Cases contêm a lógica central da aplicação e são independentes de interfaces externas, o que garante maior flexibilidade e reutilização do código. Por exemplo, se sua aplicação possui contas para os usuários, um Use Case poderia ser como criar um usuário.
  4. Camada amarela ( Enterprise )

    • Onde as entidades se localizam, as entidades representam os objetos de negócios mais importantes dentro do sistema, contendo as regras e lógicas fundamentais. Elas formam a base das operações e são os componentes menos suscetíveis a mudanças, mesmo diante de alterações externas, garantindo a estabilidade dos conceitos centrais do sistema. Por exemplo,em um sistema bancário, onde "Conta Bancária" é uma entidade central.

Como tudo isso esta sendo aplicado no Salus:

Ser'a utilizado como exemplo o dominio user e caso de uso de crirar um usuario em si.

estrutra das pastas:

Image description

Pasta Core:

Image description

Como ela 'e responsavel por conter partes essenciais e reutilizáveis do domínio da aplicação, entao, geralmente, essas parte voce colocara dentro dessa pasta. No caso, tem-se um arquivo entity.ts que pode ser utilizado por qualquer entendidade do sistema e um arquivo UniqueEntityId.ts que encapsula a lógica para gerar um identificador único.

Dentro dela, tem-se, dois arquivo:

Entity.ts

import UniqueEntityId from './UniqueEntityId.ts';

export default class Entity<Props> {
  private _id: UniqueEntityId;

  protected props: Props;

  get id() {
    return this._id;
  }

  constructor(props: Props) {
    this.props = props;
    this._id = new UniqueEntityId();
  }
}

Enter fullscreen mode Exit fullscreen mode

Essa é uma classe genérica que, no seu construtor, faz a atribuição automática das props (propriedades) recebidas e também gera um ID único para cada instância. Essa funcionalidade é especialmente útil para qualquer classe que herdar de Entity. Basicamente, a classe contribui com principio do encapsulamento, automatiza as atribuições de propriedades e gera um ID único automaticamente.

É importante lembrar que, sempre que uma classe é instanciada, o construtor é chamado automaticamente. Isso significa que qualquer classe futura que utilizar Entity terá essa funcionalidade automática: as propriedades serão atribuídas e um ID único será gerado sem necessidade de código adicional.

import { randomUUID } from 'node:crypto';

export default class UniqueEntityId {
  private value: string;

  toString() {
    return this.value;
  }

  constructor() {
    this.value = randomUUID();
  }
}

Enter fullscreen mode Exit fullscreen mode

Essa classe, UniqueEntityId, também possui um construtor que é responsável, assim que a classe é instanciada, por gerar IDs únicos. A classe garante que cada instância terá um valor de ID único.

Como foi visto na explicação anterior, dentro do construtor da classe Entity, há uma instância da classe UniqueEntityId. Isso significa que, quando uma instância de Entity é criada, ela automaticamente instancia a classe UniqueEntityId, que se encarrega de gerar um ID exclusivo para aquela entidade. Dessa forma, a geração de IDs é feita de maneira automática e transparente sempre que a classe Entity é utilizada.

Pasta Enterprise:

Image description

Responsavel por conter as entidades do domínio do sistema. Essas entidades são representações dos conceitos principais do negócio e encapsulam a lógica de negócio e as regras associadas a essas entidades. No que sera apresentado, um exemplo crítico é a entidade User, que é essencial para o funcionamento do sistema Salus, pois sem usuários, o sistema não teria propósito. É importante ressaltar que o Salus será utilizado por profissionais de saúde regulamentados. Imagine se a aplicação fosse desenvolvida sem um sistema de usuários, permitindo que qualquer pessoa acessasse as informações sensíveis disponíveis. Isso não apenas comprometeria a segurança dos dados, mas também a privacidade dos pacientes e a conformidade com as regulamentações de proteção de dados. Contudo, para algumas aplicacoes, por exemplo, uma calculadora online, nao faria sentido criar um usuario.

import Entity from '@/core/entities/Entity.ts';

export interface IUserProps{
  cpf: string;
  password: string;
  name: string;
  createdAt?: Date;
  updatedAt?: Date;
}

export default class User extends Entity<UserProps> {
  constructor(props: UserProps) {
    super(props);
    this.props.createdAt = this.props.createdAt || new Date();
    this.props.updatedAt = new Date();
  }

  get cpf() {
    return this.props.cpf;
  }

  get password() {
    return this.props.password;
  }

  set password(password: string) {
    this.props.password = password;
  }

  get name() {
    return this.props.name;
  }

  set name(name: string) {
    this.props.name = name;
    this.props.updatedAt = new Date();
  }

  get createdAt() {
    return this.props.createdAt;
  }

  get updatedAt() {
    return this.props.updatedAt;
  }
}
Enter fullscreen mode Exit fullscreen mode

Como se pode ver, a classe User possui uma interface (conhecida como contrato também) que contém alguns atributos que todo usuário (classe User) da aplicação deve ter (como cpf, password, name, etc.), e ela estende Entity. A classe User recebe parâmetros chamados props, do tipo IUserProps, garantindo que as propriedades passadas para a classe User tenham o formato definido na interface.

No início da classe, temos um construtor e, dentro desse construtor, há um super(props). Esse super é um método nativo do TypeScript que é responsável por chamar o construtor da classe pai, que, neste caso, é a classe Entity, e repassar para ele como propriedades props, que são o que a classe User recebe. Conforme foi visto anteriormente, a classe Entity é responsável por fazer todas as atribuições de forma automática e também por criar um ID, além de, claro, auxiliar no encapsulamento. No corpo da classe, temos métodos getters que são responsáveis por, caso necessário, pegar as respectivas informações atribuídas.

OBS.:

  1. Uma interface é responsável por indicar o que uma classe deve implementar. Ela especifica quais propriedades e/ou métodos um objeto deve ter, sem implementar a lógica por trás desses métodos. Portanto, conforme pode-se ver, o que está dentro da interface foi implementado.
  2. Vale lembrar que a classe pai também é conhecida como superclasse e as classes filhas como subclasses. Uma maneira para te ajudar a lembrar o que o metodo Super faz.
  3. Não se preocupe com os atributos createdAt e updatedAt.

Pasta application

Image description

Resposavel por conter, principalmente, os useCases, que representam a lógica de negócio da aplicação, todavia, como pode se ver, há outras pastas também, uma chamada __tests__, que contém o repositório de teste da aplicação para os useCases, uma chamada utils que pode conter coisas úteis para os useCases, como tratamento de erros personalizados, os contratos das operações que podem ser executadas no banco de dados (o arquivo que está dentro de application/repositories), etc.

application/utils/repositoies/ICreateUserRepository.ts

import User from '@/domain/user/enterprise/entities/User.ts';

export interface ICreateUserRepository {
  create(user: User): Promise<User>;
}

Enter fullscreen mode Exit fullscreen mode

Essa interface deve ser implementada por uma classe que será responsável por criar um usuário no repositório, como um banco de dados.

application/__tests__/repositoies/InMemoryUsersRepository.ts

import User from '../../../enterprise/entities/User.ts';
import { ICreateUserRepository } from '../../utils/repositories/ICreateUserRepository.ts';

export default class InMemoryUsersRepository implements ICreateUserRepository {
  public items: User[] = [];

  async create(user: User): Promise<User> {
    this.items.push(user);

    return user;
  }
}

Enter fullscreen mode Exit fullscreen mode

Este arquivo foi criado como uma "simulação"/representacao de banco de dados, utilizando um banco de dados em memória. Esse tipo de implementação possui algumas vantagens, inclusive para testes e prototipação. Vou deixar um link no final com mais informações sobre isso.

Aqui, a classe InMemoryUsersRepository implementa a interface ICreateUserRepository, o que significa que ela deve seguir o contrato definido por essa interface, que, neste caso, contém apenas o método create.

Dentro da classe, o método create recebe um objeto User e o adiciona ao array items, que é do tipo User[]. Este array simula o armazenamento dos dados na memória. Ao final, o método retorna o próprio usuário que foi criado.

application/useCases/CreateUserUseCase.ts

import User from '../../enterprise/entities/User.ts';
import { ICreateUserRepository } from '../utils/repositories/ICreateUserRepository.ts';

interface CreateUserUseCaseRequest {
  cpf: string;
  name: string;
  password: string;
}

interface CreateUserUseCaseResponse {
  user: User;
}

export default class CreateUserUseCase {
  constructor(private createUserRepository: ICreateUserRepository) {}

  async execute({
    cpf,
    name,
    password,
  }: CreateUserUseCaseRequest): Promise<CreateUserUseCaseResponse> {
    const user = await this.createUserRepository.create(new User({ cpf, name, password }));

    return { user };
  }
}

Enter fullscreen mode Exit fullscreen mode

CreateUserUseCaseRequest e o CreateUserUseCaseResponse nos auxiliam na tipagem. O CreateUserUseCaseRequest é o que se espera receber, e o CreateUserUseCaseResponse é o que se espera retornar. Dentro do construtor da classe CreateUserUseCase, há a inicialização de um atributo chamado createUserRepository, que é do tipo ICreateUserRepository. Como foi visto anteriormente, é um contrato/interface, então, pode-se concluir, pela tipagem, que haverá um atributo chamado createUserRepository que terá um método create.

Dentro do método execute, há uma variável chamada user que recebe a chamada do método create do repositório createUserRepository (atributo que foi inicializado no construtor). O método create é responsável por persistir os dados do usuário, e ele recebe como argumento uma nova instância da classe User, que é criada com os dados fornecidos pelo CreateUserUseCaseRequest, ou seja, CPF, nome e senha. Após a criação do usuário, o método execute retorna um objeto contendo o usuário recém-criado, seguindo o formato definido pela interface.

application/useCases/CreateUserUseCase.spec.ts

import InMemoryUsersRepository from '../__tests__/repositories/InMemoryUsersRepository.ts';
import CreateUserUseCase from './CreateUserUseCase.ts';

let inMemoryUsersRepository: InMemoryUsersRepository;
let sut: CreateUserUseCase;

describe('create user use case', () => {
  beforeEach(() => {
    inMemoryUsersRepository = new InMemoryUsersRepository();
    sut = new CreateUserUseCase(inMemoryUsersRepository);
  });

  it('should be able to create a user', async () => {
    const { user } = await sut.execute({
      cpf: '12345678900',
      name: 'John Doe',
      password: 'password',
    });

    expect(user.cpf).toBe('12345678900');
  });

  it('should be allocate a new user into repository', async () => {
    const { user } = await sut.execute({
      cpf: '12345678900',
      name: 'John Doe',
      password: 'password',
    });

    expect(user).toStrictEqual(inMemoryUsersRepository.items[0]);
  });
});

Enter fullscreen mode Exit fullscreen mode

Apos as importancoes, ha duas variaveis sendo declaradas e apos o ":" ha o tipo delas.

Geralmente, em um teste, você terá as funções describe e it. A função describe serve como um agrupador para os testes, definindo um tema/título do que será testado. Já as funções it representam os casos de teste individuais, descrevendo comportamentos específicos que devem ser verificados.

Logo abaixo do describe, temos uma função chamada beforeEach, que é responsável por, antes de cada it, executar o código que foi passado. Como pode ser visto, dentro do beforeEach há a instância do banco de dados em memória que foi visto anteriormente (em application/tests/repositories/InMemoryUsersRepository.ts), e a variável sut, que é a instância da classe CreateUserUseCase sendo passada para ela, permitindo que esse objeto tenha acesso a todos os métodos que existem no InMemoryUsersRepository().

  it('should be able to create a user', async () => {
    const { user } = await sut.execute({
      cpf: '12345678900',
      name: 'John Doe',
      password: 'password',
    });

    expect(user.cpf).toBe('12345678900');
  });

Enter fullscreen mode Exit fullscreen mode

No primeiro teste, a variável user recebe o retorno da função execute do sut, que é chamada com as informações necessárias para a criação de um usuário. Após essa criação, utilizamos o método expect, que, combinado com matchers, verifica se o resultado obtido corresponde ao que esperamos. Neste caso, aplicamos expect() junto ao matcher toBe(), o que nos permite validar se user.cpf é igual ao CPF fornecido na chamada da função execute, ou seja, "12345678900".

  it('should be allocate a new user into repository', async () => {
    const { user } = await sut.execute({
      cpf: '12345678900',
      name: 'John Doe',
      password: 'password',
    });

    expect(user).toStrictEqual(inMemoryUsersRepository.items[0]);
  });
Enter fullscreen mode Exit fullscreen mode

No segundo it, a variável user novamente recebe o retorno da função execute do sut, que é chamada com as mesmas informações para a criação de um usuário. Após a criação, utilizamos o método expect para verificar se o novo usuário foi corretamente alocado no repositório. Neste caso, aplicamos expect() em conjunto com o matcher toStrictEqual(), o que nos permite validar se o objeto user é estritamente igual ao primeiro item armazenado no repositório inMemoryUsersRepository.items[0]. Isso garante que o usuário foi corretamente adicionado ao repositório.

Obs.: O SUT (System Under Test) representa o sistema ou componente que está sendo testado. Ele facilita a organização e leitura dos testes, deixando claro qual parte do código está sendo avaliada.

.
Terabox Video Player