Esta postagem é um exercício desenvolvimento orientado a objetos com uso de alguns padrões de projeto e boas práticas do SOLID. Aqui, vou desenvolver um código para o cálculo de aprovação de alunos em uma disciplina. Para isso, vou adotar uma premissa muito rígida: resolver exclusivamente o problema que está sendo apresentado da maneira mais direta possível. Não vou tentar imaginar problemas futuros (embora seja eu mesmo a descrever todos os problemas).

Atenção! Esta postagem é a parte 2 do mesmo exercício, onde na parte 1 foi implementado sem observar qualquer bom uso de OOP, SOLID, etc. Esta postagem faz muito mais sentido se você ler também a irmã: Provas, Recuperação, Final… não OOP & não SOLID # Parte 1.

Primeiro problema

O sistema deve receber três notas de um aluno de três avaliações aplicadas durante um semestre letivo em uma disciplina de curso superior. O sistema deve informar se o aluno está aprovado ou reprovado, considerando esse conjunto de notas. As notas são valores numéricos que podem ir de 0 até 10. O critério para aprovar é ter média simples maior ou igual a 7.0.

O código abaixo mostra a versão inicial, focada exclusivamente em atender o que se pede.

import java.math.BigDecimal;
import java.math.RoundingMode;

public class GradingCalculator {
    private static final BigDecimal MAX_GRADE = new BigDecimal("10.0");
    private static final BigDecimal PASSING_GRADE = new BigDecimal("7.0");
    private static final BigDecimal EXAMS_COUNT = new BigDecimal("3.0");
    private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;
    private static final int SCALE = 1;

    private final BigDecimal firstExam;
    private final BigDecimal secondExam;
    private final BigDecimal thirdExam;

    public GradingCalculator(
            BigDecimal firstExam,
            BigDecimal secondExam,
            BigDecimal thirdExam
    ) {
        validateExamGrade("firstExam", firstExam);
        validateExamGrade("secondExam", secondExam);
        validateExamGrade("thirdExam", thirdExam);
        this.firstExam = firstExam;
        this.secondExam = secondExam;
        this.thirdExam = thirdExam;
    }

    private BigDecimal getAverageGrade() {
        return firstExam
                .add(secondExam)
                .add(thirdExam)
                .divide(EXAMS_COUNT, SCALE, ROUNDING_MODE);
    }

    public boolean isApproved() {
        return getAverageGrade().compareTo(PASSING_GRADE) >= 0;
    }

    private static void validateExamGrade(
            String examDescription,
            BigDecimal grade
    ) {
        if (grade == null
                || grade.compareTo(BigDecimal.ZERO) < 0
                || grade.compareTo(MAX_GRADE) > 0) {
            throw new IllegalArgumentException(
                    "'%s' must be between 0.0 and 10.0".formatted(examDescription)
            );
        }
    }
}

Mas a implementação acima ainda pode melhorar. Do jeito que está, ela é apenas uma versão mais organizada da implementação apresentada no outro post. No final, não acrescentei nenhuma melhoria semântica.

O nome GrandingCalculator, por exemplo, sugere que a classe apenas calcula, mas da forma como foi implementada, ela guarda estado. E mais, a classe tem responsabilidades. Ela calcula a média, verifica se o resultado é suficiente para aprovação, além de aplicar validações nos dados.

A implementação a seguir é construída com pensamento nas práticas de responsabilidade única, inversão de dependência, princípio aberto/fechado, etc. É importante ressaltar que a implementação não é um exercío criativo e imaginativo sobre as possíveis funcionalidades que talvez possam vir a integrar o sistema no futuro.

A nota de uma avaliação está bem definida para o intervalo entre 0.0 e 10.0. Nada mais justo que criar uma classe para representar esse conceito.

import java.math.BigDecimal;

public final class Grade {

    private static final BigDecimal MIN = BigDecimal.ZERO;
    private static final BigDecimal MAX = new BigDecimal("10.0");

    private final BigDecimal value;

    public Grade(BigDecimal value) {
        if (value == null
                || value.compareTo(MIN) < 0
                || value.compareTo(MAX) > 0
        ) {
            throw new IllegalArgumentException("Grade must be between 0.0 and 10.0");
        }
        this.value = value;
    }

    public BigDecimal value() {
        return value;
    }
}

Uma nota, sozinha, não é suficiente para determinar se aluno foi aprovado ou reprovado. Vamos considerar, então, que existe uma entidade para repsentar o conjunto de notas dentre o semestre.

import java.util.List;

