← Blog

Substituição de Liskov em MuleSoft: implementações que se substituem

Código deste artigo: src/main/mule/lsp · contrato: lsp-order-after.raml · testes: lsp-test-suite.xml

O princípio de Barbara Liskov diz que, se um código funciona com um tipo, ele precisa continuar funcionando com qualquer subtipo desse tipo. O Mule não tem classes nem subtipos, então é tentador concluir que o LSP não se aplica. Ele se aplica — mas é preciso procurá-lo no lugar certo.

Onde o LSP aparece em integração

A primeira versão deste artigo associava o LSP a “tipos de pedido”: pedidos padrão, expresso e internacional devolvendo respostas consistentes. Isso é, na verdade, uma regra de consistência de contrato, e força o princípio. A situação em que a substituição acontece de fato no Mule é esta: várias implementações ficam atrás de um mesmo contrato, e o consumidor não deveria saber qual delas respondeu.

  • Uma process API que lê pedidos de um ERP antigo e, depois de uma migração, de um novo.
  • Uma system API com um backend de produção e um stub usado nos testes.
  • Dois sistemas regionais expostos pela mesma experience API.

Liskov também insistia que o contrato trata de comportamento, não só de assinaturas. Para uma API isso significa três coisas: o formato dos dados, o significado dos valores e a forma de reportar erros.

O contrato

#%RAML 1.0 DataType
type: object
properties:
  orderId: string
  status:
    enum: [PENDING, SHIPPED]
  totalPrice: number
# E, no comportamento: pedido inexistente é 404, nunca um 200 vazio.

Um flow consumidor se apoia nele para decidir se o pedido pode ser cancelado:

<sub-flow name="lsp-after-summarise-order">
    <ee:transform>
        <ee:message>
            <ee:set-payload><![CDATA[%dw 2.0
output application/json
---
payload ++ { canCancel: payload.status == "PENDING" }]]></ee:set-payload>
        </ee:message>
    </ee:transform>
</sub-flow>

Antes: um adaptador que parece compatível e não é

O adaptador do ERP legado traduz os códigos dele (P, S) para o contrato. O adaptador do ERP novo foi escrito às pressas: o sistema novo já usa nomes legíveis, então ele copia os valores como vêm.

<sub-flow name="lsp-before-provider-new-erp">
    <!-- o ERP novo devolve { id, state: "pending", amount: "120.00" } -->
    <ee:transform>
        <ee:message>
            <ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
    orderId: payload.id,
    status: payload.state,
    totalPrice: payload.amount
}]]></ee:set-payload>
        </ee:message>
    </ee:transform>
</sub-flow>

Todos os nomes de campo batem, então uma checagem de schema só pelos nomes passaria. O comportamento, não:

  • status vem como "pending", e canCancel vira false para um pedido que pode ser cancelado.
  • totalPrice é o texto "120.00", não um número.
  • Um pedido inexistente devolve um 200 vazio em vez de 404.

Nada falha. O consumidor devolve uma resposta confiante e errada — o tipo mais caro de bug em integração.

Depois: os dois adaptadores respeitam o contrato inteiro

<sub-flow name="lsp-after-provider-new-erp">
    <ee:transform>...</ee:transform>                    <!-- chama o ERP novo -->
    <choice>
        <when expression="#[payload == null]">
            <raise-error type="APP:NOT_FOUND" description="Order not found" />
        </when>
    </choice>
    <ee:transform>
        <ee:message>
            <ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
    orderId: payload.id,
    status: upper(payload.state),
    totalPrice: payload.amount as Number
}]]></ee:set-payload>
        </ee:message>
    </ee:transform>
</sub-flow>

Agora o adaptador traduz vocabulário, tipos e erros. Chame GET /lsp/after/orders/ORD-1001?provider=legacy e ?provider=new: as respostas são idênticas.

A evidência

A suíte MUnit faz as mesmas perguntas aos dois provedores. Na versão antes, o provedor novo devolve um canCancel diferente, um preço em texto e um 200 para um pedido inexistente — cada um verificado explicitamente. Na versão depois, o pedido pendente, o pedido enviado e o pedido inexistente dão exatamente o mesmo resultado nos dois provedores. Isso é substituição, testada.

Duas nuances

  • Acrescentar um campo opcional costuma ser seguro. Consumidores feitos para tolerar o desconhecido ignoram campos novos. O que quebra a substituição é remover, renomear ou mudar o tipo de um campo — ou manter o nome e mudar o significado, como acima.
  • A entrada também conta. Uma implementação que exige um campo obrigatório a mais na requisição não é substituível, mesmo que as respostas sejam perfeitas.

Checklist

  • Existe um contrato escrito contra o qual todas as implementações são testadas?
  • Ele cobre os vocabulários de valores (enums, unidades, formatos), e não só os nomes dos campos?
  • Todas as implementações reportam “não encontrado” e entrada inválida da mesma forma?
  • Dá para trocar uma implementação por outra na configuração e obter os mesmos resultados de teste?