Skip to content
@clean-arch-enablers-project

CleanArchEnablers

Making the experience of developing cleanly-architected software easier.

⚠️ This README is a WIP. Soon it will be complete and in english as well.

A SDK

CAE — Clean Arch Enablers é a SDK que tem como objetivo habilitar as vantagens da Arquitetura Limpa e neutralizar ao máximo a fricção de implementá-la. Para um bom entendimento a respeito dos componentes desta SDK, software deve ser visto como uma entidade que existe exclusivamente para servir funcionalidades, e essas funcionalidades são categorizadas como os Casos de Uso de tal entidade. Ou seja, são o para-quê de um software existir.

Vale frisar que esse recorte não é sobre qualquer funcionalidade de uma aplicação, mas sim sobre as que servem de ponto de acesso ao domínio sendo implementado. Existem funcionalidades que são Casos de Uso de um domínio, e funcionalidades que são passos internos de um Caso de Uso. É importante saber distinguir cada qual.


use-cases-vs-inner-steps


Dado este primeiro passo, o próximo é entender que todo Caso de Uso é uma ação que possui início, meio e fim. O início pode receber ou não dados de entrada; o meio é a lógica intrínseca da ação; o fim pode ou não retornar dados de saída. Na ótica do CAE, não importa qual aplicação um time esteja criando, todas terão esta mesma característica: ser um artefato com pontos de acesso que são ações com início, meio e fim. Este fato não depende de como as funcionalidades estão sendo servidas: não importa se estão sendo expostas como REST Endpoints, Kafka Consumers ou CRON Jobs... independente do meio de exposição, todo tipo de ponto de acesso ao domínio pode ser abstraído em formato de Caso de Uso.


flows


UseCase e seus subtipos

O modelo de pensamento orientado a Caso de Uso foi traduzido para 4 componentes básicos da SDK:

FunctionUseCase<I, O>

//Funcionalidades que recebem dados de entrada, executam seu algoritmo e terminam retornando dados de saída
public interface FunctionUseCase<I, O> extends UseCase {
    O execute(I input);
}

ConsumerUseCase<I>

//Funcionalidades que recebem dados de entrada, executam seu algoritmo e terminam
public interface ConsumerUseCase<I> extends UseCase {
    void execute(I input);
}

SupplierUseCase<O>

//Funcionalidades que executam seu algoritmo e terminam retornando dados de saída
public interface SupplierUseCase<O> extends UseCase {
    O execute();
}

RunnableUseCase

//Funcionalidades que executam seu algoritmo e terminam
public interface RunnableUseCase extends UseCase {
    void execute();
}

Isso foi desenhado para que qualquer possível funcionalidade se encaixe em 1 tipo de UseCase.

Outro axioma notável é que casos de uso que recebem dados de entrada normalmente precisam verificá-los antes. Isso também foi abstraído para dentro de um outro componente básico:

UseCaseInput

//Modelos de input com comportamento nativo de verificação
public class UseCaseInput {
    public void autoverify() { ... }
}

Com isso todo UseCase que aceita dados de entrada requer que seu modelo de input seja um derivado de UseCaseInput, tornando possível contar com a API padrão UseCaseInput.autoverify(): void. O Autoverify irá escanear a si mesmo e assegurar antes da execução de seu UseCase que:

  • Todos os campos marcados com @NotNullInputField não estão null (serve para qualquer tipo de dado).
  • Todos os campos marcados com @NotEmptyInputField não estão vazios (serve para coleções e String).
  • Todos os campos marcados com @NotBlankInputField não estão null (serve para String e coleções de String).
  • Todos os campos marcados com @ValidInnerProperties estão recursivamente complacentes com as 3 anotações mencionadas acima (serve para tipos customizados).

Com isso o FunctionUseCase e o ConsumerUseCase ficam assim:

//Input precisa ser subtipo de UseCaseInput
public interface FunctionUseCase<I extends UseCaseInput, O> extends UseCase { ... }

//Input precisa ser subtipo de UseCaseInput
public interface ConsumerUseCase<I extends UseCaseInput> extends UseCase { ... }

