Padrão de design para “operação permitida no objeto, apenas se o objeto estiver em determinado estado”

8

Por exemplo:

Somente solicitações de trabalho que ainda não estão em revisão ou aprovadas podem ser atualizadas. Em outras palavras, uma pessoa pode atualizar seu formulário de dispositivo de trabalho até que o RH comece a analisá-lo, ou ele já será aceito.

Portanto, um pedido de emprego pode estar em 4 estados:

APLICADO (estado inicial), IN_REVIEW, APROVADO, DECLINADO

Como faço para alcançar esse comportamento?

Certamente, posso escrever um método update () na classe Application, verificar o estado do aplicativo e não fazer nada ou lançar uma exceção se o aplicativo não estiver no estado necessário

Mas esse tipo de código não torna óbvio que essa regra existe, permite que qualquer pessoa chame o método update () e somente depois de falhar o cliente sabe que essa operação não era permitida. Portanto, o cliente precisa estar ciente de que tal tentativa pode falhar, portanto, tenha cuidado. O cliente estar ciente de tais coisas também significa que a lógica está vazando para fora.

Tentei criar classes diferentes para cada estado (ApprovedApplication etc.) e coloquei as operações permitidas apenas nas classes permitidas, mas esse tipo de abordagem também parece errado.

Existe um padrão oficial de design, ou um simples trecho de código, para implementar esse comportamento?

uylmz
fonte
7
Geralmente, essas coisas são chamadas de StateMachines, e sua implementação varia um pouco, dependendo de seus requisitos e do (s) idioma (s) com o qual você está trabalhando.
Telastyn
e como garantir que os métodos certos estejam disponíveis nos estados certos?
Ullmz
1
Depende do idioma. Classes diferentes é uma implementação comum para idiomas populares, embora "jogar se não estiver no estado correto" é provavelmente o mais comum.
Telastyn
1
Onde está o problema em incluir o método "canUpdate" e verificá-lo antes de chamar o Update?
Euphoric
1
this kind of code does not make it obvious such a rule exists- É por isso que o código possui documentação. Os escritores de bom código seguirão o conselho da Euphoric e fornecerão um método para permitir que pessoas de fora testem a regra antes de experimentar o hardware.
Blrfl

Respostas:

4

Esse tipo de situação aparece com bastante frequência. Por exemplo, os arquivos só podem ser manipulados enquanto estão abertos e, se você tentar fazer algo com um arquivo depois de fechado, você receberá uma exceção de tempo de execução.

Seu desejo ( expresso em sua pergunta anterior ) de usar o sistema de tipos da linguagem para garantir que algo errado não possa acontecer é nobre, pois erros em tempo de compilação são sempre preferíveis a erros em tempo de execução. No entanto, não há um padrão de design que conheço para esse tipo de situação, provavelmente porque acabaria causando mais problemas do que resolveria. (Seria impraticável.)

A coisa mais próxima da sua situação que eu conheço é modelar estados diferentes de um objeto que correspondam a recursos diferentes por meio de interfaces extras, mas dessa maneira você está reduzindo o número de locais no código onde pode ocorrer um erro de tempo de execução. não erradicar a possibilidade de um erro de tempo de execução.

Portanto, na sua situação, você declararia várias interfaces descrevendo o que pode ser feito com seu objeto em seus vários estados, e seu objeto retornaria uma referência à interface correta em uma transição de estado.

Então, por exemplo, o approve()método da sua classe retornaria uma ApprovedApplicationinterface. A interface seria implementada em particular (por meio de uma classe aninhada), de modo que o código que apenas faz referência a Applicationnão pode invocar nenhum dos ApprovedApplicationmétodos. Em seguida, o código que manipula um aplicativo aprovado declara explicitamente sua intenção de fazê-lo no momento da compilação, exigindo ApprovedApplicationque ele trabalhe. Mas, é claro, se você armazenar essa interface em algum lugar e continuar a usá-la após a decline()invocação do método, você ainda receberá um erro de tempo de execução. Eu não acho que exista uma solução perfeita para o seu problema.

Mike Nakis
fonte
Como uma observação lateral, deve ser application.approve (someoneWhoCanApprove) ou someoneWhoCanApprove.approve (application)? Eu acho que deveria ser a primeira vez que "alguém" pode não ter acesso aos campos de aplicação para fazer os ajustes necessários
uylmz
Não tenho certeza, mas você também deve examinar a possibilidade de que nenhum dos dois esteja correto. ou seja, if( someone.hasApprovalPermission( application ) ) { application.approve(); } o princípio da separação de preocupações indica que nem o aplicativo nem alguém deve se preocupar em tomar decisões sobre permissões e segurança.
quer
3

Estou acenando com a cabeça em diferentes partes das várias respostas, mas o OP parece ainda ter a preocupação de controlar o fluxo. Há muito para tentar se unir em palavras. Eu apenas vou corrigir algum código - O Padrão de Estado.