public final class Grades {

private final List<Grade> exams;

public Grades(List<Grade> exams) {
if (exams == null || exams.isEmpty()) {
throw new IllegalArgumentException("Grades cannot be empty");
}
this.exams = List.copyOf(exams);
}

public List<Grade> exams() {
return exams;
}
}

Agora que temos um conjunto de notas (Grades), podemos criar uma classe capaz de calcular e, agora, apenas calcular; ela não vai guardar estado. Perceba que a classe de serviço depende de abstrações. Uma para aplicar a regra de aprovação (média maior u igual a 7.0) e outra para calcular a média. A classe de serviço atua como um orquestrador. E repito, isto é feito desta forma porque a classe, dentro do problema que ela resolve, deve ser construída para ser aberta a extensão, e fechada para modificações, independentemente do que estar por vir. Se a regra para aprovação ou o cálculo da média precisarem mudar, a classe GradeEvaluationService nem precisa saber disso.

import java.math.BigDecimal;

public class GradeEvaluationService {

private final GradeCalculationPolicy calculationPolicy;
private final ApprovalPolicy approvalPolicy;

public GradeEvaluationService(
GradeCalculationPolicy calculationPolicy,
ApprovalPolicy approvalPolicy
) {
this.calculationPolicy = calculationPolicy;
this.approvalPolicy = approvalPolicy;
}

public boolean evaluate(Grades grades) {
BigDecimal finalGrade = calculationPolicy.calculate(grades);
return approvalPolicy.evaluate(finalGrade);
}
}

Precisamos implementar as abstrações GradeCalculationPolicy e ApprovalPolicy.

import java.math.BigDecimal;
import java.math.RoundingMode;

public class SimpleAverageCalculation implements GradeCalculationPolicy {

private static final int SCALE = 1;
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;

@Override
public BigDecimal calculate(Grades grades) {
BigDecimal sum = grades
.exams()
.stream()
.map(Grade::value)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return sum.divide(
BigDecimal.valueOf(grades.exams().size()),
SCALE,
ROUNDING_MODE
);
}
}

import java.math.BigDecimal;

public class SimpleApprovalPolicy implements ApprovalPolicy {

private final BigDecimal passingGrade;

public SimpleApprovalPolicy(
BigDecimal passingGrade
) {
this.passingGrade = passingGrade;
}

@Override
public boolean evaluate(BigDecimal finalGrade) {
return finalGrade.compareTo(passingGrade) >= 0;
}
}

Exemplo de uso:

import java.math.BigDecimal;
import java.util.List;

public class Usage {
    public static void main(String[] args) {
        Grades grades = new Grades(List.of(
                new Grade(new BigDecimal("8.0")),
                new Grade(new BigDecimal("6.5")),
                new Grade(new BigDecimal("7.0"))
        ));
        GradeCalculationPolicy gradeCalculationPolicy = new SimpleAverageCalculation();
        ApprovalPolicy approvalPolicy = new SimpleApprovalPolicy(new BigDecimal("7.0"));
        GradeEvaluationService service = new GradeEvaluationService(
                gradeCalculationPolicy,
                approvalPolicy
        );
        boolean approved = service.evaluate(grades);
        System.out.printf("Approved: %b\n", approved);
    }
}

Primeiro acréscimo

O sistema agora deve considerar a nota da reposição. A reposição é uma prova extra que o aluno faz caso sua média das três primeiras provas não seja suficiente para aprovação. Nesse caso, a prova da reposição deve substituir a menor nota entre as três avaliações. Se a nota da reposição for ainda menor, ela não deve ser considerada.

Aqui as coisas começam a ficar interessantes. Temos que aplicar uma mudança relevante no sistema. Conseguimos fazer isso sem quebrar tudo, sem refatorar 1000 classes? Sabemos que a média é calculada com uso de uma abstração representada pela classe GradeCalculationPolicy. Uma vez que GradeEvaluationService foi construído com inversão dependência, na prática, nem precisamos mexer nele.

Não podemos fugir de mudar a entidade que representa as notas. Ela precisa considerar agora uma prova de reposição, que vou deixar como opcional.

import java.util.List;
import java.util.Optional;

public final class Grades {

private final List<Grade> regularExams;
private final Grade recoveryExam;

public Grades(List<Grade> regularExams, Grade recoveryExam) {
if (regularExams == null || regularExams.size() != 3) {
throw new IllegalArgumentException("Exactly 3 regular exams are required");
}
this.regularExams = List.copyOf(regularExams);
this.recoveryExam = recoveryExam;
}

public Grades(List<Grade> regularExams) {
this(regularExams, null);
}

public List<Grade> regularExams() {
return regularExams;
}

public Optional<Grade> recoveryExam() {
return Optional.ofNullable(recoveryExam);
}
}