A intenção do design é que se payloads de input não respeitarem o contrato, uma exception seja lançada e o UseCase não seja executado. Porém... do jeito até então apresentado, a responsabilidade de chamar o UseCaseInput::autoverify seria do desenvolvedor, dentro da implementação de seu UseCase. Isso não foi considerado flexibilidade, mas sim vulnerabilidade. A flexibilidade já é garantida ao deixar que o desenvolvedor escolha colocar ou não anotações nos campos, mas a responsabilidade de garantir que as anotações sejam respeitadas uma vez declaradas no contrato não faz sentido deixar solto. Isso é algo que todo caso de uso que aceita input deveria fazer automaticamente.

Daí nasce a primeira Autofeature, funcionalidades que são internas e automaticamente garantidas a qualquer tipo de UseCase, independente da regra de negócio de uma implementação específica. É então para habilitar esta primeira Autofeature e preparar o terreno para próximas, que os tipos de UseCase com input deixam de ser interfaces: com a utilização de Template Pattern, cada tipo se torna uma classe abstrata com implementações padrões que giram em torno de uma regra de negócio abstrata, que só será implementada posteriormente, via polimorfismo.

Fica assim:

FunctionUseCase<I, O>

public abstract class FunctionUseCase<I extends UseCaseInput, O> extends UseCase {
    
    /*
    API do caso de uso. Método concreto. 
    Chama a Autofeature e em seguida a lógica do caso de uso.                             
    */
    public O execute(I input){
        input.autoverify();
        return this.applyInternalLogic(input);
    }
    
    /*
    método abstrato que encapsulará a lógica específica 
    de cada caso de uso      
    */
    protected abstract O applyInternalLogic(I input); 
    
}

ConsumerUseCase<I>

public abstract class ConsumerUseCase<I extends UseCaseInput> extends UseCase {

    /*
    API do caso de uso. Método concreto. 
    Chama a Autofeature e em seguida a lógica do caso de uso.                             
    */
    public void execute(I input){
        input.autoverify();
        this.applyInternalLogic(input);
    }

    /*
    método abstrato que encapsulará a lógica específica 
    de cada caso de uso      
    */
    protected abstract void applyInternalLogic(I input);

}

Desta forma o desenvolvedor não precisa mais se preocupar em chamar o UseCaseInput::autoverify para que a verificação do payload de input aconteça. Se torna automático, uma Autofeature.

No caso do Autoverify, como já mencionado, caso o payload de entrada não respeite o contrato definido pelo desenvolvedor, uma exception será lançada, e o UseCase não terá sua lógica interna executada. A exception que esta Autofeature lança é do tipo MappedException, outro componente básico da SDK.


MappedException e seus subtipos

Tipos de MappedException servem para abstrair cenários de exceção comuns, que acontecem em qualquer aplicação:

InternalMappedException

//exceptions provocadas por problemas internos da aplicação
public class InternalMappedException extends MappedException {}

InputMappedException

//exceptions provocadas por dados de entrada
public class InputMappedException extends MappedException {}

NotFoundMappedException

//exceptions provocadas por não encontrar algo que era esperado
public class NotFoundMappedException extends MappedException {}

NotAuthorizedMappedException

//exceptions provocadas por ausência de autorização numa ação protegida
public class NotAuthorizedMappedException extends MappedException {}

NotAuthenticatedMappedException

//exceptions provocadas por ausência de identificação numa ação protegida
public class NotAuthenticatedMappedException extends MappedException {}

Com esse componente da SDK, tipos comuns de exceção são disponibilizados para reuso, bastando aplicá-los em cenários condizentes. Isso incentiva o uso de exceptions semânticas, sem ter a fricção de criá-las manualmente.

Outro efeito derivado do uso desse componente é que padrões são estabelecidos, fomentando previsibilidade não só na SDK em si como também nas suas aplicações clientes. Por exemplo, tipos de MappedException sempre serão interpretados pela SDK como cenários de erro intencionalmente mapeados: é considerado que desenvolvedores fizeram a escolha consciente de lançar esses tipos de exception como parte do fluxo. Portanto, sempre que uma exception é interceptada e ela é do tipo MappedException, ela será propagada adiante sem interferência por parte da SDK.


mapped-exceptions Contudo, mesmo que times tenham o cuidado de utilizar subtipos de MappedException, ainda assim é possível que erros inesperados aconteçam: exceptions que uma biblioteca lança, edge cases na própria lógica do desenvolvedor que causam NullPointerException... tudo pode acontecer. Isso deixa em evidência mais um padrão, que é endereçado por outro componente básico da SDK: Trier.