Nomes de estados como pretérito

"In_Review" não é um estado, talvez, mas uma transição ou processo. Caso contrário, os nomes dos seus estados devem ser consistentes: "Aplicando", "Aprovando", "Recusando" etc. etc. OU também "Revisado". Ou não.

O estado Aplicado faz uma transição de revisão e define o estado para Revisado. O estado revisado faz uma transição de aprovação e define o estado como Aprovado (ou Recusado).


// Application class encapsulates state transition,
// the client is unable to directly set state.
public class Application {
    State currentState = null;

    State AppliedState    = new Applied(this);
    State DeclinedState   = new Declined(this);
    State ApprovedState   = new Approved(this);
    State ReviewedState   = new Reviewed(this);

    public class Application (ApplicationDocument myApplication) {
        if(myApplication != null && isComplete()) {
            currentState = AppliedState;
        } else {            
            throw new ArgumentNullException ("Your application is incomplete");
            // some kind of error communication would probably be better
        }
    }

    public apply()    { currentState.apply(); }
    public review()   { currentState.review(); }
    public approve()  { currentState.approve(); }
    public decline()  { currentState.decline(); }


    //These could be done via an enum. I like enums!
    protected void setSubmittingState() {}
    protected void setApproveState() {}
    // etc. ...
}

// could be an interface if we don't have any default or base behavior.
public abstract class State {   
    protected Application theApp;
    // maybe these return an object communicating errors / error state.
    public abstract void apply();
    public abstract void review();
    public abstract void accept();
    public abstract void decline();
}

public class Applied implements State {
    public Applied (Application newApp) {
        if(newApp != null)
            theApp = newApp;
        else
            throw new ArgumentNullException ("null application argument");
     }

    public override void apply() {
        // whatever is appropriate when already in "applied" state
        // do not do any work on behalf of other states!
        // throwing exceptions here is not appropriate, as others
        // have said.
      }

    public override void review() {
        if(recursiveBureaucracyBuckPassing())
            theApp.setReviewedState();
    }