Agora basta implementar uma versão de GradeCalculationPolicy capaz de lidar com a prova de reposição.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;

public class BestOfThreeWithRecoveryCalculation implements GradeCalculationPolicy {

private static final int SCALE = 1;
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;

@Override
public BigDecimal calculate(Grades grades) {

List<Grade> exams = grades.regularExams();

BigDecimal initialAverage = average(exams);

if (initialAverage.compareTo(new BigDecimal("7.0")) >= 0) {
return initialAverage;
}

if (grades.recoveryExam().isEmpty()) {
return initialAverage;
}

Grade lowest = exams.stream()
.min(Comparator.comparing(Grade::value))
.orElseThrow();

List<Grade> adjusted = exams.stream()
.filter(g -> g != lowest)
.toList();

List<Grade> finalGrades = List.of(
adjusted.get(0),
adjusted.get(1),
grades.recoveryExam().get()
);

return average(finalGrades);
}

private BigDecimal average(List<Grade> grades) {
BigDecimal sum = grades.stream()
.map(Grade::value)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return sum.divide(
BigDecimal.valueOf(grades.size()),
SCALE,
ROUNDING_MODE
);
}
}

E agora, quando formos instanciar o GradeEvaluationService, basta passar a nova implementação. Ele vai funcionar, e nem precisou de modificações internas. Mas a nova implementação tem um problema relevante: antes, ela tinha apenas que calcular a média de três valores, agora precisa usar a nota de reposição e, para isso, precisa saber se a média é suficiente para aprovação. Como vimos, essa regra é implementada por uma abstração específica. Mesmo se usássemos dentro do BestOfThreeWithRecoveryCalculation uma implementação de ApprovalPolicy, ainda não ficaria elegante o suficiente, pois vários pontos da lógica de negócio precisariam lidar com isso, e sincronizar as mesmas implementações, etc.

Com isso, a ideia para contornar este problema é fazer com que existam duas implementações que saibam lidar com cada situação. A primeira é quando o aluno não tem nota reposição, e o cálculo da média simples se mantém. A segunda quando há uma reposição, e a média das três primeiras provas não importa, bastante substituir a menor nota pela reposição. Se a reposição for ainda menor, ele vai continuar reprovado. Fazer duas implementações é bom do ponto de vista que ela vão ter um único e pontual objetivo.

Assim, BestOfThreeWithRecoveryCalculation é ajustado sempre usar a nota da reposição. Agora, temos que criar mais uma abstração capaz de decidir quando usar BestOfThreeWithRecoveryCalculation ou SimpleAverageCalculation. Vamos usar o recurso de Service Loader do Java.

A abstração GradeCalculationPolicy é incrementada com um método que indica se ela é capaz de trabalhar com um conjunto de notas específico.

import java.math.BigDecimal;

public interface GradeCalculationPolicy {

    boolean supports(Grades grades);

    BigDecimal calculate(Grades grades);
}

Com isso SimpleAverageCalculation, passa a indicar que consegue trabalhar apenas com um objeto Grades que não tem contém a prova de recuperação.

import java.math.BigDecimal;
import java.math.RoundingMode;

public class SimpleAverageCalculation implements GradeCalculationPolicy {

private static final int SCALE = 1;
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;

@Override
public boolean supports(Grades grades) {
return grades.recoveryExam().isEmpty();
}

@Override
public BigDecimal calculate(Grades grades) {
BigDecimal sum = grades
.recoveryExam()
.stream()
.map(Grade::value)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return sum.divide(
BigDecimal.valueOf(grades.regularExams().size()),
SCALE,
ROUNDING_MODE
);
}
}

Semelhantemente, a classe SimpleAverageWithRecoveryExamCalculation passa a indicar que só consegue trabalhar se a nota de recuperação estiver disponível. Nesse caso, a implementação sempre vai considerar a nota de reposição.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.stream.Stream;

public class SimpleAverageWithRecoveryExamCalculation implements GradeCalculationPolicy {

private static final int SCALE = 1;
private static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;

@Override
public boolean supports(Grades grades) {
return grades.recoveryExam().isPresent();
}

@Override
public BigDecimal calculate(Grades grades) {
List<Grade> withoutMinimumGrade = grades
.recoveryExam()
.stream()
.sorted((g1, g2) -> g1.value().compareTo(g2.value()))
.skip(1)
.toList();
List<Grade> withRecoveryGrade = Stream.concat(
withoutMinimumGrade.stream(),
Stream.of(grades.recoveryExam().get())
).toList();
return GradeCalculationPolicy.average(
withRecoveryGrade,
SCALE,
ROUNDING_MODE
);
}
}