Trier e suas APIs

O Trier é como um bloco de try-catch: nele é possível encapsular qualquer tipo de ação e parametrizar um tratamento específico em caso dela lançar uma exception inesperada, ou seja, diferente de MappedException. O tratamento parametrizável tem o objetivo de transformar tais exceptions inesperadas em instâncias de MappedException, na intenção de controlar o caos e preservar a ordem.

A API mais básica do Trier é essa:

/*
Tenta executar 'someString.concat("another string")'
e se isso der qualquer problema diferente de MappedException, 
irá converter para InternalMappedException.
*/
Trier.of(() -> someString.concat("another string")) //<- encapsula ação
    .onUnexpectedExceptions(ex -> new InternalMappedException("problem at blablabla...", ex)) //<- parametriza handler
    .execute(); //<- executa ação pronto para usar o handler

O Trier pode encapsular qualquer tipo de ação:

  • ações com entrada e saída
  • ações com entrada e sem saída
  • ações sem entrada e com saída
  • ações sem entrada nem saída

Além de parametrizar um handler para transformar exceptions inesperadas em MappedException, é possível também definir quais cenários são elegíveis para retentativas em caso da ação quebrar.

Funciona assim:

Trier.of(() -> this.doSomethingWith(thatThing))
        .retryOn(IOException.class, 5, 300, TimeUnit.MILLISECONDS)//<-- retenta até 5x em caso de IOException
        .retryOn(ServiceUnavailableException.class, 10, 100, TimeUnit.MILLISECONDS)//<-- retenta até 10x em caso de ServiceUnavailableException
        .onUnexpectedExceptions(this::handleUnexpectedException)
        .execute();

A API retryOn recebe os seguintes parâmetros:

  • a exception que se ocorrer, retentativas devem ser executadas.
  • até quantas vezes para essa exception a ação pode retentar.
  • tempo base para começar a retentar.

O tempo base vai servir como tempo de espera entre a tentativa que falhou e a primeira retentativa após. Uma vez a primeira retentativa também tendo falhado, as próximas irão acontecer com intervalos de tempo diferentes. A fórmula é a seguinte:

Para o cenário de até 10 retentativas, com tempo base de 300ms:

  • tentativa falha: espera 300ms (300 * 2^0).
  • 1ª retentativa falha: espera 600ms (300 * 2^1).
  • 2ª retentativa falha: espera 1200ms (300 * 2^2).
  • 3ª retentativa falha: espera 2400ms (300 * 2^3).
  • 4ª retentativa falha: espera 4800ms (300 * 2^4).
  • 5ª retentativa falha: espera 9600ms (300 * 2^5).
  • 6ª retentativa falha: espera 19200ms (300 * 2^6).
  • 7ª retentativa falha: espera 38400ms (300 * 2^7).
  • 8ª retentativa falha: espera 76800ms (300 * 2^8).
  • 9ª retentativa falha: espera 153600ms (300 * 2^9).
  • 10ª retentativa falha. Lança NoRetriesLeftException.

Sendo possível concluir que o setup de até 10x retentativas com tempo base de 300ms possui potencial para levar até 306900ms, isto é, até aproximadamente 5 minutos (soma de todas as esperas).

A fórmula é a seguinte:

tempo de espera = tempo base * 2 ^ (número de retentativas já executadas)

Além da API do Trier possibilitar o setup de vários cenários distintos para retentativa numa mesma ação, ela também disponibiliza um método para setup de handler em caso de exaustão. Ou seja, para casos de esgotamento de retentativas. No exemplo dado acima, tal handler seria invocado no momento que a 10ª retentativa teria falhado.

É assim:

Trier.of(() -> this.doSomethingWith(thatThing))
        .retryOn(IOException.class, 5, 300, TimeUnit.MILLISECONDS)
        .retryOn(ServiceUnavailableException.class, 10, 100, TimeUnit.MILLISECONDS)
        .onExhaustion(failureStatus -> this.handleExhaustion(failureStatus))//<- é executado em caso de esgotar retentativas
        .onUnexpectedExceptions(this::handleUnexpectedException)
        .execute();

O tipo de failureStatus é FailureStatus, que possui os seguintes campos:

  • exception: a exception responsável por ter causado a exaustão do Trier para a ação.
  • totalOfRetries: quantas tentativas aconteceram para que a exaustão tenha ocorrido.