    public override void decline() { // ditto  }
}

public class Reviewed implements State {}
public class Approved implements State {}
public class Declined implements State {}

Editar - Comentários sobre o tratamento de erros

Um comentário recente:

... se você estiver tentando emprestar um livro que já foi emitido para outra pessoa, o modelo do livro conterá a lógica para impedir que seu estado seja alterado. Isso pode ser por meio de um valor de retorno (por exemplo, um código booleano yay / nay ou código de status) ou de uma exceção (por exemplo, IllegalStateChangeException) ou de algum outro meio. Independentemente dos meios escolhidos, este aspecto não é coberto como parte desta (ou de qualquer) resposta.

E a partir da pergunta original:

Mas esse tipo de código não torna óbvio que essa regra existe, permite que qualquer pessoa chame o método update () e somente depois de falhar o cliente sabe que essa operação não era permitida.

Há mais trabalho de design a ser feito. Não existe Unified Field Theory Pattern. A confusão vem do pressuposto de que a estrutura de transição de estado executará funções gerais de aplicativos e manipulação de erros. Isso parece errado, porque é. A resposta mostrada foi projetada para controlar a mudança de estado.


Certamente, posso escrever um método update () na classe Application, verificar o estado do aplicativo e não fazer nada ou lançar uma exceção se o aplicativo não estiver no estado necessário

Isso sugere que existem três funcionalidades trabalhando aqui: O Estado, Atualização e interação dos dois. Nesse caso, Applicationnão é o código que escrevi. Pode ser usado para determinar o estado atual. Applicationtambém não é applicationPaperwork. Applicationnão é a interação dos dois, mas poderia ser uma StateContextEvaluatorclasse geral . Agora Applicationorquestrará essas interações de componentes e, em seguida, agirá de acordo, como emitir uma mensagem de erro.

Finalizar edição

radarbob
fonte
Estou esquecendo de algo? Isso parece permitir a chamada de todos os quatro métodos, independentemente do estado, sem uma dica de como essa configuração deve ser usada para comunicar aos métodos de chamada que a chamada apply () não teve êxito devido a uma solicitação de exemplo.
kwah
1
permitir chamar todos os quatro métodos, independentemente do estado Sim. Isso deve. sem uma dica de como essa configuração deve ser usada para se comunicar com os métodos de chamada. Consulte o comentário no Applicationconstrutor em que a exceção é lançada. Talvez a chamada AppliedState.Approve()possa resultar em uma mensagem do usuário "O aplicativo deve ser revisado antes de poder ser aprovado".
Radarbob 26/12/15
1
... a chamada apply () não foi bem-sucedida porque já havia se inscrito, por exemplo . Isso é pensar errado. A chamada foi bem sucedida. Mas existe um comportamento diferente para diferentes estados. Esse é o padrão de estado ...... No entanto, o programador deve decidir qual comportamento é apropriado. Mas é errado pensar que "OMG, um erro !!! Precisamos nos apopléticos e abortar o programa!" Espero AppliedState.apply()lembrar gentilmente ao usuário que o aplicativo já foi enviado e está aguardando revisão. E o programa continua.
Radarbob 26/12/15
Presumindo que o padrão de estado esteja sendo usado como modelo, a "falha" deve ser comunicada à interface do usuário. Por exemplo, se você estiver tentando emprestar um livro que já foi emitido para outra pessoa, o modelo de Livro conterá a lógica para impedir que seu estado seja alterado. Isso pode ser por meio de um valor de retorno (por exemplo, um código booleano yay / nay ou código de status) ou de uma exceção (por exemplo, IllegalStateChangeException) ou de algum outro meio. Independentemente dos meios escolhidos, este aspecto não é coberto como parte desta (ou de qualquer) resposta.
Kwah 27/12/2015
Graças a Deus alguém disse isso. "Preciso de um comportamento diferente com base no estado de um objeto. ... Sim, sim. Você deseja o padrão de estado ." ++ feijão antigo.
RubberDuck
1

Em geral, o que você está descrevendo é um fluxo de trabalho. Mais especificamente, funções comerciais incorporadas por estados como REVISADO APROVADO ou RECUSADO se enquadram no cabeçalho de "regras de negócios" ou "lógica de negócios".

Mas, para ficar claro, as regras de negócios não devem ser codificadas em exceções. Fazer isso seria usar exceções para o controle de fluxo do programa, e há muitas boas razões para você não fazer isso. Exceções devem ser usadas para condições excepcionais, e o estado INVÁLIDO de um aplicativo é totalmente excepcional do ponto de vista comercial.

Use exceções nos casos em que o programa não pode se recuperar de uma condição de erro sem a intervenção do usuário ("arquivo não encontrado", por exemplo).

Não há um padrão específico para escrever lógica de negócios, além das técnicas usuais para organizar sistemas de processamento de dados de negócios e escrever código para implementar seus processos. Se as regras de negócios e o fluxo de trabalho forem elaborados, considere usar algum tipo de servidor de fluxo de trabalho ou mecanismo de regras de negócios.

De qualquer forma, os estados REVIEW, APPROVED, DECLINED etc. podem ser representados por uma variável privada do tipo enum em sua classe. Se você usar métodos getter / setter, poderá controlar se os setters permitirão ou não alterações, examinando primeiro o valor da variável enum. Se alguém tenta escrever para um setter quando o valor de enumeração está no estado errado, então você pode lançar uma exceção.

Robert Harvey
fonte
Existe um objeto chamado "Aplicativo", suas propriedades só podem ser alteradas se o "Estado" for "INICIAL". Esse não é um grande fluxo de trabalho, como documentos fluindo de um departamento para outro. O que deixo de fazer é refletir esse comportamento no sentido orientado a objetos.
uylmz
O aplicativo @Reek deve expor a interface de leitura / gravação, e a lógica de interação deve ocorrer em um nível superior. O candidato e o RH usam o mesmo objeto, mas têm privilégios diferentes - o objeto do aplicativo não deve se preocupar com isso. Exceções internas podem ser usadas para proteger a integração do sistema, mas não vou ficar na defensiva (a edição de informações de contato pode ser necessária mesmo para aplicativos aprovados - só é necessário um nível de acesso mais alto).
estremecer
1

Applicationpoderia ser uma interface e você poderia ter uma implementação para cada um dos estados. A interface poderia ter um moveToNextState()método, e isso ocultaria toda a lógica do fluxo de trabalho.

Para as necessidades do cliente, também pode haver um método retornando diretamente o que você pode fazer e não (ou seja, um conjunto de booleanos), em vez de apenas o estado, para que você não precise de uma "lista de verificação" no cliente (presumo mesmo assim, o cliente deve ser um controlador ou interface do usuário MVC).

No entanto, em vez de lançar uma exceção, você não pode fazer nada e registrar a tentativa. Isso é seguro no tempo de execução, as regras foram aplicadas e o cliente tinha maneiras de ocultar os controles de "atualização".

bigstones
fonte
1

Uma abordagem para esse problema que tem sido extremamente bem-sucedida na natureza é a hipermídia - a representação do estado da entidade é acompanhada por controles de hipermídia que descrevem os tipos de transições atualmente permitidas. O consumidor consulta os controles para descobrir o que pode ser feito.

É uma máquina de estado, com uma consulta em sua interface que permite descobrir quais eventos você pode disparar.

Em outras palavras: estamos descrevendo a web (REST).

Outra abordagem é levar sua idéia de interfaces diferentes para diferentes estados e fornecer uma consulta que permita detectar quais interfaces estão disponíveis no momento. Pense em IUnknown :: QueryInterface ou em baixa transmissão. O código do cliente toca Mother May I com o estado para descobrir o que é permitido.

É essencialmente o mesmo padrão - basta usar uma interface para representar os controles da hipermídia.

VoiceOfUnreason
fonte
Eu gosto disso. Poderia ser combinado com o padrão State para retornar uma coleção de estados válidos para os quais poderia ser feito a transição. A Cadeia de Comando vem à mente de certa forma.
21416 RubberDuck
1
Meu palpite é que você não deseja "coleção de estados válidos", mas "coleção de ações válidas". Pense no gráfico: você deseja o nó atual (estado) e a lista de arestas (ações). Você descobrirá o próximo estado quando escolher sua ação.
VoiceOfUnreason
Sim. Você está certo. Uma coleção de ações válidas nas quais essa ação é realmente uma transição de estado (ou algo que desencadeia uma).
precisa
1

Aqui está um exemplo de como você pode abordar isso de uma perspectiva funcional e como isso ajuda a evitar as possíveis armadilhas. Estou trabalhando em Haskell, que presumo que você não saiba, então explicarei em detalhes à medida que for avançando.

data Application = Applied ApplicationDetails |
                   InReview ApplicationDetails |
                   Approved ApplicationDetails |
                   Declined ApplicationDetails

Isso define um tipo de dados que pode estar em um dos quatro estados que correspondem aos estados do seu aplicativo. ApplicationDetailsé considerado um tipo existente que contém as informações detalhadas.

newtype UpdatableApplication = UpdatableApplication Application

Um alias de tipo que precisa de conversão explícita de e para Application. Isso significa que, se definirmos a seguinte função que aceita e desembrulha um UpdatableApplicatione faz algo útil com ele,

updateApplication :: UpdatableApplication -> ApplicationDetails -> Application
updateApplication (UpdatableApplication app) details = ...

precisamos converter explicitamente o aplicativo em um UpdatableApplication antes de podermos usá-lo. Isso é feito usando esta função:

findUpdatableApplication :: Application -> Maybe UpdatableApplication
findUpdatableApplication app@(Applied _) = Just (UpdatableApplication app)
findUpdatableApplication _               = Nothing

Aqui fazemos três coisas interessantes:

  • Verificamos o estado do aplicativo (usando a correspondência de padrões, o que é realmente útil para esse tipo de código) e
  • se puder ser atualizado, o envolveremos em um UpdatableApplication(que envolve apenas uma nota de tipo de compilação da alteração do tipo que está sendo adicionada, pois Haskell possui um recurso específico para fazer esse tipo de truque no nível de tipo, não custa nada em tempo de execução) e
  • retornamos o resultado em um "Talvez" (semelhante a OptionC # ou Optionalem Java - é um objeto que envolve um resultado que pode estar ausente).

Agora, para realmente montar isso, precisamos chamar essa função e, se o resultado for bem-sucedido, passá-lo para a função de atualização ...

case findUpdatableApplication application of
    Just updatableApplication -> do
        storeApplicationInDatabase (updateApplication updatableApplication)
        showConfirmationPage
    Nothing -> do
        showErrorPage

Como a updateApplicationfunção precisa do objeto empacotado, não podemos esquecer de verificar as pré-condições. E como a função de verificação de pré-condição retorna o objeto embrulhado dentro de um Maybeobjeto, não podemos esquecer de verificar o resultado e responder de acordo se houver falha.

Agora ... você pode fazer isso em uma linguagem orientada a objetos. Mas é menos conveniente:

  • Nenhuma das linguagens OO que eu tentei tem uma sintaxe simples para criar um tipo de invólucro com segurança de tipo, então isso é padrão.
  • Também será menos eficiente, porque, pelo menos para a maioria dos idiomas, eles não poderão eliminar o tipo de wrapper, pois será necessário que ele exista e seja detectável em tempo de execução (Haskell não tem verificação de tipo de tempo de execução, todas as verificações de tipo são executada em tempo de compilação).
  • Embora algumas linguagens OO tenham tipos equivalentes, Maybeelas geralmente não têm uma maneira conveniente de extrair os dados e escolher o caminho a seguir ao mesmo tempo. A correspondência de padrões também é realmente útil aqui.
Jules
fonte
1

Você pode usar o padrão «command» e pedir ao Invoker para fornecer uma lista de funções válidas de acordo com o estado da classe do receptor.

Eu usei o mesmo para fornecer funcionalidade a diferentes interfaces que deveriam chamar meu código; algumas das opções não estavam disponíveis dependendo do estado atual do registro; portanto, meu invocador atualizou a lista e, dessa forma, toda GUI solicitou ao Invocador quais opções estavam disponíveis e eles se pintaram de acordo.

bns
fonte