O resolver, que usa o mercanismo Service Loader do Java fica assim.

import java.util.List;
import java.util.ServiceLoader;
import java.util.stream.Collectors;

public class DefaultGradeCalculationPolicyResolver implements GradeCalculationPolicyResolver {

private final List<GradeCalculationPolicy> policies;

public DefaultGradeCalculationPolicyResolver() {
policies = ServiceLoader
.load(GradeCalculationPolicy.class)
.stream()
.map(ServiceLoader.Provider::get)
.toList();
}

@Override
public GradeCalculationPolicy resolve(Grades grades) {
return policies
.stream()
.filter(p -> p.supports(grades))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"No GradeCalculationPolicy found for grades: " + grades
+ ". Available policies: " + policies.stream()
.map(p -> p.getClass().getName())
.collect(Collectors.joining(", "))
));
}
}

A inevitavelmente a classe de serviço precisou mudar para usar a nota abstração GradeCalculationPolicyResolver.

import java.math.BigDecimal;

public class GradeEvaluationService {

private final GradeCalculationPolicyResolver gradeCalculationPolicyResolver;
private final ApprovalPolicy approvalPolicy;

public GradeEvaluationService(
GradeCalculationPolicyResolver gradeCalculationPolicyResolver,
ApprovalPolicy approvalPolicy
) {
this.gradeCalculationPolicyResolver = gradeCalculationPolicyResolver;
this.approvalPolicy = approvalPolicy;
}

public boolean evaluate(Grades grades) {
BigDecimal finalGrade = gradeCalculationPolicyResolver
.resolve(grades)
.calculate(grades);
return approvalPolicy.evaluate(finalGrade);
}
}

Depois disso tudo, com o mínimo de acoplamento e classes com responsabilidades bem definidas, eu enviaria este código para produção.

Um pouco mais de complexidade

O sistema deve permitir que cada avaliação, incluindo a reposição, seja composta por um conjunto de atividades. A nota de avaliação é a média simples das notas das atividades, com a nota das atividades também indo de 0 até 10.

E dessa vez, vamos ter que restruturar tudo novamente, adicionar mais abstrações? Vamos lembrar que todo cálculo acontece sobre as notas das três provas mais a reposição. Isto é, todas as abstrações trabaram com a classe Grade. Se Grade é composta internamente por várias atividades, desde que ela continue informando seu valor, nada muda nas abstrações.

Criamos a classe Activity. Ela é muito semelhante à classe Grade original.

import java.math.BigDecimal;

public final class Activity {

private static final BigDecimal MIN = BigDecimal.ZERO;
private static final BigDecimal MAX = new BigDecimal("10.0");

private final BigDecimal value;

public Activity(BigDecimal value) {
if (value == null
|| value.compareTo(MIN) < 0
|| value.compareTo(MAX) > 0) {
throw new IllegalArgumentException("Activity must be between 0.0 and 10.0");
}
this.value = value;
}

public BigDecimal value() {
return value;
}
}

A classe Grade, que antes tinha uma única nota, agora vaiter uma lista de atividades. Simples assim.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;

public final class Grade {

private final List<Activity> activities;

public Grade(List<Activity> activities) {
if (activities == null || activities.isEmpty()) {
throw new IllegalArgumentException("Grade must have at least one activity");
}
this.activities = List.copyOf(activities);
}

public List<Activity> activities() {
return activities;
}

public BigDecimal value(
int scale,
RoundingMode roundingMode
) {
BigDecimal sum = activities.stream()
.map(Activity::value)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return sum.divide(
BigDecimal.valueOf(activities.size()),
scale,
roundingMode
);
}
}

E fim. Não modificando nenhuma lógica de negócio. Foi necessário somente criar uma nova entidade para representar as atividades e ajustar a classe representante das notas, para usar as atividades.

Dava para ter pensando nisso antes?

As atividades realizadas dentro de uma avaliação podem ter um peso variável na composição da nota da avaliação. O peso de uma atividade é um valor que pode variar de 1 a 10. A nota da avaliação deve ser a média ponderada das notas de todas as atividades.

A implementação dessa funcionalidade vai direto no alvo. Precisamos ajustar a classe Activity para receber o peso da atividade, e precisamos ajustar a classe Grade pra calcular a nota considerando a média ponderada.

O método na classe Grade ficou parecido com isso.

public BigDecimal value(int scale, RoundingMode roundingMode) {

BigDecimal weightedSum = activities.stream()
.map(a -> a.value().multiply(a.weight()))
.reduce(BigDecimal.ZERO, BigDecimal::add);

BigDecimal totalWeight = activities.stream()
.map(Activity::weight)
.reduce(BigDecimal.ZERO, BigDecimal::add);

return weightedSum.divide(
totalWeight,
scale,
roundingMode
);
}