Daí cabe ao desenvolvedor escolher o que fazer nesse cenário.

Mais uma conveniência que o Trier possibilita é que sempre quando uma retentativa acontece, o desenvolvedor pode criar um RetrySubscriber para receber uma notificação e reagir como quiser a ela (gravar logs, enviar métricas, etc.).

Para declarar um subscriber, basta criar uma classe que implemente a interface RetrySubscriber:

public class MyOwnRetrySubscriber implements RetrySubscriber{
    
    @Override
    public void receiveRetryNotification(RetryNotification retryNotification){
        //gravar logs, extrair métricas, etc.
    }
    
}

O objeto recebido, do tipo RetryNotification, possui os seguintes campos:

  • cause: exception que causou a retentativa.
  • totalOfRetriesAtThisPoint: quantas retentativas já ocorreram para esse cenário até o momento da notificação.

Para que o subscriber seja utilizado pela SDK, é necessário que ele seja fornecido para o RetryNotifier:

RetryNotifier.SINGLETON.subscribe(new MyOwnRetrySubscriber());

É crucial que a implementação de RetrySubscriber seja fornecida para o RetryNotifier em tempo de bootstrap da aplicação cliente, para que a instância já esteja presente em caso de qualquer retentativa ocorrer.

O RetryNotifier emite as notificações em seu próprio Pool de Threads, significando que se uma retentativa ocorre na Thread de uma requisição sendo atendida pela aplicação cliente e ela está preparada para receber notificações, este recebimento acontecerá em outra Thread, de forma não bloqueante.

É possível personalizar a Pool de Threads usada pelo RetryNotifier por meio do componente RetryNotifierThreadPoolProvider:

RetryNotifierThreadPoolProvider.SINGLETON
        .setMinSize(3)//<- tamanho mínimo da Pool de Threads
        .setMaxSize(15)//<- tamanho máximo para a Pool
        .setKeepAliveTimeForIdleThreadsInSeconds(4)//<- limite de segundos 1 thread extra pode ficar viva e ociosa
        .setQueueCapacity(10)//<- quantas notificações podem ser enfileiradas antes de começar a escalar as Threads
        .setPoolName("MyCustomRetryNotifierPool");//<- nome da Pool

Assim como a inscrição de um RetrySubscriber, é importante que customizações como essa da Pool de Threads seja realizada em tempo de bootstrap da aplicação cliente.

Caso o desenvolvedor não forneça personalizações para a Pool, esses são os valores default:

  • minSize: 5.
  • maxSize: 30.
  • keepAliveTimeForIdleThreadsInSeconds: 60.
  • queueCapacity: 100.
  • poolName: CaeRetryNotifierThreadPool.

⚠️ CONTINUE...



⚡Joining the force

If anyone gets interested in becoming an official collaborator, it is very simple. The workflow is:

  1. 🔍 Identify where it would be interesting to collaborate.
  2. 📝 Open a new issue on the repository that has an opportunity for enhancement, specifying the intended changes.
  3. 🛠️ Create a new branch to address the changes (allowed prefixes: feature/, refact/, docs/, or fix/).
  4. 📩 Once it is done, open a new pull request.
  5. 🔀 Pass through the code review phase.
  6. ✅ Welcome to the team!

Once completed the workflow, the developer gets associated with our GitHub and LinkedIn organization pages.





CAE — Clean Architecture made easy.

Popular repositories Loading

  1. cae-framework cae-framework Public

    Repository for the open source CAE framework designed to make the experience of developing software with clean architecture easier.

    Java 12 1

  2. cae-cli cae-cli Public

    Repository for the open source CAE CLI tool.

    Java 5 1

  3. cae-utils-http-client cae-utils-http-client Public

    Repository for the open source CAE HTTP client library.

    Java 4

  4. cae-utils-mapped-exceptions cae-utils-mapped-exceptions Public

    Repository for the open source CAE Mapped Exceptions library.

    Java 3

  5. cae-utils-trier cae-utils-trier Public

    Repository for the open source CAE Trier library.

    Java 3

  6. cae-utils-env-vars cae-utils-env-vars Public

    Repository for the open source CAE EnvVars library.

    Java 3

Repositories

Showing 10 of 12 repositories

Top languages

Loading…

Most used topics

Loading…