Desde o começo, criamos uma carga extra de código fonte para atender todas as abstrações que precisávamos usar. Mas essa carga extra mostrou que é mais propícia a receber mudanças. Podemos fazer alterações com risco reduzido em quebrar funcionalidades relacionadas. Veja, fizemos várias alterações e não precisamos mecher na classe GradeEvaluationService há um bom tempo.

A última chance

Caso o aluno não seja aprovado após a reposição, ele pode fazer uma prova final, com nota variando de 0 a 10. A prova final é única, não sendo composta por múltiplas atividades. Suponhamos que N seja a média das três primeiras avaliações (possivelmente considerando também a prova de reposição), e vamos supor que F seja a nota da prova final, o critério para aprovação neste caso é a média simples entre N e F, cujo valor deve ser maior ou igual a 6.0.

De cara, isso me parece requerer mais uma mudança estrutural e criação de mais algumas abstrações. Vamos analiasr como o código vai aceitar essa mudança.

Sabemos que o design tem uma abstração que decide se o aluno foi aprovado. Atualmente, a única necessidade que temos é saber se ele foi aprovado por média, com ou sem reposição, sendo necessário o valor ser maior ou igual a 7.0 para aprovação. Mas agora, com a prova final, as coisas mudam. O limiar a ser condiderado é 6.0. Num primeiro momento, me parece que basta criar uma nova ApprovalPolicy e ajustar o código para decidir qual usar entre SimpleApprovalPolicy e, digamos, FinalExamApprovalPolicy.

import java.math.BigDecimal;

public interface ApprovalPolicy {

    boolean supports(Grades grades);

    boolean evaluate(BigDecimal finalGrade);
}

Mas temos um problema mais sério! O cálculo da média das provas regulares é realizado por uma abstração, e atualmente existem duas versões, para calcular a média considerando apenas as três notas, e outra que considera a nota da reposição: SimpleAverageCalculation e SimpleAverageWithRecoveryExamCalculation. Para calcular a média considerando a prova final, é necessário saber a média das provas regulares. Assim, uma boa abordagem pode ser criar uma nova abstração para o cálculo da média. Essa nova abstração vai ter duas implementações, um para calcular a média com a prova final e outra para calcular a média sem ela. E essas implementações, como necessitam da média das provas regulares, basta chamar a abstração que faz isso.

O cálculo da média do semestre, quando não há prova final, independente se o aluno fez reposição, fica como mostrado abaixo. Note que, como essa abstração é especializada em calcular a média quando não há prova final, ela apenas obtém o valor da média regular e passa para frente.

public class WithoutFinalExamGradeCalculatorPolicy implements GradeCalculatorPolicy {

private final RegularGradeCalculatorFactoryProvider regularGradeCalculatorFactoryProvider;

public WithoutFinalExamGradeCalculatorPolicy(
RegularGradeCalculatorFactoryProvider regularGradeCalculatorFactoryProvider
) {
this.regularGradeCalculatorFactoryProvider = regularGradeCalculatorFactoryProvider;
}

@Override
public boolean supports(Grades grades) {
return grades.finalExam().isEmpty();
}

@Override
public BigDecimal execute(Grades grades) {
return regularGradeCalculatorFactoryProvider
.get(grades)
.get()
.execute(grades);
}
}

Do outro lado, quando há uma prova final, a implementação abaixo continua pegando a média das avaliações regulares, mas dessa vez, adiciona o valor da média final e calcula a média simples divindo por dois.

public class WithFinalExamGradeCalculator implements GradeCalculatorPolicy {

private final RegularGradeCalculatorFactoryProvider regularGradeCalculatorFactoryProvider;

public WithFinalExamGradeCalculator(
RegularGradeCalculatorFactoryProvider regularGradeCalculatorFactoryProvider
) {
this.regularGradeCalculatorFactoryProvider = regularGradeCalculatorFactoryProvider;
}

@Override
public boolean supports(Grades grades) {
return grades.finalExam().isPresent();
}

@Override
public BigDecimal execute(Grades grades) {
return regularGradeCalculatorFactoryProvider
.get(grades)
.get()
.execute(grades)
.add(grades.finalExam().get().value(1, RoundingMode.HALF_UP))
.divide(new BigDecimal("2.0"), 1, RoundingMode.HALF_UP);
}
}

A beleza de criar todas essas abstrações é o resultado de termos classes altamente especializadas. Imagine o quão simples é testar uma classe que executa uma tarefa extreamente especializada!

A classe de serviço fica assim, totalmente dependente de abstrações.

public class GradeEvaluationService {

private final GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider;
private final ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider;

public GradeEvaluationService(
GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider,
ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider
) {
this.gradeCalculatorFactoryProvider = gradeCalculatorFactoryProvider;
this.approvalPolicyFactoryProvider = approvalPolicyFactoryProvider;
}

public boolean evaluate(Grades grades) {
BigDecimal finalGrade = gradeCalculatorFactoryProvider
.get(grades)
.get()
.execute(grades);
return approvalPolicyFactoryProvider
.get(grades)
.get()
.execute(finalGrade);
}
}

Isso é bom para relatórios

O sistema deve ser incrementado para dizer em quais condições o aluno foi aprovado, ou reprovado, isto é, se foi aprovado por média sem reposição, se foi aprovado com prova de reposição, se foi aprovado com a prova final, ou foi reprovado.

Me parece uma evolução natural. Deu um certo trabalho implementar a funcionalidade anterior. Vamos ver o quanto vai ser simples atender essa nov anecessidade.

A classe GradeEvaluationService tem um método que true/false para indicar se o aluno foi aprovado, ou não. Me parece que o desejo agora é que esse método diga como esse aluno foi aprovado ou reprovado. Mas de qual componente deve ser a responsabilidade? A classe GradeEvaluationService usa uma abstração para calcular a média, e depois usa outra para verificar se essa média é suficiente para aprovação. Ela está, nesse momento, apenas orquestrando a execução de algumas abstrações. Ela deve ser responsável por descobrir como esse aluno foi aprovado ou reprovado?

Com a informação se o aluno foi aprovado ou reprovado (true/false) e também com a informação se o objeto Grades possui prova de reposição ou prova final, conseguimos decidir entre os diferentes resultados.

Adicionei uma enumeração para listar todos os resultados possíveis.

public enum Result {
FAIL,
APPROVED_WITHOUT_EXTRA_EXAMS,
APPROVED_WITH_RECOVERY_EXAM,
APPROVED_WITH_FINAL_EXAM
}

A classe de serviço agora vai orquestrar mais uma abstração.

public class GradeEvaluationService {

private final GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider;
private final ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider;
private final ResultEvaluatorPolicyFactoryProvider resultEvaluatorPolicyFactoryProvider;

public GradeEvaluationService(
GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider,
ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider,
ResultEvaluatorPolicyFactoryProvider resultEvaluatorPolicyFactoryProvider
) {
this.gradeCalculatorFactoryProvider = gradeCalculatorFactoryProvider;
this.approvalPolicyFactoryProvider = approvalPolicyFactoryProvider;
this.resultEvaluatorPolicyFactoryProvider = resultEvaluatorPolicyFactoryProvider;
}

public Result evaluate(Grades grades) {
BigDecimal finalGrade = gradeCalculatorFactoryProvider
.get(grades)
.get()
.execute(grades);
boolean approved = approvalPolicyFactoryProvider
.get(grades)
.get()
.execute(finalGrade);
ResultInformation resultInformation = new ResultInformation(
approved,
grades
);
return resultEvaluatorPolicyFactoryProvider
.get(resultInformation)
.get()
.execute(resultInformation);
}
}

Criamos uma classe para lidar com informações segregadas dos resultados obtidos pelo aluno, como mostrado a seguir.

public class ResultInformation {
private final boolean approvedByGrade;
private final boolean approvedByFrequency;
private final Grades grades;

public ResultInformation(
boolean approvedByGrade,
boolean approvedByFrequency,
Grades grades
) {
this.approvedByGrade = approvedByGrade;
this.approvedByFrequency = approvedByFrequency;
this.grades = grades;
}

public boolean isApprovedByGrade() {
return approvedByGrade;
}

public boolean isApprovedByFrequency() {
return approvedByFrequency;
}

public Grades getGrades() {
return grades;
}
}

A abstração (implementação para aprovação com prova final) fica como mostrado a seguir.

public class ApprovedWithFinalExamResultEvaluatorPolicy implements ResultEvaluatorPolicy {

public ApprovedWithFinalExamResultEvaluatorPolicy(
) {
}

@Override
public boolean supports(ResultInformation resultInformation) {
return resultInformation.isApproved()
&& resultInformation.getGrades().finalExam().isPresent();
}

@Override
public Result execute(ResultInformation resultInformation) {
return Result.APPROVED_WITH_FINAL_EXAM;
}
}

Não é só nota, é envolvimento

O sistema deve considerar a frequência do aluno. Um aluno não será reprovado por faltas quando tiver uma frequência igual ou superior a 75% das aulas. A reprovação por faltas deve acontecer mesmo se o aluno obtiver média para aprovação. Nestes casos, o sistema deve indicar também se a reprovação foi por falta, no caso dele alcançar média para aprovação, ou por falta e média, nos caso dele não conseguir média para aprovação, e também não apresentar a frequência necessária.

E para esta última funcionalidade, vamos seguir estratégias já utilizadas antes. Com isso, o cálculo da taxa de frequência vai ficar à critério de uma abstração. Depois, para verificar a frequência é suficiente para aprovação, outra abstração ficar responsável. Como mencionou, é a mesma estratégia utilizada antes, como o cálculo da média e o cálculo da aprovação por essa média. Como temos apenas uma maneira de calcular a frequência, essa abstração, na verdade, fazer fazer apenas uma coisa. Mas vamos lembrar que podemos fazer desse jeito para manter o máximo de abstração e o princípio aberto/fechado, responsabilidade única, segregação de interfaces, etc.

A versão final da classe de serviço vai ficar como abaixo. Perceba que ela depende exclusivamente de abstrações. Como orquestradora das regras de negócio, ela sabe que precisa calcular a média, verificar a frequência, e juntar tudo para conseguir um resultado final, mas ela não sabe, nem é interessante que ela saiba, como tudo isso é feito.

public class GradeEvaluationService {

private final GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider;
private final ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider;
private final ResultEvaluatorPolicyFactoryProvider resultEvaluatorPolicyFactoryProvider;
private final FrequencyCalculatorPolicyFactoryProvider frequencyCalculatorPolicyFactoryProvider;
private final FrequencyApprovalPolicyFactoryProvider frequencyApprovalPolicyFactoryProvider;

public GradeEvaluationService(
GradeCalculatorFactoryProvider gradeCalculatorFactoryProvider,
ApprovalPolicyFactoryProvider approvalPolicyFactoryProvider,
ResultEvaluatorPolicyFactoryProvider resultEvaluatorPolicyFactoryProvider,
FrequencyCalculatorPolicyFactoryProvider frequencyCalculatorPolicyFactoryProvider,
FrequencyApprovalPolicyFactoryProvider frequencyApprovalPolicyFactoryProvider
) {
this.gradeCalculatorFactoryProvider = gradeCalculatorFactoryProvider;
this.approvalPolicyFactoryProvider = approvalPolicyFactoryProvider;
this.resultEvaluatorPolicyFactoryProvider = resultEvaluatorPolicyFactoryProvider;
this.frequencyCalculatorPolicyFactoryProvider = frequencyCalculatorPolicyFactoryProvider;
this.frequencyApprovalPolicyFactoryProvider = frequencyApprovalPolicyFactoryProvider;
}

public Result evaluate(Grades grades) {
BigDecimal finalGrade = gradeCalculatorFactoryProvider
.getFactory(grades)
.getObject()
.execute(grades);
boolean approvedByGrade = approvalPolicyFactoryProvider
.getFactory(grades)
.getObject()
.execute(finalGrade);
BigDecimal frequency = frequencyCalculatorPolicyFactoryProvider
.getFactory(grades)
.getObject()
.execute(grades);
boolean approvedByFrequency = frequencyApprovalPolicyFactoryProvider
.getFactory(grades)
.getObject()
.execute(frequency);
ResultInformation resultInformation = new ResultInformation(
approvedByGrade,
approvedByFrequency,
grades
);
return resultEvaluatorPolicyFactoryProvider
.getFactory(resultInformation)
.getObject()
.execute(resultInformation);
}
}

Precisamos, agora, atualizar a abstração que lida com os possíveis resultados finais. Mas antes, vamos analisar a implementação que sabe entregar o resultado quando o aluno é aprovado apenas com as três primeiras provas, isso está na classe ApprovedWithoutExtraExamsResultEvaluatorPolicy, apresentada abaixo. Em especial, vamos perceber o método supports. É esse método que diz com qual tipo de informação aquela implementação consegue trabalhar. Poderíamos criar novas implementação para lidar com o frequência, mas ao fazer isso, corremos o risco de ter uma explosão combinacional. Imagine que essas regras cresçam indefinidamente, que é comum em um sistema que está em constante evolução, isso pode ser um problema. Estaríámos empurrando complexidade para debaixo do tapete, e o método supports, que deveria ser tão simples quanto possível, pode ficar extremamente complexo.

public class ApprovedWithoutExtraExamsResultEvaluatorPolicy implements ResultEvaluatorPolicy {

public ApprovedWithoutExtraExamsResultEvaluatorPolicy(
) {
}


@Override
public boolean supports(ResultInformation resultInformation) {
return resultInformation.isApprovedByGrade()
&& resultInformation.getGrades().recoveryExam().isEmpty()
&& resultInformation.getGrades().finalExam().isEmpty();
}

@Override
public Result execute(ResultInformation resultInformation) {
return Result.APPROVED_WITHOUT_EXTRA_EXAMS;
}
}

O ideal é que se busque entender as abstrações escondidas no noss modelo de dados. Mas para ser breve, vou seguir apenas implementando duas novas abstrações, apesar do que acabei de mencionar. O lado bom, no entanto, é que basta implementar novas abstrações e atualizar o modelo de dados.

Sobre o aumento exponencial da complexidade, olha o exemplo da aprovação por média, sem provas extras:

@Override
public boolean supports(ResultInformation resultInformation) {
return resultInformation.isApprovedByGrade()
&& resultInformation.isApprovedByFrequency()
&& resultInformation.getGrades().recoveryExam().isEmpty()
&& resultInformation.getGrades().finalExam().isEmpty();
}

Melhorias

Uma primeira melhoria possível diz respeito à organização conceitual das abstrações. À medida que o sistema evolui, começamos a perceber que existem dois grandes eixos de decisão: o cálculo (como chegar a um número) e a interpretação (o que esse número significa dentro das regras acadêmicas). Separar explicitamente esses dois eixos em módulos ou pacotes distintos, por exemplo, calculation, approval, result e frequency, pode tornar o sistema ainda mais comunicativo. Isso facilita tanto a navegação no código quanto a compreensão por novos desenvolvedores.

Também podemos refletir sobre o uso extensivo de Factory Providers combinados com ServiceLoader. Embora extremamente flexível, esse mecanismo pode introduzir complexidade desnecessária em sistemas menores. Essa parte, no entanto, foi adicionado propositalmente para garantir o máximo de extensibilidade, de modo que todas as classes dependessem exclusivamente de abstarções, com exceção das factories.

Além disso, à medida que novas regras surgem (frequência, prova final, pesos variáveis etc.), fica evidente que o modelo começa a caminhar para algo mais próximo de um motor de regras. Uma melhoria estrutural seria representar cada regra como um objeto independente que avalia uma parte do ResultInformation e produz um fragmento de decisão. Em vez de múltiplas implementações com supports complexos, poderíamos compor regras menores em pipelines. Isso reduziria o risco de explosão combinacional no método supports que pode se tornar um problema grave. A ideia de adicionar toneladas de abstrações é não deixar esse tipo de problema contaminar a aplicação.

Por fim, e bastante óbvio, vale considerar testes automatizados como parte integrante do design. Cada abstração criada é pequena, especializada e altamente testável. A evolução do código demonstra que um design orientado a responsabilidades claras reduz drasticamente o impacto de mudanças. Formalizar isso com uma suíte de testes unitários bem estruturada consolidaria ainda mais a robustez do sistema, sobretudo nos dias atuais, onde boa parte desse trabalho pode ficar para os assistêntes de código movidos à inteligência artificial.

Conclusão

Este exercício mostra algo muito maior do que um simples cálculo de média escolar. Ele evidencia o contraste entre duas formas de pensar software: a abordagem direta, focada em resolver o problema imediato, e a abordagem arquitetural, focada em preparar o sistema para evoluir com qualidade e segurança.

No primeiro cenário, mover o CARD para DONE é rápido, simples e pragmático. Em muitos contextos, isso é exatamente o que se espera. Porém, conforme novas regras surgem, como as regras de reposição, atividades, pesos, prova final, frequência, relatórios detalhados, etc, percebemos que decisões estruturais tomadas no início podem determinar o custo de evolução futura.

Ao aplicar princípios como o de responsabilidade única, aberto/fechado, inversão de dependência e segregação de interfaces, o sistema se torna mais verboso, mas também mais resiliente. A complexidade deixa de estar concentrada em um único bloco monolítico e passa a ser distribuída em unidades especializadas e previsíveis.

O ponto central não é que sempre devemos escolher a arquitetura mais sofisticada. O verdadeiro aprendizado está em compreender o contexto:

  • Quando vale a pena ser simples?
  • Se for necessário, você consegue extrair as abstrações, entender o modelo de dados?
  • Quando vale a pena investir em extensibilidade?
  • Quando a abstração é excesso e quando ela é prevenção?

No fim das contas, esse exercício revela que design não é sobre escrever mais código, é sobre escrever código que aceita mudanças sem colapsar. E em sistemas reais, mudanças não são exceção. Elas são a regra.

Categorized in:

Uncategorized,

Last Update: 11/02/2026